Skip to content

Instantly share code, notes, and snippets.

@jhosteny
Created August 13, 2025 19:40
Show Gist options
  • Select an option

  • Save jhosteny/c265c75adf04c01ef567ee8b0f8480ad to your computer and use it in GitHub Desktop.

Select an option

Save jhosteny/c265c75adf04c01ef567ee8b0f8480ad to your computer and use it in GitHub Desktop.
Phoenix GitHub Actions CI
name: Build and Test Workflow
on:
workflow_call:
inputs:
dialyzer:
description: Whether to run the dialyzer
type: boolean
required: false
default: false
env:
MIX_ENV: test
jobs:
extract_versions:
name: Extract info from .tool-versions
runs-on: ubuntu-latest
outputs:
elixir-version: ${{ steps.set-versions.outputs.elixir_version }}
otp-version: ${{ steps.set-versions.outputs.otp_version }}
steps:
- name: Checkout .tool-versions file
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
sparse-checkout: |
.tool-versions
sparse-checkout-cone-mode: false
- name: Set Elixir, OTP, and Node.js versions as output
id: set-versions
run: |
elixir_version=$(grep -h elixir .tool-versions | awk '{ print $2 }' | awk -F - '{print $1}')
otp_version=$(grep -h erlang .tool-versions | awk '{ print $2 }')
nodejs_version=$(grep -h nodejs .tool-versions | awk '{ print $2 }')
echo "elixir_version=$elixir_version" >> $GITHUB_OUTPUT
echo "otp_version=$otp_version" >> $GITHUB_OUTPUT
echo "nodejs_version=$nodejs_version" >> $GITHUB_OUTPUT
test:
name: Test on OTP ${{ needs.extract_versions.outputs.otp-version }} / Elixir ${{ needs.extract_versions.outputs.elixir-version }}
runs-on: ubuntu-latest
needs: extract_versions
env:
otp-version: ${{ needs.extract_versions.outputs.otp-version }}
elixir-version: ${{ needs.extract_versions.outputs.elixir-version }}
services:
db:
image: postgres:16.6
ports: ["5432:5432"]
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Set up Elixir
uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9 # v1.20.4
with:
otp-version: ${{ env.otp-version }}
elixir-version: ${{ env.elixir-version }}
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: <org>/<repository>
fetch-depth: 0
- name: Cache deps
id: cache-deps
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
env:
cache-name: cache-elixir-deps
with:
path: deps
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ env.cache-name }}-
# Define how to cache the `_build` directory. After the first run, this
# speeds up tests runs a lot. This includes not re-compiling our project's
# downloaded deps every run.
- name: Cache compiled build
id: cache-build
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
env:
cache-name: cache-compiled-build
with:
path: _build
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ env.cache-name }}-
${{ runner.os }}-mix-
# Conditionally bust the cache when job is re-run. Sometimes, we may have
# issues with incremental builds that are fixed by doing a full recompile.
# In order to not waste dev time on such trivial issues (while also
# reaping the time savings of incremental builds for *most* day-to-day
# development), force a full recompile only on builds that are retried.
- name: Clean to rule out incremental build as a source of flakiness
if: github.run_attempt != '1'
run: |
mix deps.clean --all
mix clean
shell: sh
- name: Install dependencies
run: mix deps.get
- name: Check for unused dependencies
run: mix deps.unlock --check-unused
- name: Check for abandoned packages
run: mix hex.audit
- name: Compiles without warnings
run: mix compile --warnings-as-errors
- name: Check Formatting
run: mix format --check-formatted
- name: Check code analysis
run: mix credo --strict
# mix gettext.extract --check-up-to-date will only check .pot files and
# not the .po file associated. We check that manually here.
- name: Compare gettext files
run: |
diff <(grep -v '^##' priv/gettext/en/LC_MESSAGES/errors.po | grep msgid) <(grep -v '^##' priv/gettext/errors.pot | grep msgid)
diff <(grep -v '^##' priv/gettext/en/LC_MESSAGES/default.po | grep msgid) <(grep -v '^##' priv/gettext/default.pot | grep msgid)
- name: Check if POT files are up to date
run: mix gettext.extract --check-up-to-date
- name: Check migrations
run: mix excellent_migrations.check_safety
- name: Test seeds
run: mix ecto.drop --quiet && mix ecto.create --quiet && mix ecto.migrate --quiet && mix run priv/repo/seeds.exs
- name: Run Sobelow security scan
run: mix sobelow --config
- name: Restore PLT cache
id: plt_cache
if: ${{ inputs.dialyzer }}
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
key: |
plt-${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
plt-${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-
path: |
priv/plts
- name: Create PLTs
if: ${{ inputs.dialyzer && steps.plt_cache.outputs.cache-hit != 'true' }}
run: MIX_ENV=dev mix dialyzer --plt
# By default, the GitHub Cache action will only save the cache if all steps in the job succeed,
# so we separate the cache restore and save steps in case running dialyzer fails.
- name: Save PLT cache
id: plt_cache_save
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
if: ${{ inputs.dialyzer && steps.plt_cache.outputs.cache-hit != 'true' }}
with:
key: |
plt-${{ runner.os }}-${{ env.otp-version }}-${{ env.elixir-version }}-${{ hashFiles('**/mix.lock') }}
path: |
priv/plts
- name: Run dialyzer
if: ${{ inputs.dialyzer }}
run: MIX_ENV=dev mix dialyzer --format github
test_frontend:
name: Test frontend
runs-on: ubuntu-latest
needs: extract_versions
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: <org>/<repository>
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ needs.extract_versions.outputs.nodejs_version }}
- name: Install npm dependencies
working-directory: ./assets
run: npm install
- name: Check frontend formatting
working-directory: ./assets
run: npm run check-formatting
name: Deploy Workflow
on:
workflow_call:
inputs:
role:
description: CI role
type: string
required: true
repository:
description: ECS repository
type: string
required: true
mix_env:
description: Mix environment
type: string
required: true
cluster:
description: Cluster name
type: string
required: true
service:
description: Service name
type: string
required: true
task:
description: Task name
type: string
required: true
container:
description: Container name
type: string
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.mix_env }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Report dependencies
uses: erlef/mix-dependency-submission@bdccfd60e12db8f77147dc6024758e459025f5ee # v1.2.1
- name: Login to Docker Hub, to avoid rate limits
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_PASS }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ inputs.role }}
role-session-name: ecr-ecs-deploy
aws-region: <region>
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Get short tag and repo URI
id: info
run: |
IMAGE_TAG=$(echo ${{ github.sha }} | cut -c 1-7)
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
REPO_URI=${{ steps.login-ecr.outputs.registry }}/${{ inputs.repository }}
echo "repo_uri=$REPO_URI" >> $GITHUB_OUTPUT
echo "image=$REPO_URI:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Build, tag, and push builder image to Amazon ECR
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
push: true
target: builder
build-args:
MIX_ENV=${{ inputs.mix_env }}
cache-from: type=registry,ref=${{ steps.info.outputs.repo_uri }}:builder
cache-to: type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${{ steps.info.outputs.repo_uri }}:builder
tags: ${{ steps.info.outputs.repo_uri }}:builder
- name: Build, tag, and push application image to Amazon ECR
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
push: true
build-args:
MIX_ENV=${{ inputs.mix_env }}
cache-from: |
type=registry,ref=${{ steps.info.outputs.repo_uri }}:builder
type=registry,ref=${{ steps.info.outputs.repo_uri }}:latest
cache-to: type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${{ steps.info.outputs.repo_uri }}:latest
tags: |
${{ steps.info.outputs.repo_uri }}:${{ steps.info.outputs.image_tag }}
${{ steps.info.outputs.repo_uri }}:latest
- name: Download existing ECS task definition
id: task-def-download
run: |
aws ecs describe-task-definition --task-definition ${{ inputs.task }} --query taskDefinition | jq -r 'del(
.taskDefinitionArn,
.requiresAttributes,
.compatibilities,
.revision,
.status,
.registeredAt,
.registeredBy
)' > task-definition.json
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@64aefa8f68c9083d24d230e3099d046d5964bcba # v1.7.5
with:
task-definition: task-definition.json
container-name: ${{ inputs.container }}
image: ${{ steps.info.outputs.image }}
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@4b08990e8909cf36bc2ca95f994312f090c41865 # v2.3.4
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ inputs.service }}
cluster: ${{ inputs.cluster }}
wait-for-service-stability: true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment