From 320d49d5de95edbd05b7a88a05f6106bd51ad433 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 16:45:28 -0400 Subject: [PATCH 1/4] OpenConceptLab/ocl_online#451 | fhir_next.yml: dispatch one ref to the fhir-next staging service, behind the staging approval Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019okcdQVD6Kzzdr3gZZBryz --- .github/workflows/fhir_next.yml | 184 ++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 .github/workflows/fhir_next.yml diff --git a/.github/workflows/fhir_next.yml b/.github/workflows/fhir_next.yml new file mode 100644 index 000000000..bbce480b2 --- /dev/null +++ b/.github/workflows/fhir_next.yml @@ -0,0 +1,184 @@ +name: FHIR next on staging + +# Deploys one ref of the FHIR branch (OpenConceptLab/ocl_online#451) to its own staging service, which shares +# staging's database, Elasticsearch and Redis. It pushes only fhir-next* image tags and redeploys only that service: +# never the :staging or :production tags, a release, or another service. Remove it once the branch has merged. +on: + workflow_dispatch: + inputs: + ref: + description: "Branch, tag or commit to deploy" + required: true + default: "ocl_online/451-fhir-svcm" + type: string + accept_staging_skew: + description: "Deploy even if the ref and staging's build differ in migrations, documents.py or tasks.py" + required: false + default: false + type: boolean + +# The newest dispatch wins, even while an older one waits at the approval gate. +concurrency: + group: fhir-next-staging + cancel-in-progress: true + +permissions: + contents: read + +env: + AWS_REGION: "us-east-2" + ECR_REPOSITORY: "oclapi2" + ECS_CLUSTER_STAGING_PRODUCTION: "ocl-staging-production" + ECS_SERVICE_FHIR_NEXT: "staging-fhir-next" + STAGING_API_URL: "https://api.staging.openconceptlab.org" + FHIR_NEXT_URL: "https://fhir-next.staging.openconceptlab.org" + +jobs: + guard: + name: Guard shared state + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.resolve.outputs.sha }} + short: ${{ steps.resolve.outputs.short }} + steps: + - name: Checkout ref + uses: actions/checkout@v3 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + - name: Resolve the ref to a commit + id: resolve + run: | + sha=$(git rev-parse --verify 'HEAD^{commit}') + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "short=${sha:0:8}" >> "$GITHUB_OUTPUT" + - name: Fail if the ref changes, or doesn't match staging's, migrations, documents.py or tasks.py + env: + ACCEPT_STAGING_SKEW: ${{ inputs.accept_staging_skew }} + run: | + # The service shares staging's DB and ES, and master's Celery workers run what it enqueues. So the ref must + # not change these files, and must match the build staging runs in them (staging can lag master). + shared='(^|/)migrations/|(^|/)documents\.py$|(^|/)tasks\.py$' + own=$(git diff --name-only origin/master...HEAD | grep -E "$shared" || true) + if [ -n "$own" ]; then + echo "::error::The ref changes files staging shares with master's code. Keep these changes out of it:" + echo "$own" + exit 1 + fi + staging_build=$(curl --silent --fail --max-time 30 "$STAGING_API_URL/version/" | tr -d '"') + staging_sha=$(git rev-parse --verify "${staging_build##*-}^{commit}") + skew=$(git diff --name-only "$staging_sha" HEAD | grep -E "$shared" || true) + if [ -n "$skew" ] && [ "$ACCEPT_STAGING_SKEW" = "true" ]; then + echo "::warning::Deploying although the ref and staging's build $staging_build differ in: ${skew//$'\n'/ }" + elif [ -n "$skew" ]; then + echo "::error::The ref and staging's build $staging_build differ in these files. Merge origin/master into the ref if staging is ahead of it; if the ref is ahead, wait until staging runs master's change, or dispatch again with accept_staging_skew once you've judged the difference safe:" + echo "$skew" + exit 1 + fi + + build: + name: Build and push image + needs: [guard] + runs-on: ubuntu-latest + steps: + - name: Free Disk Space + uses: jlumbroso/free-disk-space@main + with: + android: true + dotnet: true + haskell: true + tool-cache: false + docker-images: false + large-packages: false + swap-storage: false + - name: Checkout ref + uses: actions/checkout@v3 + with: + ref: ${{ needs.guard.outputs.sha }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # Built before any AWS credentials exist in this job. + - name: Build Docker image + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + with: + context: . + load: true + tags: oclapi2:fhir-next-${{ needs.guard.outputs.short }} + build-args: SOURCE_COMMIT=${{ needs.guard.outputs.short }} + cache-from: | + type=gha + type=gha,scope=fhir-next + cache-to: type=gha,mode=min,scope=fhir-next + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + - name: Push image to Amazon ECR + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: fhir-next-${{ needs.guard.outputs.short }} + run: | + docker tag "oclapi2:$IMAGE_TAG" "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" + docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" + + deploy: + name: Deploy to fhir-next.staging + needs: [guard, build] + runs-on: ubuntu-latest + environment: staging + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + # Moved only after approval: the service's task definition (owned by Terraform) runs :fhir-next, and any + # new deployment, a staging refresh's restart included, pulls whatever the tag points at. + - name: Point fhir-next at this build + env: + IMAGE_TAG: fhir-next-${{ needs.guard.outputs.short }} + run: | + read -r media_type digest <<< "$(aws ecr describe-images --repository-name "$ECR_REPOSITORY" \ + --image-ids imageTag="$IMAGE_TAG" --query 'imageDetails[0].[imageManifestMediaType,imageDigest]' --output text)" + current=$(aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag=fhir-next \ + --query 'imageDetails[0].imageDigest' --output text 2>/dev/null || true) + if [ "$current" = "$digest" ]; then + echo "fhir-next already points at $IMAGE_TAG" + else + manifest=$(aws ecr batch-get-image --repository-name "$ECR_REPOSITORY" --image-ids imageTag="$IMAGE_TAG" \ + --accepted-media-types "$media_type" --query 'images[0].imageManifest' --output text) + aws ecr put-image --repository-name "$ECR_REPOSITORY" --image-tag fhir-next --image-manifest "$manifest" \ + --image-manifest-media-type "$media_type" --query 'image.imageId.imageDigest' --output text + fi + - name: Redeploy the service + run: | + aws ecs update-service --cluster "$ECS_CLUSTER_STAGING_PRODUCTION" --service "$ECS_SERVICE_FHIR_NEXT" \ + --force-new-deployment --query 'service.[serviceName,taskDefinition,desiredCount]' --output text + - name: Wait for the service to be stable + run: | + # Each wait gives up after 10 minutes, and a first start on a host pulls the whole image. + for attempt in 1 2 3; do + if aws ecs wait services-stable --cluster "$ECS_CLUSTER_STAGING_PRODUCTION" --services "$ECS_SERVICE_FHIR_NEXT"; then + exit 0 + fi + echo "Not stable after wait $attempt" + done + aws ecs describe-services --cluster "$ECS_CLUSTER_STAGING_PRODUCTION" --services "$ECS_SERVICE_FHIR_NEXT" \ + --query 'services[0].events[:5].message' --output text + exit 1 + - name: Check the served build + env: + SHORT_SHA: ${{ needs.guard.outputs.short }} + run: | + served=$(curl --silent --fail --max-time 30 "$FHIR_NEXT_URL/version/") + echo "Served: $served" + case "$served" in + *"-$SHORT_SHA\"") echo "fhir-next serves $SHORT_SHA" ;; + *) echo "::error::fhir-next serves $served, expected a build ending in $SHORT_SHA"; exit 1 ;; + esac From 137e3ed7d7d68b7af3fe31e2d8fca03a1781a77a Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 16:52:11 -0400 Subject: [PATCH 2/4] OpenConceptLab/ocl_online#451 | Codex pass 1 fixes: recheck staging's build after approval, before credentials (F1.1); diffs without rename detection (F1.2); git failures and pipelines fail the step (F1.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019okcdQVD6Kzzdr3gZZBryz --- .github/workflows/fhir_next.yml | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/.github/workflows/fhir_next.yml b/.github/workflows/fhir_next.yml index bbce480b2..c56a52735 100644 --- a/.github/workflows/fhir_next.yml +++ b/.github/workflows/fhir_next.yml @@ -25,6 +25,11 @@ concurrency: permissions: contents: read +# bash -eo pipefail: a failed command anywhere in a pipeline fails the step. +defaults: + run: + shell: bash + env: AWS_REGION: "us-east-2" ECR_REPOSITORY: "oclapi2" @@ -32,6 +37,9 @@ env: ECS_SERVICE_FHIR_NEXT: "staging-fhir-next" STAGING_API_URL: "https://api.staging.openconceptlab.org" FHIR_NEXT_URL: "https://fhir-next.staging.openconceptlab.org" + # The service shares staging's DB and ES, and master's Celery workers run what it enqueues. So a ref must not change + # these files, and must match the build staging runs in them (staging can lag master). + SHARED_PATHS: '(^|/)migrations/|(^|/)documents\.py$|(^|/)tasks\.py$' jobs: guard: @@ -46,6 +54,7 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: 0 + persist-credentials: false - name: Resolve the ref to a commit id: resolve run: | @@ -56,10 +65,9 @@ jobs: env: ACCEPT_STAGING_SKEW: ${{ inputs.accept_staging_skew }} run: | - # The service shares staging's DB and ES, and master's Celery workers run what it enqueues. So the ref must - # not change these files, and must match the build staging runs in them (staging can lag master). - shared='(^|/)migrations/|(^|/)documents\.py$|(^|/)tasks\.py$' - own=$(git diff --name-only origin/master...HEAD | grep -E "$shared" || true) + # --no-renames lists a renamed file under its old path as well. A failed diff fails the step. + changed=$(git diff --name-only --no-renames origin/master...HEAD) + own=$(grep -E "$SHARED_PATHS" <<< "$changed" || true) if [ -n "$own" ]; then echo "::error::The ref changes files staging shares with master's code. Keep these changes out of it:" echo "$own" @@ -67,7 +75,8 @@ jobs: fi staging_build=$(curl --silent --fail --max-time 30 "$STAGING_API_URL/version/" | tr -d '"') staging_sha=$(git rev-parse --verify "${staging_build##*-}^{commit}") - skew=$(git diff --name-only "$staging_sha" HEAD | grep -E "$shared" || true) + changed=$(git diff --name-only --no-renames "$staging_sha" HEAD) + skew=$(grep -E "$SHARED_PATHS" <<< "$changed" || true) if [ -n "$skew" ] && [ "$ACCEPT_STAGING_SKEW" = "true" ]; then echo "::warning::Deploying although the ref and staging's build $staging_build differ in: ${skew//$'\n'/ }" elif [ -n "$skew" ]; then @@ -95,6 +104,7 @@ jobs: uses: actions/checkout@v3 with: ref: ${{ needs.guard.outputs.sha }} + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 # Built before any AWS credentials exist in this job. @@ -132,6 +142,29 @@ jobs: runs-on: ubuntu-latest environment: staging steps: + # Staging may have deployed another build while this run waited for approval: compare again, before any AWS + # credentials exist in this job. + - name: Checkout ref + uses: actions/checkout@v3 + with: + ref: ${{ needs.guard.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Fail if the ref doesn't match staging's current build in migrations, documents.py or tasks.py + env: + ACCEPT_STAGING_SKEW: ${{ inputs.accept_staging_skew }} + run: | + staging_build=$(curl --silent --fail --max-time 30 "$STAGING_API_URL/version/" | tr -d '"') + staging_sha=$(git rev-parse --verify "${staging_build##*-}^{commit}") + changed=$(git diff --name-only --no-renames "$staging_sha" HEAD) + skew=$(grep -E "$SHARED_PATHS" <<< "$changed" || true) + if [ -n "$skew" ] && [ "$ACCEPT_STAGING_SKEW" = "true" ]; then + echo "::warning::Deploying although the ref and staging's build $staging_build differ in: ${skew//$'\n'/ }" + elif [ -n "$skew" ]; then + echo "::error::Staging now runs $staging_build, which differs from the ref in these files. Dispatch again once the ref matches, or with accept_staging_skew once you've judged the difference safe:" + echo "$skew" + exit 1 + fi - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: From def98b94eaf1b0a408ad652cbb0563fc964d39b4 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 16:58:52 -0400 Subject: [PATCH 3/4] OpenConceptLab/ocl_online#451 | simplification: one staging-skew step, run in the guard and again after approval (YAML alias, S1); shorter served-build check (S4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019okcdQVD6Kzzdr3gZZBryz --- .github/workflows/fhir_next.yml | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/.github/workflows/fhir_next.yml b/.github/workflows/fhir_next.yml index c56a52735..60c05a8b7 100644 --- a/.github/workflows/fhir_next.yml +++ b/.github/workflows/fhir_next.yml @@ -61,9 +61,7 @@ jobs: sha=$(git rev-parse --verify 'HEAD^{commit}') echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "short=${sha:0:8}" >> "$GITHUB_OUTPUT" - - name: Fail if the ref changes, or doesn't match staging's, migrations, documents.py or tasks.py - env: - ACCEPT_STAGING_SKEW: ${{ inputs.accept_staging_skew }} + - name: Fail if the ref changes migrations, documents.py or tasks.py run: | # --no-renames lists a renamed file under its old path as well. A failed diff fails the step. changed=$(git diff --name-only --no-renames origin/master...HEAD) @@ -73,6 +71,12 @@ jobs: echo "$own" exit 1 fi + # The deploy job runs this step again after approval: staging may deploy another build while this run waits. + - &staging-skew + name: Fail if the ref and staging's build differ in migrations, documents.py or tasks.py + env: + ACCEPT_STAGING_SKEW: ${{ inputs.accept_staging_skew }} + run: | staging_build=$(curl --silent --fail --max-time 30 "$STAGING_API_URL/version/" | tr -d '"') staging_sha=$(git rev-parse --verify "${staging_build##*-}^{commit}") changed=$(git diff --name-only --no-renames "$staging_sha" HEAD) @@ -150,21 +154,7 @@ jobs: ref: ${{ needs.guard.outputs.sha }} fetch-depth: 0 persist-credentials: false - - name: Fail if the ref doesn't match staging's current build in migrations, documents.py or tasks.py - env: - ACCEPT_STAGING_SKEW: ${{ inputs.accept_staging_skew }} - run: | - staging_build=$(curl --silent --fail --max-time 30 "$STAGING_API_URL/version/" | tr -d '"') - staging_sha=$(git rev-parse --verify "${staging_build##*-}^{commit}") - changed=$(git diff --name-only --no-renames "$staging_sha" HEAD) - skew=$(grep -E "$SHARED_PATHS" <<< "$changed" || true) - if [ -n "$skew" ] && [ "$ACCEPT_STAGING_SKEW" = "true" ]; then - echo "::warning::Deploying although the ref and staging's build $staging_build differ in: ${skew//$'\n'/ }" - elif [ -n "$skew" ]; then - echo "::error::Staging now runs $staging_build, which differs from the ref in these files. Dispatch again once the ref matches, or with accept_staging_skew once you've judged the difference safe:" - echo "$skew" - exit 1 - fi + - *staging-skew - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: @@ -210,8 +200,5 @@ jobs: SHORT_SHA: ${{ needs.guard.outputs.short }} run: | served=$(curl --silent --fail --max-time 30 "$FHIR_NEXT_URL/version/") - echo "Served: $served" - case "$served" in - *"-$SHORT_SHA\"") echo "fhir-next serves $SHORT_SHA" ;; - *) echo "::error::fhir-next serves $served, expected a build ending in $SHORT_SHA"; exit 1 ;; - esac + [[ $served == *"-$SHORT_SHA\"" ]] || { echo "::error::fhir-next serves $served, expected a build ending in $SHORT_SHA"; exit 1; } + echo "fhir-next serves $SHORT_SHA" From eef2fffade8a5af99703d25553336d63c347e066 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 14 Oct 2026 17:22:05 -0400 Subject: [PATCH 4/4] OpenConceptLab/ocl_online#451 | adversarial pass fixes: no Actions cache in the ref's build (F1.2); free-disk-space pinned (F1.3); run and deploy-job names show the ref and commit (F1.4); wait out a staging-api deploy before the recheck; a missing image fails the step (F1.7) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019okcdQVD6Kzzdr3gZZBryz --- .github/workflows/fhir_next.yml | 35 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/.github/workflows/fhir_next.yml b/.github/workflows/fhir_next.yml index 60c05a8b7..d95ea8d8a 100644 --- a/.github/workflows/fhir_next.yml +++ b/.github/workflows/fhir_next.yml @@ -1,4 +1,6 @@ name: FHIR next on staging +# Display only, so the approver sees what this run deploys; the deploy job's name adds the commit. +run-name: "FHIR next on staging: ${{ inputs.ref }}${{ inputs.accept_staging_skew && ' (staging skew accepted)' || '' }}" # Deploys one ref of the FHIR branch (OpenConceptLab/ocl_online#451) to its own staging service, which shares # staging's database, Elasticsearch and Redis. It pushes only fhir-next* image tags and redeploys only that service: @@ -38,7 +40,8 @@ env: STAGING_API_URL: "https://api.staging.openconceptlab.org" FHIR_NEXT_URL: "https://fhir-next.staging.openconceptlab.org" # The service shares staging's DB and ES, and master's Celery workers run what it enqueues. So a ref must not change - # these files, and must match the build staging runs in them (staging can lag master). + # these files, and must match the build staging runs in them (staging can lag master). That holds at deploy time + # only: after a staging deploy that changes them, dispatch again with master merged, or stop the service. SHARED_PATHS: '(^|/)migrations/|(^|/)documents\.py$|(^|/)tasks\.py$' jobs: @@ -57,10 +60,13 @@ jobs: persist-credentials: false - name: Resolve the ref to a commit id: resolve + env: + REF: ${{ inputs.ref }} run: | sha=$(git rev-parse --verify 'HEAD^{commit}') echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "short=${sha:0:8}" >> "$GITHUB_OUTPUT" + echo "Deploys \`$REF\` at $sha: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/master...$sha" >> "$GITHUB_STEP_SUMMARY" - name: Fail if the ref changes migrations, documents.py or tasks.py run: | # --no-renames lists a renamed file under its old path as well. A failed diff fails the step. @@ -94,8 +100,9 @@ jobs: needs: [guard] runs-on: ubuntu-latest steps: + # Pinned: this job later holds the AWS credentials. - name: Free Disk Space - uses: jlumbroso/free-disk-space@main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 with: android: true dotnet: true @@ -111,7 +118,8 @@ jobs: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # Built before any AWS credentials exist in this job. + # Built before any AWS credentials exist in this job, and without the Actions cache: a ref's Dockerfile can name + # its own frontend, which would receive the cache token of the default branch that build.yml restores from. - name: Build Docker image uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 with: @@ -119,10 +127,6 @@ jobs: load: true tags: oclapi2:fhir-next-${{ needs.guard.outputs.short }} build-args: SOURCE_COMMIT=${{ needs.guard.outputs.short }} - cache-from: | - type=gha - type=gha,scope=fhir-next - cache-to: type=gha,mode=min,scope=fhir-next - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: @@ -141,34 +145,39 @@ jobs: docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" deploy: - name: Deploy to fhir-next.staging + name: Deploy ${{ needs.guard.outputs.short }} to fhir-next.staging needs: [guard, build] runs-on: ubuntu-latest environment: staging steps: - # Staging may have deployed another build while this run waited for approval: compare again, before any AWS - # credentials exist in this job. - name: Checkout ref uses: actions/checkout@v3 with: ref: ${{ needs.guard.outputs.sha }} fetch-depth: 0 persist-credentials: false - - *staging-skew - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ env.AWS_REGION }} + # A fhir-next task placed during a staging-api deploy can take the room the new API task needs, and staging's + # /version/ names the new build only once that deploy is done. + - name: Wait until no staging-api deploy is in progress + run: aws ecs wait services-stable --cluster "$ECS_CLUSTER_STAGING_PRODUCTION" --services staging-api + # Staging may have deployed another build while this run waited for approval: compare again. + - *staging-skew # Moved only after approval: the service's task definition (owned by Terraform) runs :fhir-next, and any # new deployment, a staging refresh's restart included, pulls whatever the tag points at. - name: Point fhir-next at this build env: IMAGE_TAG: fhir-next-${{ needs.guard.outputs.short }} run: | - read -r media_type digest <<< "$(aws ecr describe-images --repository-name "$ECR_REPOSITORY" \ - --image-ids imageTag="$IMAGE_TAG" --query 'imageDetails[0].[imageManifestMediaType,imageDigest]' --output text)" + details=$(aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag="$IMAGE_TAG" \ + --query 'imageDetails[0].[imageManifestMediaType,imageDigest]' --output text) + read -r media_type digest <<< "$details" + [[ $digest == sha256:* ]] || { echo "::error::No image found for $IMAGE_TAG: $details"; exit 1; } current=$(aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag=fhir-next \ --query 'imageDetails[0].imageDigest' --output text 2>/dev/null || true) if [ "$current" = "$digest" ]; then