Merge pull request #94 from Sea-Haven-Industries/cd/add-dotnet-eb
Some checks are pending
ci / ci / ci (push) Waiting to run

ci(cd-dotnet-eb): add reusable CD workflow for .NET on Elastic Beanstalk
This commit is contained in:
Adam Moussa 2026-07-27 17:28:56 -04:00 • committed by GitHub
commit b7d7be2727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 329 additions and 1 deletions

274
.github/workflows/cd-dotnet-eb.yaml vendored Normal file
View file

@ -0,0 +1,274 @@
name: CD — .NET Elastic Beanstalk
# Caller example:
#
# jobs:
# deploy:
# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@<full-commit-sha> # main
# with:
# project: "Api.Example/Api.Example.csproj"
# eb-application: "example-api"
# eb-environment: "example-api-dev"
# procfile-command: "dotnet Api.Example.dll"
# secrets:
# deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
#
# The caller owns branch-to-environment mapping (one job per branch); this
# workflow is deliberately branch-agnostic.
on:
workflow_call:
inputs:
dotnet-version:
description: ".NET SDK version"
type: string
default: "8.0.x"
working-directory:
description: "Directory containing the solution/project"
type: string
default: "."
project:
description: "Project file to publish (e.g. Api.Example/Api.Example.csproj)"
type: string
required: true
eb-application:
description: "Elastic Beanstalk application name"
type: string
required: true
eb-environment:
description: "Elastic Beanstalk environment name to deploy"
type: string
required: true
region:
description: "AWS region"
type: string
default: "us-east-1"
bundle-bucket:
description: "S3 bucket for the application bundle (default: the account's elasticbeanstalk-<region>-<account-id> bucket)"
type: string
default: ""
procfile-command:
description: "Command for a generated Procfile (e.g. 'dotnet Api.Example.dll'). Leave empty if the repo commits its own Procfile."
type: string
default: ""
version-label:
description: "EB application version label (default: <run-number>-<short-sha>)"
type: string
default: ""
wait-timeout-minutes:
description: "How long to wait for the environment to finish updating"
type: number
default: 20
secrets:
deploy-role-arn:
description: "OIDC deploy role ARN"
required: true
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
# Serialise per environment so two pushes cannot deploy over each other.
# cancel-in-progress is FALSE on purpose: unlike CI, aborting midway can
# leave an environment mid-update.
concurrency:
group: cd-dotnet-eb-${{ inputs.eb-application }}-${{ inputs.eb-environment }}
cancel-in-progress: false
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
# Every step below uses env-var indirection rather than inline expression
# interpolation, so shell metacharacters in an input can never be parsed
# as script. Note the repo's actionlint runs with shellcheck disabled
# (see ci.yaml), so this is not enforced automatically — keep it by hand.
- name: Publish
env:
PROJECT: ${{ inputs.project }}
run: dotnet publish "$PROJECT" --configuration Release --output publish
- name: Write Procfile
if: ${{ inputs.procfile-command != '' }}
env:
PROCFILE_COMMAND: ${{ inputs.procfile-command }}
run: |
printf 'web: %s\n' "$PROCFILE_COMMAND" > publish/Procfile
- name: Verify Procfile
run: |
if [[ ! -f publish/Procfile ]]; then
echo "::error::No Procfile in the published output. Either set the procfile-command input or commit a Procfile that is copied to the publish directory."
exit 1
fi
echo "Procfile: $(cat publish/Procfile)"
- name: Package bundle
run: |
cd publish
zip -qr ../bundle.zip .
cd ..
echo "Bundle size: $(du -h bundle.zip | cut -f1)"
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with:
role-to-assume: ${{ secrets.deploy-role-arn }}
aws-region: ${{ inputs.region }}
- name: Pre-flight checks
env:
EB_APP: ${{ inputs.eb-application }}
EB_ENV: ${{ inputs.eb-environment }}
run: |
echo "Pre-flight: checking environment $EB_ENV..."
STATUS=$(aws elasticbeanstalk describe-environments \
--application-name "$EB_APP" \
--environment-names "$EB_ENV" \
--query 'Environments[0].Status' --output text 2>/dev/null || echo "NOT_FOUND")
case "$STATUS" in
None|NOT_FOUND|Terminated|Terminating)
echo "::error::Environment $EB_ENV not found (or terminated) in application $EB_APP. This workflow deploys to an existing environment; it does not create one."
exit 1
;;
Ready)
echo "Pre-flight: environment is Ready — OK to deploy."
;;
*)
echo "::error::Environment $EB_ENV is in status $STATUS — wait for it to reach Ready."
exit 1
;;
esac
- name: Resolve deploy parameters
env:
BUNDLE_BUCKET: ${{ inputs.bundle-bucket }}
VERSION_LABEL: ${{ inputs.version-label }}
REGION: ${{ inputs.region }}
RUN_NUMBER: ${{ github.run_number }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SHA: ${{ github.sha }}
run: |
if [[ -z "$BUNDLE_BUCKET" ]]; then
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BUNDLE_BUCKET="elasticbeanstalk-${REGION}-${ACCOUNT_ID}"
fi
if [[ -z "$VERSION_LABEL" ]]; then
# run_attempt is part of the label so a re-run of the same commit
# does not collide with the version the first attempt created —
# create-application-version rejects a duplicate label.
VERSION_LABEL="${RUN_NUMBER}.${RUN_ATTEMPT}-${SHA:0:7}"
fi
echo "BUNDLE_BUCKET=$BUNDLE_BUCKET" >> "$GITHUB_ENV"
echo "VERSION_LABEL=$VERSION_LABEL" >> "$GITHUB_ENV"
echo "Bundle bucket: $BUNDLE_BUCKET"
echo "Version label: $VERSION_LABEL"
- name: Upload bundle
env:
EB_APP: ${{ inputs.eb-application }}
run: |
S3_KEY="${EB_APP}/${VERSION_LABEL}.zip"
aws s3 cp bundle.zip "s3://${BUNDLE_BUCKET}/${S3_KEY}"
echo "S3_KEY=$S3_KEY" >> "$GITHUB_ENV"
- name: Create application version
env:
EB_APP: ${{ inputs.eb-application }}
RUN_ID: ${{ github.run_id }}
run: |
aws elasticbeanstalk create-application-version \
--application-name "$EB_APP" \
--version-label "$VERSION_LABEL" \
--source-bundle "S3Bucket=${BUNDLE_BUCKET},S3Key=${S3_KEY}" \
--description "GitHub Actions run ${RUN_ID}" \
--no-auto-create-application \
--output text > /dev/null
echo "Created application version $VERSION_LABEL"
- name: Deploy to environment
env:
EB_APP: ${{ inputs.eb-application }}
EB_ENV: ${{ inputs.eb-environment }}
run: |
aws elasticbeanstalk update-environment \
--application-name "$EB_APP" \
--environment-name "$EB_ENV" \
--version-label "$VERSION_LABEL" \
--output text > /dev/null
echo "Deployment of $VERSION_LABEL to $EB_ENV started."
- name: Post-deploy health check
env:
EB_APP: ${{ inputs.eb-application }}
EB_ENV: ${{ inputs.eb-environment }}
WAIT_TIMEOUT_MINUTES: ${{ inputs.wait-timeout-minutes }}
run: |
# Deliberately NOT `aws elasticbeanstalk wait environment-updated`:
# that waiter is hardcoded to 20 attempts x 20s (~6m40s) and the CLI
# exposes no way to extend it, so a slower rolling deploy would fail
# the job while the deployment was still healthy. Poll instead.
DEADLINE=$(( SECONDS + WAIT_TIMEOUT_MINUTES * 60 ))
echo "Waiting up to ${WAIT_TIMEOUT_MINUTES}m for $EB_ENV to finish updating..."
while true; do
read -r STATUS HEALTH HEALTH_STATUS DEPLOYED CNAME <<< "$(aws elasticbeanstalk describe-environments \
--application-name "$EB_APP" \
--environment-names "$EB_ENV" \
--query 'Environments[0].[Status,Health,HealthStatus,VersionLabel,CNAME]' --output text)"
if [[ "$STATUS" != "Updating" ]]; then
break
fi
if (( SECONDS >= DEADLINE )); then
echo "::error::Environment $EB_ENV was still Updating after ${WAIT_TIMEOUT_MINUTES}m. The deploy may still be in progress — check the EB console before retrying."
exit 1
fi
sleep 15
done
echo "Status: $STATUS | Health: $HEALTH ($HEALTH_STATUS) | Version: $DEPLOYED"
# Assert all three conditions. Elastic Beanstalk reports a rolled-back
# deploy as a perfectly healthy Ready environment — it is simply
# running the PREVIOUS version — so the version check is what turns a
# failed deploy into a failed job.
if [[ "$STATUS" != "Ready" ]]; then
echo "::error::Environment $EB_ENV ended in status $STATUS after deploy."
exit 1
fi
# HealthStatus is only meaningful with enhanced health; environments on
# basic health report Unknown/No Data, so fall back to the colour.
case "$HEALTH_STATUS" in
Ok|Info)
;;
Unknown|"No Data"|None|"")
if [[ "$HEALTH" != "Green" ]]; then
echo "::error::Environment $EB_ENV health is $HEALTH after deploy."
exit 1
fi
;;
*)
echo "::error::Environment $EB_ENV health is $HEALTH_STATUS after deploy."
exit 1
;;
esac
if [[ "$DEPLOYED" != "$VERSION_LABEL" ]]; then
echo "::error::Environment $EB_ENV is running $DEPLOYED, not the deployed version $VERSION_LABEL — Elastic Beanstalk rolled the deploy back."
exit 1
fi
echo "Recent environment events:"
aws elasticbeanstalk describe-events \
--application-name "$EB_APP" \
--environment-name "$EB_ENV" \
--max-items 10 \
--query 'Events[*].[Severity,Message]' --output table
echo "Health check passed. Environment URL: http://${CNAME}"

