@@ -215,15 +215,18 @@ jobs:
215215 env :
216216 ECR_REPO : ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }}
217217
218- # App leg only: capture the digest the :dev tag currently points at, BEFORE
219- # this build overwrites it, so promote-trigger-dev can tell whether the app
220- # image actually changed (a no-op :dev push triggers no ECS deploy).
221- - name : Capture previous :dev app digest
218+ # App leg only: stamp the trigger epoch and capture the :dev digest BEFORE the
219+ # build/push overwrites it. Stamping the epoch pre-build (not after the push,
220+ # like it was) guarantees it precedes the :dev push that triggers the pipeline,
221+ # so the dev ECS execution's startTime can't land before the epoch and get
222+ # rejected by the cutover poll. The digest read uses the ECR API so an absent
223+ # tag ("None", first deploy → changed) is distinct from a read error.
224+ - name : Capture pre-build :dev state
222225 id : prevdigest
223226 if : matrix.ecr_repo_secret == 'ECR_APP'
224227 run : |
225- REF="${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev "
226- PREV=$(docker buildx imagetools inspect "$REF" 2>/dev/null | awk '/^Digest:/{print $2; exit}' || true)
228+ echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT "
229+ PREV="$(aws ecr batch-get-image --repository-name "${{ steps.ecr-repo.outputs.name }}" --image-ids imageTag=dev --query 'images[0].imageId.imageDigest' --output text 2>/dev/null)" || PREV="__ERR__"
227230 echo "digest=${PREV}" >> "$GITHUB_OUTPUT"
228231
229232 - name : Build and push
@@ -239,16 +242,22 @@ jobs:
239242 # App leg only: publish the metadata promote-trigger-dev needs to correlate
240243 # this push to its dev ECS deploy and decide whether to wait. Dev has no
241244 # promote-images job, so this stands in for its retag_epoch/app_image_changed
242- # outputs. The epoch is recorded just after the :dev push ( the pipeline trigger) .
245+ # outputs. The epoch and prev digest come from the pre-build step above .
243246 - name : Publish dev cutover metadata
244247 if : matrix.ecr_repo_secret == 'ECR_APP'
245248 run : |
246249 mkdir -p dev-meta
247250 NEW="${{ steps.build.outputs.digest }}"
248251 PREV="${{ steps.prevdigest.outputs.digest }}"
252+ if [ -z "$NEW" ]; then
253+ echo "ERROR: build did not report an image digest" >&2
254+ exit 1
255+ fi
249256 echo "$NEW" > dev-meta/digest.txt
250- date +%s > dev-meta/retag_epoch.txt
251- if [ -n "$NEW" ] && [ "$NEW" = "$PREV" ]; then
257+ echo "${{ steps.prevdigest.outputs.epoch }}" > dev-meta/retag_epoch.txt
258+ # PREV=__ERR__ (read failed) falls through to changed=true (wait) — the safe
259+ # direction. A real no-op is detected when the read succeeds and matches.
260+ if [ "$PREV" != "__ERR__" ] && [ "$NEW" = "$PREV" ]; then
252261 echo "false" > dev-meta/app_image_changed.txt
253262 echo "ℹ️ :dev already points at ${NEW}; no ECS dev deploy will be triggered."
254263 else
@@ -311,7 +320,16 @@ jobs:
311320 exit 1
312321 fi
313322 bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim --skip-promotion 2>&1 | tee deploy.log
314- VERSION=$(sed -E 's/\x1b\[[0-9;]*m//g' deploy.log | grep -oE '20[0-9]{6}\.[0-9]+' | tail -n1 || true)
323+ # Anchor on the "version" keyword and take the FIRST match: with
324+ # --skip-promotion the CLI can print the unchanged current version AFTER
325+ # the one it just deployed, and dashboard URLs carry other IDs — so a bare
326+ # last-match could promote the wrong version. Fall back to a bare
327+ # first-match only if no version-labelled line is present.
328+ CLEAN=$(sed -E 's/\x1b\[[0-9;]*m//g' deploy.log)
329+ VERSION=$(printf '%s\n' "$CLEAN" | grep -oiE 'version[[:space:]]+v?20[0-9]{6}\.[0-9]+' | grep -oE '20[0-9]{6}\.[0-9]+' | head -n1 || true)
330+ if [ -z "$VERSION" ]; then
331+ VERSION=$(printf '%s\n' "$CLEAN" | grep -oE '20[0-9]{6}\.[0-9]+' | head -n1 || true)
332+ fi
315333 if [ -z "$VERSION" ]; then
316334 echo "ERROR: could not parse deployed version from deploy output" >&2
317335 exit 1
@@ -484,7 +502,16 @@ jobs:
484502 fi
485503 bunx trigger.dev@4.4.3 deploy --env "$TRIGGER_ENV" --skip-promotion 2>&1 | tee deploy.log
486504 # Extract the deployed version (e.g. 20260715.2) tied to THIS invocation.
487- VERSION=$(sed -E 's/\x1b\[[0-9;]*m//g' deploy.log | grep -oE '20[0-9]{6}\.[0-9]+' | tail -n1 || true)
505+ # Anchor on the "version" keyword and take the FIRST match: with
506+ # --skip-promotion the CLI can print the unchanged current version AFTER
507+ # the one it just deployed, and dashboard URLs carry other IDs — so a bare
508+ # last-match could promote the wrong version. Fall back to a bare
509+ # first-match only if no version-labelled line is present.
510+ CLEAN=$(sed -E 's/\x1b\[[0-9;]*m//g' deploy.log)
511+ VERSION=$(printf '%s\n' "$CLEAN" | grep -oiE 'version[[:space:]]+v?20[0-9]{6}\.[0-9]+' | grep -oE '20[0-9]{6}\.[0-9]+' | head -n1 || true)
512+ if [ -z "$VERSION" ]; then
513+ VERSION=$(printf '%s\n' "$CLEAN" | grep -oE '20[0-9]{6}\.[0-9]+' | head -n1 || true)
514+ fi
488515 if [ -z "$VERSION" ]; then
489516 echo "ERROR: could not parse deployed version from deploy output" >&2
490517 exit 1
@@ -728,11 +755,20 @@ jobs:
728755 # retag is a no-op, ECR fires no push event, and no ECS app deploy runs.
729756 # promote-trigger reads this to promote immediately instead of waiting for
730757 # a cutover that will never happen.
731- get_digest() { docker buildx imagetools inspect "$1" 2>/dev/null | awk '/^Digest:/{print $2; exit}'; }
732- APP_REF="${REGISTRY}/${{ secrets.ECR_APP }}"
733- NEW_APP_DIGEST="$(get_digest "${APP_REF}:${{ github.sha }}")"
734- PREV_APP_DIGEST="$(get_digest "${APP_REF}:${ECR_TAG}" || true)"
735- if [ -n "$NEW_APP_DIGEST" ] && [ "$NEW_APP_DIGEST" = "$PREV_APP_DIGEST" ]; then
758+ #
759+ # Read digests via the ECR API, which cleanly returns "None" for an absent
760+ # tag (first deploy → changed) vs a non-zero exit on a real read error. On
761+ # a read error we fall through to changed=true (wait) — the safe direction
762+ # (old tasks stay current, job fails visibly) rather than promoting early.
763+ APP_REPO="${{ secrets.ECR_APP }}"
764+ ecr_digest() { aws ecr batch-get-image --repository-name "$1" --image-ids imageTag="$2" --query 'images[0].imageId.imageDigest' --output text 2>/dev/null; }
765+ NEW_APP_DIGEST="$(ecr_digest "$APP_REPO" "${{ github.sha }}")" || NEW_APP_DIGEST="__ERR__"
766+ PREV_APP_DIGEST="$(ecr_digest "$APP_REPO" "${ECR_TAG}")" || PREV_APP_DIGEST="__ERR__"
767+ if [ "$NEW_APP_DIGEST" = "__ERR__" ] || [ "$NEW_APP_DIGEST" = "None" ] || [ -z "$NEW_APP_DIGEST" ]; then
768+ echo "ERROR: could not resolve the new app image digest for ${{ github.sha }}" >&2
769+ exit 1
770+ fi
771+ if [ "$PREV_APP_DIGEST" != "__ERR__" ] && [ "$NEW_APP_DIGEST" = "$PREV_APP_DIGEST" ]; then
736772 echo "app_image_changed=false" >> "$GITHUB_OUTPUT"
737773 echo "ℹ️ App deploy tag ${ECR_TAG} already points at ${NEW_APP_DIGEST}; no ECS app deploy will be triggered."
738774 else
0 commit comments