Repository navigation
Deploy to Dev #54
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: 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 }} |