View file

@ -24,6 +24,8 @@ Organization-level GitHub configuration for Sea Haven Industries.
**`.github/workflows/cd-mobile-ios.yaml`** — Reusable CD for iOS apps via Fastlane to TestFlight (Node + Ruby setup inputs).
**`.github/workflows/cd-dotnet-eb.yaml`** — Reusable CD for .NET apps on AWS Elastic Beanstalk. Publishes the project, packages a bundle, uploads it, creates an application version, and updates an **existing** environment with OIDC credentials — it never creates an environment. Serialised per environment via a `concurrency` group, and the post-deploy check fails the job if EB rolls the deploy back. The caller owns branch-to-environment mapping.
**`.github/workflows/callable-labeler.yaml`** — Org-wide PR auto-labeler. Label rules live inline here (single source of truth) — consumer repos need only a thin caller with `contents: read`, `pull-requests: write`, and `issues: write`; no per-repo labeler.yml.
**`.github/workflows/callable-dependency-review.yaml`** — Dependency review on PRs, failing on high severity. Requires Dependency Graph.
@ -34,7 +36,7 @@ Organization-level GitHub configuration for Sea Haven Industries.
### Workflow templates (`workflow-templates/`)
Starter workflows offered on the org's **Actions → New workflow** page: `ci-python`, `ci-node`, `cdk-deploy`, `sam-deploy`, `dependency-review`, `labeler`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling.
Starter workflows offered on the org's **Actions → New workflow** page: `ci-python`, `ci-node`, `cdk-deploy`, `sam-deploy`, `dotnet-eb-deploy`, `dependency-review`, `labeler`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling.
### Action pinning policy
@ -274,6 +276,28 @@ jobs:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
```
**.NET app on Elastic Beanstalk** (e.g., shoc-backend):
```yaml
name: Deploy
on:
push:
branches: [dev]
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@main
with:
project: Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj
eb-application: shoc-backend
eb-environment: shoc-backend-dev
procfile-command: dotnet Api.SeaHavenIndustries.dll
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
```
> The environment must already exist — this workflow deploys a new application version to it and never creates one. Branch-to-environment mapping belongs in the caller: add one job per branch (e.g. `dev` → `…-dev`, `main` → `…-staging`) rather than parameterising the reusable by branch. `procfile-command` generates the Procfile the Amazon Linux .NET platform needs; omit it only if the repo commits its own Procfile into the publish output. Deploys are serialised per environment, and the job fails if Elastic Beanstalk rolls the version back.
Enable optional steps as repos adopt them:
| Input | Default | Turn on when... |

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — Deploy (.NET Elastic Beanstalk)",
"description": "Publishes a .NET project and deploys it to an existing AWS Elastic Beanstalk environment via OIDC, using the org reusable cd-dotnet-eb workflow. Set project, eb-application, eb-environment and procfile-command before enabling.",
"iconName": "octicon-rocket",
"categories": ["Deployment", "C#"],
"filePatterns": ["\\.csproj$", "\\.sln$"]
}

View file

@ -0,0 +1,23 @@
name: Deploy (.NET Elastic Beanstalk)
on:
push:
branches: [main]
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@555d07c3a240689a81668026787eba089df4c975 # main
with:
# Required: the project to publish, relative to the repo root.
project: REPLACE-ME-project-csproj
# Required: the Elastic Beanstalk application name.
eb-application: REPLACE-ME-eb-application
# Required: the EXISTING environment to deploy to. This workflow updates
# an environment; it does not create one. Map branches to environments by
# adding one job per branch rather than parameterising by branch.
eb-environment: REPLACE-ME-eb-environment
# The Amazon Linux .NET platform needs a Procfile. Set the entry-point
# command here to have one generated, or delete this line if the repo
# commits its own Procfile into the published output.
procfile-command: REPLACE-ME-dotnet-entry-assembly-dll
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}