Skip to content

Deploy to Dev

Deploy to Dev #54

Workflow file for this run

name: Deploy to Dev
# Every merge to main gets a v.X.Y.Z tag from codebase-versioning.yml, which
# triggers the release image build. This chains off that build rather than the
# tag itself, so the image is guaranteed to exist instead of being waited on.
on:
workflow_run:
workflows: ["Build & Publish Release Image"]
types: [completed]
workflow_dispatch:
inputs:
version:
description: "Version tag to deploy (e.g. v.1.0.0)"
required: true
type: string
concurrency:
group: deploy-dev
cancel-in-progress: false
env:
AWS_REGION: eu-west-1
ECR_REPOSITORY: block-explorer
TERRAFORM_VERSION: 1.16.0
GHCR_REGISTRY: ghcr.io
jobs:
# Resolves which tag to deploy and refuses anything not reachable from main.
# The release image is built from whatever commit the tag points at, so
# without this a v.* tag pushed to any branch would put unreviewed code in
# front of dev traffic.
guard:
name: Resolve and Verify Tag
runs-on: ubuntu-latest
permissions:
contents: read
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
outputs:
tag: ${{ steps.resolve.outputs.tag }}
ghcr_tag: ${{ steps.resolve.outputs.ghcr_tag }}
ecr_tag: ${{ steps.content.outputs.ecr_tag }}
app_sha: ${{ steps.content.outputs.app_sha }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Resolve tag
id: resolve
env:
# On a tag push, workflow_run.head_branch carries the tag name.
RAW_TAG: ${{ inputs.version || github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
# workflow_run always starts on the default branch, but a dispatch can
# start anywhere, and the jobs below apply the Terraform they check out.
# Only the image travels as a tag; the tree is always main's.
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "::error::Started on '${GITHUB_REF}'. Dispatch this from main — the deploy applies whatever Terraform it checks out."
exit 1
fi
if [[ ! "$RAW_TAG" =~ ^v\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Not a version tag: '$RAW_TAG'. Expected v.X.Y.Z."
exit 1
fi
# GHCR strips the v. prefix. The ECR tag is content-addressed, see below.
echo "tag=${RAW_TAG}" >> "$GITHUB_OUTPUT"
echo "ghcr_tag=${RAW_TAG#v.}" >> "$GITHUB_OUTPUT"
- name: Verify the tag is reachable from main
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
git fetch --quiet origin main
SHA=$(git rev-list -n 1 "refs/tags/${TAG}")
if ! git merge-base --is-ancestor "$SHA" origin/main; then
echo "::error::${TAG} ($SHA) is not an ancestor of origin/main. Refusing to deploy unreviewed code."
exit 1
fi
echo "${TAG} -> ${SHA}, reachable from main."
# At the tag, not at HEAD: a merge landing mid-deploy must not move it.
- name: Resolve the content SHA
id: content
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
APP_SHA=$(bash scripts/app-content-sha.sh "refs/tags/${TAG}")
echo "app_sha=${APP_SHA}" >> "$GITHUB_OUTPUT"
echo "ecr_tag=dev-${APP_SHA}" >> "$GITHUB_OUTPUT"
echo "${TAG} carries app content ${APP_SHA}."
# Copies the manifest list rather than a single platform, so ECR keeps both
# arches for arm64 tasks, under a dev- prefix deploy-production.yml never writes.
promote:
name: Promote Image to ECR
runs-on: ubuntu-latest
needs: guard
permissions:
id-token: write
contents: read
packages: read
environment:
name: dev
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Copy manifest list from GHCR to ECR
env:
GHCR_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ github.repository }}:${{ needs.guard.outputs.ghcr_tag }}
ECR_IMAGE: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ needs.guard.outputs.ecr_tag }}
ECR_TAG: ${{ needs.guard.outputs.ecr_tag }}
run: |
set -euo pipefail
if aws ecr describe-images --repository-name "$ECR_REPOSITORY" \
--image-ids "imageTag=${ECR_TAG}" --no-cli-pager >/dev/null 2>&1; then
echo "${ECR_TAG} is already in ECR — this release ships content that is already here."
exit 0
fi
docker buildx imagetools create -t "$ECR_IMAGE" "$GHCR_IMAGE"
docker buildx imagetools inspect "$ECR_IMAGE"
# Applied serially in dependency order, each stack planned immediately before
# its own apply. The design doc's parallel plan matrix is deliberately not
# used: on a first deploy the downstream stacks cannot plan at all until the
# upstream state they read actually exists.
terraform:
name: Apply Terraform
runs-on: ubuntu-latest
needs: [guard, promote]
permissions:
id-token: write
contents: read
environment:
name: dev
url: https://dev.block-explorer.vechain.org
outputs:
cluster: ${{ steps.outputs.outputs.cluster }}
service: ${{ steps.outputs.outputs.service }}
family: ${{ steps.outputs.outputs.family }}
taskdef: ${{ steps.outputs.outputs.taskdef }}
url: ${{ steps.outputs.outputs.url }}
grafana_url: ${{ steps.outputs.outputs.grafana_url }}
steps:
# No `ref:` on purpose: this job holds AWS credentials, so checking out a
# tag named by event data would be an untrusted checkout Terraform then runs.
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
terraform_wrapper: false
# Pinned to the image, not to this release, so a no-op release registers no revision.
- name: Pin the image tag and version
env:
IMAGE_TAG: ${{ needs.guard.outputs.ecr_tag }}
VERSION_TAG: ${{ needs.guard.outputs.tag }}
FAMILY: block-explorer-dev-frontend
run: |
set -euo pipefail
running=$(aws ecs describe-task-definition --task-definition "$FAMILY" --no-cli-pager \
--query "taskDefinition.containerDefinitions[?name=='frontend'] | [0]" --output json 2>/dev/null || true)
app_version="$VERSION_TAG"
if [ -n "${running:-}" ] && [ "$running" != "null" ]; then
running_image=$(jq -r '.image // empty' <<<"$running")
running_version=$(jq -r '.environment[]? | select(.name == "APP_VERSION") | .value' <<<"$running")
if [ "${running_image##*:}" = "$IMAGE_TAG" ] && [ -n "$running_version" ]; then
app_version="$running_version"
echo "${IMAGE_TAG} already serves ${app_version}; keeping it."
fi
fi
cd terraform/environments/dev
sed -i "s|^image_tag:.*|image_tag: ${IMAGE_TAG}|" dev.yaml
sed -i "s|^app_version:.*|app_version: ${app_version}|" dev.yaml
grep -E '^(image_tag|app_version):' dev.yaml
- name: Plan and apply each stack
env:
TF_VAR_slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
# Empty until bootstrap/ is applied in prod, which leaves dns/ inert.
TF_VAR_prod_deploy_role_arn: ${{ vars.PROD_DEPLOY_ROLE_ARN }}
run: |
set -euo pipefail
for stack in dns network ecs acm edge preview-edge data observability-aws frontend observability-grafana; do
echo "::group::${stack}"
pushd "terraform/${stack}" >/dev/null
terraform init -input=false -backend-config=../environments/dev/backend.config
terraform workspace select -or-create dev
# Plan file never leaves this job: a binary plan can carry state.
terraform plan -input=false -out=tfplan
terraform apply -input=false -auto-approve tfplan
popd >/dev/null
echo "::endgroup::"
done
- name: Read service outputs
id: outputs
run: |
set -euo pipefail
pushd terraform/frontend >/dev/null
{
echo "cluster=$(terraform output -raw cluster_name)"
echo "service=$(terraform output -raw service_name)"
echo "family=$(terraform output -raw task_definition_family)"
echo "taskdef=$(terraform output -raw task_definition_arn)"
echo "url=$(terraform output -raw url)"
} >> "$GITHUB_OUTPUT"
popd >/dev/null
# Null until the AMP workspace exists, and `-raw` refuses a null.
pushd terraform/observability-grafana >/dev/null
echo "grafana_url=$(terraform output -raw amg_workspace_url 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
popd >/dev/null
# Terraform registered a new task definition revision but the service pins
# its own, because the frontend stack ignores task_definition changes.
# Passing the family is what moves the pointer to the new revision - without
# it, --force-new-deployment just redeploys the frozen one.
roll:
name: Roll ECS Service
runs-on: ubuntu-latest
needs: [guard, terraform]
permissions:
id-token: write
contents: read
environment:
name: dev
url: https://dev.block-explorer.vechain.org
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }}
# The revision rather than the image tag, so a cpu, memory, env or sidecar
# change that leaves the image alone still rolls.
- name: Check whether a roll is needed
id: needed
env:
CLUSTER: ${{ needs.terraform.outputs.cluster }}
SERVICE: ${{ needs.terraform.outputs.service }}
DESIRED: ${{ needs.terraform.outputs.taskdef }}
run: |
set -euo pipefail
running=$(aws ecs describe-services \
--cluster "$CLUSTER" --services "$SERVICE" \
--no-cli-pager --query 'services[0].taskDefinition' --output text)
if [ "$running" = "$DESIRED" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Already on ${running} — nothing to roll."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Running ${running}, rolling to ${DESIRED}."
fi
- name: Deploy the new revision
if: steps.needed.outputs.skip == 'false'
env:
CLUSTER: ${{ needs.terraform.outputs.cluster }}
SERVICE: ${{ needs.terraform.outputs.service }}
FAMILY: ${{ needs.terraform.outputs.family }}
run: |
set -euo pipefail
aws ecs update-service \
--cluster "$CLUSTER" \
--service "$SERVICE" \
--task-definition "$FAMILY" \
--force-new-deployment \
--no-cli-pager \
--query 'service.taskDefinition' --output text
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE"
# services-stable already means tasks are healthy in the target group, so
# this is really checking the ALB and DNS in front of them. Generous
# window because a first-ever deploy also waits on DNS propagation.
- name: Confirm the environment is serving
env:
URL: ${{ needs.terraform.outputs.url }}
run: |
set -euo pipefail
for attempt in $(seq 1 20); do
code=$(curl -s -o /dev/null -w '%{http_code}' "${URL}/api/health" || true)
if [ "$code" = "200" ]; then
echo "${URL}/api/health returned 200."
exit 0
fi
echo "Attempt ${attempt}/20: got '${code}', retrying in 15s..."
sleep 15
done
echo "::error::${URL}/api/health never returned 200."
exit 1
# The ALB must refuse this even though the sidecar scrapes it in-task.
- name: Confirm /api/metrics is not public
env:
URL: ${{ needs.terraform.outputs.url }}
run: |
set -euo pipefail
code=$(curl -s -o /dev/null -w '%{http_code}' "${URL}/api/metrics" || true)
if [ "$code" != "403" ]; then
echo "::error::${URL}/api/metrics returned '${code}', expected 403."
exit 1
fi
echo "/api/metrics is closed at the ALB."
- name: Summary
env:
SERVICE: ${{ needs.terraform.outputs.service }}
URL: ${{ needs.terraform.outputs.url }}
GRAFANA_URL: ${{ needs.terraform.outputs.grafana_url }}
APP_SHA: ${{ needs.guard.outputs.app_sha }}
SKIPPED: ${{ steps.needed.outputs.skip }}
run: |
{
echo "### Dev deployed"
echo
echo "- Service: \`${SERVICE}\`"
if [ "$SKIPPED" = "true" ]; then
echo "- Roll **skipped** — already running \`${APP_SHA}\` with an unchanged task definition."
else
echo "- Rolled to \`${APP_SHA}\`."
fi
echo "- ${URL}"
if [ -n "$GRAFANA_URL" ]; then
echo "- Grafana: ${GRAFANA_URL}/d/be-overview"
fi
} >> "$GITHUB_STEP_SUMMARY"
# Not on workflow_dispatch: a break-glass run may be redeploying an older tag,
# and drafting a release for it would offer prod that rollback as the next cut.
draft-release:
name: Prepare Release Draft
needs: [guard, roll]
if: github.event_name == 'workflow_run'
# Caps the called workflow rather than leaning on the repository default.
permissions:
contents: write
uses: ./.github/workflows/prepare-release.yml
with:
tag: ${{ needs.guard.outputs.tag }}