diff --git a/.github/workflows/cd-dotnet-eb.yaml b/.github/workflows/cd-dotnet-eb.yaml new file mode 100644 index 0000000..35a8435 --- /dev/null +++ b/.github/workflows/cd-dotnet-eb.yaml @@ -0,0 +1,274 @@ +name: CD — .NET Elastic Beanstalk + +# Caller example: +# +# jobs: +# deploy: +# uses: Sea-Haven-Industries/.github/.github/workflows/cd-dotnet-eb.yaml@ # 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-- 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: -)" + 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}" diff --git a/README.md b/README.md index 56c0367..05f4f6a 100644 --- a/README.md +++ b/README.md @@ -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... | diff --git a/workflow-templates/dotnet-eb-deploy.properties.json b/workflow-templates/dotnet-eb-deploy.properties.json new file mode 100644 index 0000000..06f9270 --- /dev/null +++ b/workflow-templates/dotnet-eb-deploy.properties.json @@ -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$"] +} diff --git a/workflow-templates/dotnet-eb-deploy.yml b/workflow-templates/dotnet-eb-deploy.yml new file mode 100644 index 0000000..1316bcc --- /dev/null +++ b/workflow-templates/dotnet-eb-deploy.yml @@ -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 }}