Skip to content

πŸ”– Release v2026.8.11-835b0a1c #300

πŸ”– Release v2026.8.11-835b0a1c

πŸ”– Release v2026.8.11-835b0a1c #300

Workflow file for this run

name: Deploy to AWS ECS
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+-*'
concurrency:
group: deploy-production
cancel-in-progress: false
# The workflow only talks to AWS; contents: read is all checkout needs.
permissions:
contents: read
env:
AWS_REGION: eu-west-1
ECR_REGISTRY: 525984396332.dkr.ecr.eu-west-1.amazonaws.com
ECR_REPOSITORY: b10cks/cms
ECS_CLUSTER: nb-clients01
ECS_SERVICE: b10cks-cms
ECS_TASK_DEFINITION: b10cks-cms
PHP_VERSION: '8.5'
VITE_S3_BUCKET: ${{ secrets.VITE_S3_BUCKET }}
DOCS_S3_BUCKET: b10cks-production-docs
DOCS_CLOUDFRONT_DISTRIBUTION_ID: E2DCMG9O2W1BIA
jobs:
# Same gate as release.yml: a calver tag can land on any commit, and without
# this a red one would deploy straight to production. Reuses tests.yml so the
# gate cannot drift from what runs on pull requests.
test:
uses: ./.github/workflows/tests.yml
# The deploy job's own Discord notification never runs when the gate fails
# (the job is skipped, not failed), so a blocked deploy would otherwise be
# silent outside the Actions UI.
notify-blocked:
needs: test
if: failure()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Post blocked-deploy notification to Discord
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
PAYLOAD=$(jq -n \
--arg title "🚫 Deployment Blocked β€” Tests Failed" \
--arg description "**Project:** https://github.com/b10cks/cms
**Version:** \`${VERSION}\`
The test suite failed, so this tag was not deployed.
${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'{
"embeds": [{
"title": $title,
"description": $description,
"color": 15158332,
"timestamp": $timestamp
}]
}')
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"
deploy:
needs: test
runs-on: ubuntu-latest
# must leave room for a worst-case docs build (2 Γ— 5 min timeout)
# on top of the normal ~10 min pipeline
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Fetch all history for commit comparison
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ env.PHP_VERSION }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, redis
tools: composer:v2
- name: Install production Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --no-dev
- name: Restore committed OpenAPI specs
# composer's post-autoload-dump runs `artisan docs:generate`, which
# regenerates docs/public/specs without a real app environment; the
# degenerate output sends the vitepress-openapi spec parser into an
# infinite loop during docs SSR. The committed specs are the source
# of truth for CI builds.
run: git checkout -- docs/public/specs
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
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: Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build frontend
run: bun run build
env:
NODE_ENV: production
- name: Build docs
id: docs
# vitepress repeatedly hangs in the SSR render phase in this job
# (fine in isolation) β€” bound it, retry once, and never block the
# app deploy on it: on failure we skip docs publishing instead.
run: |
if timeout 300 bun run docs:build || {
echo "::warning::docs build failed or timed out β€” retrying once"
rm -rf public/docs
timeout 300 bun run docs:build
}; then
echo "built=true" >> $GITHUB_OUTPUT
else
echo "::warning::docs build failed twice β€” deploying without docs update"
echo "built=false" >> $GITHUB_OUTPUT
fi
env:
NODE_ENV: production
APP_URL: ${{ vars.APP_FRONTEND_URL || 'https://app.b10cks.com' }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Check for existing image
id: image-exists
# tags are immutable β€” a re-run after a partial failure must not
# re-push the tag its first attempt already published
env:
IMAGE_TAG: main-${{ steps.version.outputs.version }}
run: |
if aws ecr describe-images \
--repository-name ${{ env.ECR_REPOSITORY }} \
--image-ids imageTag=$IMAGE_TAG >/dev/null 2>&1; then
echo "::notice::${IMAGE_TAG} already in ECR β€” skipping build and push"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Build and push Docker image
if: steps.image-exists.outputs.exists != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:main-${{ steps.version.outputs.version }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=registry,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:cache
cache-to: type=registry,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:cache,mode=max
provenance: false
- name: Download current task definition
run: |
aws ecs describe-task-definition \
--task-definition ${{ env.ECS_TASK_DEFINITION }} \
--query taskDefinition > task-definition.json
- name: Update task definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@138c24f321fdbdf7edee4a685519d253cae2cdea # v1.9.0
with:
task-definition: task-definition.json
container-name: ${{ env.ECS_TASK_DEFINITION }}
image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:main-${{ steps.version.outputs.version }}
- name: Download reverb task definition
run: |
aws ecs describe-task-definition \
--task-definition b10cks-reverb \
--query taskDefinition > reverb-task-definition.json
- name: Update reverb task definition with new image
id: reverb-task-def-image
uses: aws-actions/amazon-ecs-render-task-definition@138c24f321fdbdf7edee4a685519d253cae2cdea # v1.9.0
with:
task-definition: reverb-task-definition.json
container-name: b10cks-reverb
image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:main-${{ steps.version.outputs.version }}
- name: Switch reverb task definition to supervisord config
id: reverb-task-def
run: |
jq '.containerDefinitions[0].entryPoint = ["/usr/bin/supervisord", "-c", "/etc/supervisord-reverb.conf", "-n"]' \
${{ steps.reverb-task-def-image.outputs.task-definition }} > reverb-task-definition-final.json
echo "task-definition=reverb-task-definition-final.json" >> $GITHUB_OUTPUT
- name: Sync static assets to S3
run: |
aws s3 sync public/build/ "s3://${{ env.VITE_S3_BUCKET }}/build" \
--delete \
--cache-control "max-age=7776000,s-maxage=604800,public"
- name: Publish docs to S3
if: steps.docs.outputs.built == 'true'
run: |
# HTML gets short-lived cache (invalidated below), hashed assets long-lived
aws s3 sync public/docs/ "s3://${{ env.DOCS_S3_BUCKET }}/docs" \
--delete \
--exclude "*.html" \
--cache-control "max-age=7776000,s-maxage=604800,public"
aws s3 sync public/docs/ "s3://${{ env.DOCS_S3_BUCKET }}/docs" \
--exclude "*" \
--include "*.html" \
--cache-control "max-age=0,s-maxage=604800,public,must-revalidate"
- name: Invalidate docs CloudFront cache
if: steps.docs.outputs.built == 'true'
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ env.DOCS_CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/docs*"
- name: Deploy CMS service to Amazon ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@c465972ecbd160473f22e683363b422a5412a3de # v2.6.3
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
wait-for-minutes: 10
- name: Deploy Reverb service to Amazon ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@c465972ecbd160473f22e683363b422a5412a3de # v2.6.3
with:
task-definition: ${{ steps.reverb-task-def.outputs.task-definition }}
service: b10cks-reverb
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: false
- name: Get commits since last release
id: commits
run: |
# Get the previous tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
# If no previous tag, get all commits
COMMITS=$(git log --pretty=format:"β€’ %s" --no-merges)
else
# Get commits between previous tag and current
COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"β€’ %s" --no-merges)
fi
COMMITS=$(echo -n "$COMMITS" | base64 | tr -d '\n')
echo "commits=${COMMITS}" >> $GITHUB_OUTPUT
echo "previous_tag=${PREVIOUS_TAG:-'Initial Release'}" >> $GITHUB_OUTPUT
- name: Post deployment notification to Discord
if: always()
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
if [ "${{ job.status }}" == "success" ]; then
COLOR="3066993" # Green
STATUS="βœ… Deployment Successful"
else
COLOR="15158332" # Red
STATUS="❌ Deployment Failed"
fi
# Use jq to properly escape the commits for JSON
COMMITS=$(echo "${{ steps.commits.outputs.commits }}" | base64 --decode)
DESCRIPTION=$(echo -e "**Project:** https://github.com/b10cks/cms\n**Version:** \`${{ steps.version.outputs.version }}\`\n\n**Changes since ${{ steps.commits.outputs.previous_tag }}:**\n${COMMITS}")
# Truncate if too long (Discord has a 4096 char limit for description)
if [ ${#DESCRIPTION} -gt 3800 ]; then
DESCRIPTION=$(echo "${DESCRIPTION:0:3800}... (truncated)")
fi
# Create JSON payload with proper escaping
PAYLOAD=$(jq -n \
--arg title "$STATUS" \
--arg description "$DESCRIPTION" \
--arg color "$COLOR" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
--arg footer_text "Commit: ${{ steps.version.outputs.short_sha }}" \
'{
"embeds": [{
"title": $title,
"description": $description,
"color": $color|tonumber,
"timestamp": $timestamp,
"footer": {
"text": $footer_text
}
}]
}')
# Send to Discord
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
$DISCORD_WEBHOOK
if [ ${{ job.status }} == 'success' ]; then
echo "βœ… Deployment successful for version ${{ steps.version.outputs.version }}"
else
echo "❌ Deployment failed for version ${{ steps.version.outputs.version }}"
fi