Skip to main content

Generate Service Build and Deploy Workflow

1. ๐ŸŽฏ Page Purpose

DOCUMENT REVISION

Version: 2.0
Updated: 2026-06-16

Changes in this version
  Added a generated source-and-acquisition guide for all five required secrets.
  Distinguished the GitHub App private key from the deployment SSH private key.
  Added an optional PowerShell 7 procedure for creating and installing a dedicated deployment key.
  Added validation rules for GitHub App, Harbor, and SSH credential material before secret creation.
PURPOSE

Generate one complete repository-specific GitHub Actions workflow that:

  1. Uses the repository and environment ARC runner scale set.
  2. Creates a GitHub App token for private repository checkout.
  3. Checks out centralized CI actions and the shared source repository.
  4. Builds a combined Docker context.
  5. Builds and loads an image with Docker Buildx.
  6. Pushes immutable and moving tags to Harbor with Docker CLI.
  7. Starts a second ephemeral runner for deployment.
  8. Connects to the destination VM over verified SSH.
  9. Pulls and recreates the service container.
 10. Verifies application health and rolls back when possible.

The generated YAML is based on the working fp-web-ui-001 pipeline, with
repository, build-layout, runner, registry, host, port, secret-name, runtime,
and action-version values converted into user inputs.
Generator readiness:complete: Repository name, Service build parent, Local repository path.

2. ๐Ÿงพ Required Inputs

Common Aspireclan values are prefilled from the working fp-web-ui-001 pipeline. Change every repository, build-layout, runner, host, port, and runtime value that differs for the new service.

Repository and workflow identity

Also becomes the Harbor image repository suffix and default service label.
Both build and deploy jobs declare this environment.

Harbor and centralized CI actions

Combined Docker build context

The generated service path is <build parent>/<repository name>.

Deployment target and container

Container runtime environment

Leave blank to omit this Docker environment variable.
Leave blank to omit the automatic http://+:<container-port> binding.

GitHub secret names

Action versions

3. ๐Ÿ” Create the GitHub Environment and Secrets with PowerShell 7

The generated workflow declares the selected GitHub Environment on both jobs. Create the five required secrets at Environment scope before committing the workflow. The GitHub App installation must also include the target repository, the shared repository, and the centralized CI actions repository.

REQUIRED GITHUB ENVIRONMENT: dev

Environment secret                       Value source
  FP_CI_APP_ID                      Numeric GitHub App ID
  FP_CI_APP_PRIVATE_KEY             Complete GitHub App private-key PEM
  HARBOR_USERNAME             Harbor robot/user account name
  HARBOR_PASSWORD             Harbor robot/user account password
  DEPLOY_SSH_PRIVATE_KEY              Unencrypted OpenSSH/PEM private key for acllc@192.168.8.120

Optional repository fallback secrets
  FP_CI_APP_ID
  FP_CI_APP_PRIVATE_KEY

Not required as repository-level duplicates
  HARBOR_USERNAME
  HARBOR_PASSWORD
  DEPLOY_SSH_PRIVATE_KEY

Understand What Each Secret Is and Where to Obtain It

Collect and validate the credential material before running the secret-creation command. The GitHub App private key and the deployment SSH private key are different credentials and must never be interchanged.

SECRET SOURCE AND ACQUISITION GUIDE

1. FP_CI_APP_ID
   Purpose
     Numeric identifier for the GitHub App used by actions/create-github-app-token.
     It is not the GitHub App Client ID and not the App installation ID.

   Where to obtain it
     Open the GitHub App owner's Settings, then Developer settings, GitHub Apps,
     select the CI App, and copy the numeric App ID from the App settings page.

   Required access
     The App installation must include all repositories checked out by this workflow:
       fp-001-org/<<REPOSITORY_NAME>>
       fp-001-org/fp-001
       fp-001-org/fp-ci-actions
     The workflow requests repository Contents read access.

   Validation
     Expected value: digits only.
     Store the same App ID at Environment and optional repository scope.

2. FP_CI_APP_PRIVATE_KEY
   Purpose
     PEM private key belonging to the same GitHub App as FP_CI_APP_ID.
     The action signs a short-lived JSON Web Token with this key and exchanges it
     for a repository-scoped GitHub App installation token.

   Where to obtain it
     On the same GitHub App settings page, use the Private keys section to generate
     a private key. GitHub downloads a .pem file. Save it in an approved local
     credential location and select that file when the generated PowerShell block prompts.

   Recovery rule
     A previously downloaded GitHub App private key cannot be reconstructed from
     the secret value or downloaded again. Generate a new App private key when the
     original file is unavailable, update every consumer, validate the workflows,
     and only then remove the old App key.

   Validation
     The file must contain a PRIVATE KEY PEM header and footer.
     Do not use the deployment SSH key here.

3. HARBOR_USERNAME
   Purpose
     Harbor account name used by the build runner to push images and by the
     deployment VM to pull the immutable image.

   Where to obtain it
     Sign in to https://harbor.aspireclan.com, open project fp-ci-cd,
     and use an approved project robot account or service account. Copy the exact
     username shown by Harbor, including any robot-account prefix.

   Required permissions
     Because this workflow uses one Harbor credential for both jobs, the account
     must be able to push artifacts to and pull artifacts from fp-ci-cd.

4. HARBOR_PASSWORD
   Purpose
     Password or generated robot-account secret paired with HARBOR_USERNAME.

   Where to obtain it
     Copy the secret when the Harbor robot/service account is created or refreshed.
     If the value is no longer available, reset the credential or create a replacement
     account rather than attempting to infer the old value.

   Validation
     Test the pair with docker login against harbor.aspireclan.com before storing it.
     Never place the password in the workflow YAML or a committed env file.

5. DEPLOY_SSH_PRIVATE_KEY
   Purpose
     Private half of the SSH key used by the ephemeral deploy runner to connect to
     acllc@192.168.8.120 and execute the Docker deployment commands.

   Where to obtain it
     Reuse an approved dedicated deployment key only when its public key is already
     present in acllc's ~/.ssh/authorized_keys on 192.168.8.120.
     Otherwise, use the generated PowerShell procedure below to create a dedicated
     Ed25519 key and install only its public key on the destination VM.

   Destination requirements
     acllc must be able to authenticate with the key, run sudo -n,
     and execute sudo docker commands without an interactive password prompt.

   Validation
     Store the complete unencrypted OpenSSH/PEM private key.
     Do not store the .pub file, a PuTTY .ppk file, or a passphrase-protected key.
     Never copy the private key to the destination VM.

SCOPE RULE
  All five values are required in GitHub Environment dev.
  Optional repository-level copies of FP_CI_APP_ID and
  FP_CI_APP_PRIVATE_KEY use the same underlying App credentials; they are
  fallback copies, not separate credentials.

Optional: Create a Dedicated Deployment SSH Key

Skip this block when an approved deployment key already authenticates acllc@192.168.8.120 and has the required noninteractive sudo and Docker access. Otherwise, run the generated PowerShell block before creating the GitHub Environment secrets.

# OPTIONAL โ€” run only when an approved deployment key does not already exist.
# This creates a dedicated unencrypted Ed25519 key, installs only the public key
# on the destination VM, and verifies SSH, passwordless sudo, and Docker.
# One-time public-key installation requires an existing SSH authentication path.

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$TargetRepository = "fp-001-org/<<REPOSITORY_NAME>>"
$EnvironmentName = "dev"
$DeployHost = "192.168.8.120"
$DeployUser = "acllc"
$DeployKeyPath = Join-Path `
    $HOME `
    ".ssh/<<REPOSITORY_NAME>>-dev-deploy-ed25519"
$DeployKeyComment = "$TargetRepository/$EnvironmentName deployment"

if (Test-Path -LiteralPath $DeployKeyPath) {
    throw "Refusing to overwrite the existing key: $DeployKeyPath"
}

if (Test-Path -LiteralPath "$DeployKeyPath.pub") {
    throw "Refusing to overwrite the existing public key: $DeployKeyPath.pub"
}

New-Item `
    -ItemType Directory `
    -Path (Split-Path -Parent $DeployKeyPath) `
    -Force `
    | Out-Null

$SshKeygen = [System.Diagnostics.ProcessStartInfo]::new()
$SshKeygen.FileName = "ssh-keygen"
$SshKeygen.UseShellExecute = $false

foreach ($Argument in @(
    "-t",
    "ed25519",
    "-a",
    "100",
    "-f",
    $DeployKeyPath,
    "-C",
    $DeployKeyComment,
    "-N"
)) {
    [void] $SshKeygen.ArgumentList.Add($Argument)
}

[void] $SshKeygen.ArgumentList.Add("")

$SshKeygenProcess = [System.Diagnostics.Process]::Start($SshKeygen)
$SshKeygenProcess.WaitForExit()

if ($SshKeygenProcess.ExitCode -ne 0) {
    throw "ssh-keygen failed with exit code $($SshKeygenProcess.ExitCode)."
}

$PublicKeyText = (
    Get-Content `
        -LiteralPath "$DeployKeyPath.pub" `
        -Raw
).Trim()

if ($PublicKeyText -notmatch '^ssh-ed25519[ ]') {
    throw "The generated public key is not an Ed25519 key."
}

$PublicKeyBase64 = [Convert]::ToBase64String(
    [System.Text.Encoding]::UTF8.GetBytes($PublicKeyText)
)

$RemoteInstallCommand = @'
set -euo pipefail
umask 077
mkdir -p "$HOME/.ssh"
touch "$HOME/.ssh/authorized_keys"
public_key="$(printf '%s' '__PUBLIC_KEY_BASE64__' | base64 --decode)"
if ! grep -qxF "${public_key}" "$HOME/.ssh/authorized_keys"; then
  printf '%s\n' "${public_key}" >> "$HOME/.ssh/authorized_keys"
fi
chmod 700 "$HOME/.ssh"
chmod 600 "$HOME/.ssh/authorized_keys"
'@.Replace('__PUBLIC_KEY_BASE64__', $PublicKeyBase64)

Write-Host "Installing only the public key on $DeployUser@$DeployHost..."
ssh `
    "$DeployUser@$DeployHost" `
    $RemoteInstallCommand

if ($LASTEXITCODE -ne 0) {
    throw "Public-key installation failed with exit code $LASTEXITCODE."
}

Write-Host "Verifying the dedicated deployment identity..."
ssh `
    -i $DeployKeyPath `
    -o IdentitiesOnly=yes `
    -o BatchMode=yes `
    "$DeployUser@$DeployHost" `
    'set -e; sudo -n true; sudo docker version --format "{{.Server.Version}}"'

if ($LASTEXITCODE -ne 0) {
    throw "Deployment-key verification failed with exit code $LASTEXITCODE."
}

Write-Host ""
Write-Host "Deployment key is ready."
Write-Host "Use this private-key path when prompted for DEPLOY_SSH_PRIVATE_KEY:"
Write-Host "  $DeployKeyPath"
Write-Host ""
Write-Host "Do not upload the .pub file as the GitHub secret."
Current screenshot versus required state: the five Environment secrets are required. The two repository-level GitHub App secrets shown in the existing web UI repository are optional fallback copies. Do not create repository-level Harbor or deployment-key duplicates unless another workflow that does not declare this Environment requires them.

Create the required Environment secrets

# Run in Windows PowerShell 7 from any directory.
# GitHub CLI must already be installed and authenticated.

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$TargetRepository = "fp-001-org/<<REPOSITORY_NAME>>"
$EnvironmentName = "dev"

$AppIdSecretName = "FP_CI_APP_ID"
$AppPrivateKeySecretName = "FP_CI_APP_PRIVATE_KEY"
$HarborUsernameSecretName = "HARBOR_USERNAME"
$HarborPasswordSecretName = "HARBOR_PASSWORD"
$DeploySshKeySecretName = "DEPLOY_SSH_PRIVATE_KEY"

function Assert-NativeCommand {
    param(
        [Parameter(Mandatory)]
        [string] $Operation
    )

    if ($LASTEXITCODE -ne 0) {
        throw "$Operation failed with exit code $LASTEXITCODE."
    }
}

Write-Host "Verifying GitHub CLI authentication..."
gh auth status
Assert-NativeCommand "GitHub CLI authentication check"

Write-Host "Verifying access to $TargetRepository..."
gh repo view $TargetRepository --json nameWithOwner,visibility
Assert-NativeCommand "Repository access check"

Write-Host "Creating or reconciling GitHub Environment '$EnvironmentName'..."
gh api `
    --method PUT `
    "repos/$TargetRepository/environments/$EnvironmentName" `
    | Out-Null
Assert-NativeCommand "GitHub Environment reconciliation"

Write-Host ""
Write-Host "Enter the numeric GitHub App ID when prompted."
gh secret set `
    $AppIdSecretName `
    --repo $TargetRepository `
    --env $EnvironmentName
Assert-NativeCommand "Environment GitHub App ID secret creation"

$AppPrivateKeyPath = Read-Host "Full path to the GitHub App private-key PEM file"

if (-not (Test-Path -LiteralPath $AppPrivateKeyPath -PathType Leaf)) {
    throw "GitHub App private-key file was not found: $AppPrivateKeyPath"
}

$AppPrivateKeyText = Get-Content `
    -LiteralPath $AppPrivateKeyPath `
    -Raw

if ($AppPrivateKeyText -notmatch "-----BEGIN .*PRIVATE KEY-----") {
    throw "The selected GitHub App key does not look like a PEM private key."
}

$AppPrivateKeyText |
    gh secret set `
        $AppPrivateKeySecretName `
        --repo $TargetRepository `
        --env $EnvironmentName
Assert-NativeCommand "Environment GitHub App private-key secret creation"

Write-Host ""
Write-Host "Enter the Harbor username when prompted."
gh secret set `
    $HarborUsernameSecretName `
    --repo $TargetRepository `
    --env $EnvironmentName
Assert-NativeCommand "Environment Harbor username secret creation"

Write-Host ""
Write-Host "Enter the Harbor password when prompted."
gh secret set `
    $HarborPasswordSecretName `
    --repo $TargetRepository `
    --env $EnvironmentName
Assert-NativeCommand "Environment Harbor password secret creation"

$DeploySshPrivateKeyPath = Read-Host "Full path to the unencrypted OpenSSH/PEM deployment private key"

if (-not (Test-Path -LiteralPath $DeploySshPrivateKeyPath -PathType Leaf)) {
    throw "Deployment private-key file was not found: $DeploySshPrivateKeyPath"
}

$DeploySshPrivateKeyText = Get-Content `
    -LiteralPath $DeploySshPrivateKeyPath `
    -Raw

if ($DeploySshPrivateKeyText -match "^PuTTY-User-Key-File-") {
    throw "A PuTTY .ppk key was selected. Export it as an OpenSSH private key first."
}

if ($DeploySshPrivateKeyText -notmatch "-----BEGIN (OPENSSH |RSA |EC |DSA |)PRIVATE KEY-----") {
    throw "The deployment key does not look like an OpenSSH/PEM private key."
}

$DeploySshPrivateKeyText |
    gh secret set `
        $DeploySshKeySecretName `
        --repo $TargetRepository `
        --env $EnvironmentName
Assert-NativeCommand "Environment deployment SSH private-key secret creation"

Remove-Variable AppPrivateKeyText -ErrorAction SilentlyContinue
Remove-Variable DeploySshPrivateKeyText -ErrorAction SilentlyContinue

Write-Host ""
Write-Host "Environment secrets now present:"
gh secret list `
    --repo $TargetRepository `
    --env $EnvironmentName
Assert-NativeCommand "Environment secret listing"

Write-Host ""
Write-Host "Required Environment secrets were created for:"
Write-Host "  Repository:  $TargetRepository"
Write-Host "  Environment: $EnvironmentName"

Create the optional repository-level GitHub App fallbacks

# OPTIONAL โ€” match the fp-web-ui-001 screenshot by also creating
# repository-level fallback copies of only the GitHub App credentials.
#
# The generated workflow uses a GitHub Environment on both jobs. When the same
# secret name exists at repository and Environment scope, the Environment value
# takes precedence.

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$TargetRepository = "fp-001-org/<<REPOSITORY_NAME>>"
$AppIdSecretName = "FP_CI_APP_ID"
$AppPrivateKeySecretName = "FP_CI_APP_PRIVATE_KEY"

function Assert-NativeCommand {
    param(
        [Parameter(Mandatory)]
        [string] $Operation
    )

    if ($LASTEXITCODE -ne 0) {
        throw "$Operation failed with exit code $LASTEXITCODE."
    }
}

Write-Host "Enter the numeric GitHub App ID when prompted."
gh secret set `
    $AppIdSecretName `
    --repo $TargetRepository
Assert-NativeCommand "Repository GitHub App ID secret creation"

$AppPrivateKeyPath = Read-Host "Full path to the GitHub App private-key PEM file"

if (-not (Test-Path -LiteralPath $AppPrivateKeyPath -PathType Leaf)) {
    throw "GitHub App private-key file was not found: $AppPrivateKeyPath"
}

$AppPrivateKeyText = Get-Content `
    -LiteralPath $AppPrivateKeyPath `
    -Raw

if ($AppPrivateKeyText -notmatch "-----BEGIN .*PRIVATE KEY-----") {
    throw "The selected GitHub App key does not look like a PEM private key."
}

$AppPrivateKeyText |
    gh secret set `
        $AppPrivateKeySecretName `
        --repo $TargetRepository
Assert-NativeCommand "Repository GitHub App private-key secret creation"

Remove-Variable AppPrivateKeyText -ErrorAction SilentlyContinue

Write-Host ""
Write-Host "Repository secrets now present:"
gh secret list --repo $TargetRepository
Assert-NativeCommand "Repository secret listing"

4. ๐Ÿงฎ Review the Derived Values

GENERATED WORKFLOW ACCEPTANCE CHECKPOINT

Repository
  GitHub repository:            fp-001-org/<<REPOSITORY_NAME>>
  Source branch:                dev
  Workflow path:                .github/workflows/build-deploy-dev.yml
  GitHub Environment:           dev

ARC
  Runner scale set / runs-on:   <<REPOSITORY_NAME>>-dev-arc
  Build job:                    fresh ephemeral runner
  Deploy job:                   second fresh ephemeral runner

Build
  Service build parent:         <<SERVICE_BUILD_PARENT>>
  Service project:              <<REPOSITORY_NAME>>.csproj
  Shared repository:            fp-001
  Common project:               ac-common/ac-common.csproj
  Dockerfile:                   Dockerfile
  Docker platform:              linux/amd64

Registry
  Harbor registry:              harbor.aspireclan.com
  Harbor project:               fp-ci-cd
  Immutable tag:                dev-<full commit SHA>
  Moving tag:                   dev-latest

Deployment
  Host:                         dev-web-01 (192.168.8.120)
  SSH user:                     acllc
  Container:                    <<REPOSITORY_NAME>>-dev
  Port mapping:                 8080:8080
  Health path:                  /
  Remote env file:              /etc/fp/<<REPOSITORY_NAME>>/dev.env
  Failed health check:          rollback to previous image when available

Security
  GitHub App token:             runtime-created and repository-scoped
  Harbor credentials:          GitHub Environment secrets or runner injection
  Deployment private key:      temporary runner file, deleted in always()
  Known hosts:                 captured with ssh-keyscan and enforced
  Secret values committed:      no

5. ๐Ÿงฑ Generate the Full GitHub Actions Workflow

Copy the complete block below into .github/workflows/build-deploy-dev.yml . The value passed to CustomCodeBlock is one complete string, so indentation and GitHub expressions are preserved exactly.

# .github/workflows/build-deploy-dev.yml
# DROP-IN (FULL FILE) โ€” Kubernetes / ARC runners
#
# Flow:
#   1. A fresh <<REPOSITORY_NAME>>-dev-arc runner builds the image with Buildx and loads it into Docker.
#   2. The runner pushes the commit-SHA and dev-latest tags to Harbor using the Docker CLI.
#   3. A second fresh <<REPOSITORY_NAME>>-dev-arc runner connects to dev-web-01 over SSH.
#   4. dev-web-01 pulls the immutable commit-SHA image and recreates the container.
#
# Harbor compatibility:
#   Buildx does not push directly to Harbor. This preserves compatibility with the existing
#   Harbor/reverse-proxy setup by using the same Docker CLI push path as the working pipeline.
#
# Required <<REPOSITORY_NAME>> GitHub Environment: dev
# Required secrets:
#   FP_CI_APP_ID
#   FP_CI_APP_PRIVATE_KEY
#   HARBOR_USERNAME
#   HARBOR_PASSWORD
#   DEPLOY_SSH_PRIVATE_KEY
#     Accepted: multiline OpenSSH/PEM, literal \n form, or base64-encoded private key.
#
# The Harbor credentials can alternatively be injected into the ARC runner pod
# as HARBOR_USERNAME and HARBOR_PASSWORD. GitHub secrets are used as fallback.

name: "Build and Deploy <<REPOSITORY_NAME>> to dev-web-01"

on:
  push:
    branches:
      - "dev"

  workflow_dispatch:

permissions:
  contents: read

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

  HARBOR_REGISTRY: "harbor.aspireclan.com"
  HARBOR_PROJECT: "fp-ci-cd"

  CI_ACTIONS_REPO: "fp-ci-actions"
  CI_ACTIONS_REF: "v1.0.0"
  CI_ACTIONS_PATH: ".github/actions/status-step"

  SHARED_REPO: "${{ github.repository_owner }}/fp-001"
  SHARED_COMMONMODULES_PATH: "CommonModules"

  # Combined build-context layout retained from the existing pipeline.
  SERVICE_BUILD_PARENT: "<<SERVICE_BUILD_PARENT>>"
  SERVICE_PROJECT_RELATIVE_PATH: "<<REPOSITORY_NAME>>.csproj"
  COMMON_PROJECT_RELATIVE_PATH: "ac-common/ac-common.csproj"
  DOCKERFILE_RELATIVE_PATH: "Dockerfile"

  DEPLOY_HOST: "192.168.8.120"
  DEPLOY_SSH_USER: "acllc"
  DEPLOY_CONTAINER_NAME: "<<REPOSITORY_NAME>>-dev"
  DEPLOY_HOST_PORT: "8080"
  APP_CONTAINER_PORT: "8080"
  APP_HEALTHCHECK_PATH: "/"
  APP_REMOTE_ENV_FILE: "/etc/fp/<<REPOSITORY_NAME>>/dev.env"

  APP_RUNTIME_ENV_NAME: "ASPNETCORE_ENVIRONMENT"
  APP_RUNTIME_ENV_VALUE: "Development"
  APP_URLS_ENV_NAME: "ASPNETCORE_URLS"

  PRODUCT_LABEL: "fp"
  DEPLOYMENT_ENVIRONMENT_LABEL: "dev"

concurrency:
  group: "<<REPOSITORY_NAME>>-dev-${{ github.workflow }}"
  cancel-in-progress: false

jobs:
  build:
    name: Build and push image to Harbor

    environment:
      name: "dev"

    runs-on: "<<REPOSITORY_NAME>>-dev-arc"

    outputs:
      image_tag: ${{ steps.meta.outputs.image_tag }}
      image_full: ${{ steps.meta.outputs.image_full }}
      image_latest: ${{ steps.meta.outputs.image_latest }}
      image_digest: ${{ steps.push_harbor.outputs.digest }}

    env:
      HARBOR_USERNAME_FROM_GITHUB: ${{ secrets.HARBOR_USERNAME }}
      HARBOR_PASSWORD_FROM_GITHUB: ${{ secrets.HARBOR_PASSWORD }}

    steps:
      - name: Create GitHub App token for private repository checkouts
        id: app-token
        uses: actions/create-github-app-token@v3.2.0
        with:
          app-id: ${{ secrets.FP_CI_APP_ID }}
          private-key: ${{ secrets.FP_CI_APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}
          repositories: fp-001,${{ github.event.repository.name }},${{ env.CI_ACTIONS_REPO }}
          permission-contents: read

      - name: Checkout centralized CI actions repository
        id: checkout_ci_actions
        uses: actions/checkout@v5
        with:
          repository: ${{ github.repository_owner }}/${{ env.CI_ACTIONS_REPO }}
          ref: ${{ env.CI_ACTIONS_REF }}
          path: .ci-actions
          token: ${{ steps.app-token.outputs.token }}
          fetch-depth: 1

      - name: Status - Checkout centralized CI actions repository
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Checkout centralized CI actions repository"
          outcome: ${{ steps.checkout_ci_actions.outcome }}
          context: "env=${{ github.ref_name }} sha=${{ github.sha }}"
          success_title: "๐ŸŸข CI actions checkout"
          success_message: "๐ŸŸข Checked out '${{ github.repository_owner }}/${{ env.CI_ACTIONS_REPO }}@${{ env.CI_ACTIONS_REF }}'."
          failure_title: "๐Ÿ”ด CI actions checkout"
          failure_message: "๐Ÿ”ด Failed to checkout the centralized CI actions repository."

      - name: Status - Create GitHub App token
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Create GitHub App token"
          outcome: ${{ steps.app-token.outcome }}
          context: "env=${{ github.ref_name }} sha=${{ github.sha }}"
          success_title: "๐ŸŸข GitHub App token"
          success_message: "๐ŸŸข Token created for fp-001, <<REPOSITORY_NAME>>, and fp-ci-actions."
          failure_title: "๐Ÿ”ด GitHub App token"
          failure_message: "๐Ÿ”ด GitHub App token creation failed or was cancelled."

      - name: Checkout <<REPOSITORY_NAME>>
        id: checkout_service
        uses: actions/checkout@v5
        with:
          path: svc
          token: ${{ steps.app-token.outputs.token }}
          fetch-depth: 1

      - name: Status - Checkout <<REPOSITORY_NAME>>
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Checkout <<REPOSITORY_NAME>>"
          outcome: ${{ steps.checkout_service.outcome }}
          context: "env=${{ github.ref_name }} sha=${{ github.sha }}"
          success_title: "๐ŸŸข Service checkout"
          success_message: "๐ŸŸข Checked out '${{ github.repository }}' into svc."
          failure_title: "๐Ÿ”ด Service checkout"
          failure_message: "๐Ÿ”ด Service repository checkout failed or was cancelled."

      - name: Checkout shared FP repository
        id: checkout_shared
        uses: actions/checkout@v5
        with:
          repository: ${{ env.SHARED_REPO }}
          path: shared
          token: ${{ steps.app-token.outputs.token }}
          fetch-depth: 1

      - name: Status - Checkout shared FP repository
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Checkout shared FP repository"
          outcome: ${{ steps.checkout_shared.outcome }}
          context: "env=${{ github.ref_name }} sha=${{ github.sha }}"
          success_title: "๐ŸŸข Shared repository checkout"
          success_message: "๐ŸŸข Checked out '${{ env.SHARED_REPO }}' into shared."
          failure_title: "๐Ÿ”ด Shared repository checkout"
          failure_message: "๐Ÿ”ด Shared FP repository checkout failed or was cancelled."

      - name: Resolve build context
        id: ctx_build
        shell: bash
        run: |
          set -euo pipefail

          branch="${GITHUB_REF_NAME}"
          service_name="${{ github.event.repository.name }}"
          commit_sha="${GITHUB_SHA}"
          short_sha="${GITHUB_SHA::7}"

          if [ "${branch}" != "dev" ]; then
            echo "ERROR: This workflow deploys only the dev branch."
            exit 1
          fi

          image_repository="${branch}-${service_name}"
          image_tag="${commit_sha}"
          image_full="${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${image_repository}:${image_tag}"
          image_latest="${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${image_repository}:${branch}-latest"

          {
            echo "BRANCH=${branch}"
            echo "SERVICE_NAME=${service_name}"
            echo "COMMIT_SHA=${commit_sha}"
            echo "SHORT_SHA=${short_sha}"
            echo "IMAGE_REPOSITORY=${image_repository}"
            echo "IMAGE_TAG=${image_tag}"
            echo "IMAGE_FULL=${image_full}"
            echo "IMAGE_LATEST=${image_latest}"
            echo "PIPELINE_CONTEXT=svc=${service_name} env=${branch} sha=${short_sha}"
          } >> "${GITHUB_ENV}"

      - name: Status - Resolve build context
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Resolve build context"
          outcome: ${{ steps.ctx_build.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Build context"
          success_message: "๐ŸŸข Harbor image resolved as '${{ env.IMAGE_FULL }}'."
          failure_title: "๐Ÿ”ด Build context"
          failure_message: "๐Ÿ”ด Build context resolution failed or was cancelled."

      - name: Compute image metadata
        id: meta
        shell: bash
        run: |
          set -euo pipefail

          {
            echo "image_tag=${IMAGE_TAG}"
            echo "image_full=${IMAGE_FULL}"
            echo "image_latest=${IMAGE_LATEST}"
          } >> "${GITHUB_OUTPUT}"

      - name: Validate Harbor credentials
        id: validate_harbor_credentials
        shell: bash
        run: |
          set -euo pipefail

          harbor_user="${HARBOR_USERNAME:-${HARBOR_USERNAME_FROM_GITHUB:-}}"
          harbor_password="${HARBOR_PASSWORD:-${HARBOR_PASSWORD_FROM_GITHUB:-}}"

          if [ -z "${harbor_user}" ]; then
            echo "ERROR: HARBOR_USERNAME is not available from the ARC runner or GitHub Environment."
            exit 1
          fi

          if [ -z "${harbor_password}" ]; then
            echo "ERROR: HARBOR_PASSWORD is not available from the ARC runner or GitHub Environment."
            exit 1
          fi

          echo "::add-mask::${harbor_user}"
          echo "::add-mask::${harbor_password}"

      - name: Log in to Harbor
        id: harbor_login
        shell: bash
        run: |
          set -euo pipefail

          harbor_user="${HARBOR_USERNAME:-${HARBOR_USERNAME_FROM_GITHUB:-}}"
          harbor_password="${HARBOR_PASSWORD:-${HARBOR_PASSWORD_FROM_GITHUB:-}}"

          printf '%s' "${harbor_password}" |
            docker login "${HARBOR_REGISTRY}" \
              --username "${harbor_user}" \
              --password-stdin

      - name: Status - Harbor login
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Harbor login"
          outcome: ${{ steps.harbor_login.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Harbor login"
          success_message: "๐ŸŸข Logged in to '${{ env.HARBOR_REGISTRY }}'."
          failure_title: "๐Ÿ”ด Harbor login"
          failure_message: "๐Ÿ”ด Harbor login failed or was cancelled."

      - name: Ensure rsync is available
        id: ensure_rsync
        shell: bash
        run: |
          set -euo pipefail

          if command -v rsync >/dev/null 2>&1; then
            exit 0
          fi

          if command -v apk >/dev/null 2>&1; then
            sudo apk add --no-cache rsync
          elif command -v apt-get >/dev/null 2>&1; then
            sudo apt-get update
            sudo apt-get install --yes rsync
          else
            echo "ERROR: rsync is unavailable and no supported package manager was found."
            exit 1
          fi

      - name: Prepare combined FP build context
        id: prepare_build_context
        shell: bash
        run: |
          set -euo pipefail

          service_target="buildctx/${SERVICE_BUILD_PARENT}/${SERVICE_NAME}"

          rm -rf buildctx
          mkdir -p "${service_target}"
          mkdir -p buildctx/CommonModules

          rsync -a \
            --exclude '.git/' \
            --exclude '.github/' \
            --exclude '.vs/' \
            --exclude 'bin/' \
            --exclude 'obj/' \
            --exclude 'Dockerfile*.original' \
            --exclude '*.user' \
            --exclude '*.suo' \
            svc/ "${service_target}/"

          if [ ! -d "shared/${SHARED_COMMONMODULES_PATH}" ]; then
            echo "ERROR: shared/${SHARED_COMMONMODULES_PATH} was not found."
            echo "Shared repository contents:"
            find shared -maxdepth 3 -type d -print | sort
            exit 1
          fi

          rsync -a \
            --exclude '.git/' \
            --exclude '.github/' \
            --exclude '.vs/' \
            --exclude 'bin/' \
            --exclude 'obj/' \
            --exclude '*.user' \
            --exclude '*.suo' \
            "shared/${SHARED_COMMONMODULES_PATH}/" \
            buildctx/CommonModules/

          echo "Prepared service path: ${service_target}"
          echo "Prepared shared path: buildctx/CommonModules"

      - name: Status - Prepare combined FP build context
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Prepare combined FP build context"
          outcome: ${{ steps.prepare_build_context.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข FP build context"
          success_message: "๐ŸŸข Combined service and CommonModules build context created."
          failure_title: "๐Ÿ”ด FP build context"
          failure_message: "๐Ÿ”ด Combined build context creation failed or was cancelled."

      - name: Verify Dockerfile
        id: verify_dockerfile
        shell: bash
        run: |
          set -euo pipefail

          dockerfile_path="buildctx/${SERVICE_BUILD_PARENT}/${SERVICE_NAME}/${DOCKERFILE_RELATIVE_PATH}"

          if [ ! -f "${dockerfile_path}" ]; then
            echo "ERROR: Dockerfile was not found at '${dockerfile_path}'."
            find buildctx -maxdepth 7 -name Dockerfile -print || true
            exit 1
          fi

          echo "DOCKERFILE_PATH=${dockerfile_path}" >> "${GITHUB_ENV}"

      - name: Status - Verify Dockerfile
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Verify Dockerfile"
          outcome: ${{ steps.verify_dockerfile.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Dockerfile"
          success_message: "๐ŸŸข Dockerfile found at '${{ env.DOCKERFILE_PATH }}'."
          failure_title: "๐Ÿ”ด Dockerfile"
          failure_message: "๐Ÿ”ด Dockerfile verification failed."

      - name: Set up Docker Buildx
        id: setup_buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver: docker-container
          driver-opts: |
            network=host

      - name: Status - Set up Docker Buildx
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Set up Docker Buildx"
          outcome: ${{ steps.setup_buildx.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Docker Buildx"
          success_message: "๐ŸŸข Docker Buildx initialized."
          failure_title: "๐Ÿ”ด Docker Buildx"
          failure_message: "๐Ÿ”ด Docker Buildx initialization failed or was cancelled."

      - name: Validate combined Docker build context
        shell: bash
        run: |
          set -euo pipefail
        
          BUILD_CONTEXT="buildctx"
        
          SERVICE_PROJECT="${BUILD_CONTEXT}/${SERVICE_BUILD_PARENT}/${SERVICE_NAME}/${SERVICE_PROJECT_RELATIVE_PATH}"
          COMMON_PROJECT="${BUILD_CONTEXT}/CommonModules/${COMMON_PROJECT_RELATIVE_PATH}"
        
          echo "Validating combined build context..."
          echo "Service project: ${SERVICE_PROJECT}"
          echo "Common project:  ${COMMON_PROJECT}"
        
          if [ ! -f "${SERVICE_PROJECT}" ]; then
            echo "ERROR: Service project was not found:"
            echo "  ${SERVICE_PROJECT}"
            echo
            echo "Available files:"
            find "${BUILD_CONTEXT}" \
              -path '*/.git' -prune \
              -o -maxdepth 6 -type f -print \
              | sort
            exit 1
          fi
        
          if [ ! -f "${COMMON_PROJECT}" ]; then
            echo "ERROR: CommonModules project was not found:"
            echo "  ${COMMON_PROJECT}"
            echo
            echo "Available files:"
            find "${BUILD_CONTEXT}" \
              -path '*/.git' -prune \
              -o -maxdepth 6 -type f -print \
              | sort
            exit 1
          fi
        
          echo "Combined Docker build context is valid."

      - name: Build <<REPOSITORY_NAME>> image and load into Docker
        id: build_image
        uses: docker/build-push-action@v7.0.0
        with:
          context: buildctx
          file: ${{ env.DOCKERFILE_PATH }}
          push: false
          load: true
          tags: |
            ${{ steps.meta.outputs.image_full }}
            ${{ steps.meta.outputs.image_latest }}
          platforms: "linux/amd64"
          network: host
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: false
          sbom: false

      - name: Status - Build <<REPOSITORY_NAME>> image
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Build <<REPOSITORY_NAME>> image"
          outcome: ${{ steps.build_image.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Docker image build"
          success_message: "๐ŸŸข Built and loaded '${{ steps.meta.outputs.image_full }}' into the runner Docker daemon."
          failure_title: "๐Ÿ”ด Docker image build"
          failure_message: "๐Ÿ”ด <<REPOSITORY_NAME>> image build or local Docker load failed."

      - name: Push <<REPOSITORY_NAME>> image to Harbor using Docker CLI
        id: push_harbor
        shell: bash
        run: |
          set -euo pipefail

          commit_push_log="${RUNNER_TEMP}/harbor-commit-push.log"
          latest_push_log="${RUNNER_TEMP}/harbor-latest-push.log"

          echo "Verifying locally loaded image tags:"
          docker image inspect "${IMAGE_FULL}" >/dev/null
          docker image inspect "${IMAGE_LATEST}" >/dev/null

          echo
          echo "Pushing immutable commit tag:"
          echo "  ${IMAGE_FULL}"

          docker push "${IMAGE_FULL}" 2>&1 |
            tee "${commit_push_log}"

          echo
          echo "Pushing dev-latest tag:"
          echo "  ${IMAGE_LATEST}"

          docker push "${IMAGE_LATEST}" 2>&1 |
            tee "${latest_push_log}"

          image_digest="$(
            sed -nE \
              's/.*digest: (sha256:[0-9a-f]{64}).*/\1/p' \
              "${commit_push_log}" |
              tail -n 1
          )"

          if [ -z "${image_digest}" ]; then
            repo_digest="$(
              docker image inspect \
                --format '{{range .RepoDigests}}{{println .}}{{end}}' \
                "${IMAGE_FULL}" \
                2>/dev/null |
                grep -F "${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_REPOSITORY}@sha256:" |
                head -n 1 || true
            )"

            if [[ "${repo_digest}" == *@sha256:* ]]; then
              image_digest="${repo_digest#*@}"
            fi
          fi

          if [ -z "${image_digest}" ]; then
            echo "ERROR: Harbor push completed, but the image digest could not be determined."
            echo "Commit push output:"
            cat "${commit_push_log}"
            exit 1
          fi

          echo "Harbor image digest: ${image_digest}"
          echo "digest=${image_digest}" >> "${GITHUB_OUTPUT}"

      - name: Status - Push <<REPOSITORY_NAME>> image to Harbor
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Push <<REPOSITORY_NAME>> image to Harbor"
          outcome: ${{ steps.push_harbor.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Harbor image push"
          success_message: "๐ŸŸข Docker CLI pushed '${{ steps.meta.outputs.image_full }}' and '${{ steps.meta.outputs.image_latest }}'."
          failure_title: "๐Ÿ”ด Harbor image push"
          failure_message: "๐Ÿ”ด Docker CLI failed to push the <<REPOSITORY_NAME>> image to Harbor."

      - name: Print Harbor image metadata
        id: print_image
        shell: bash
        run: |
          set -euo pipefail

          echo "Harbor image:"
          echo "  Commit tag: ${{ steps.meta.outputs.image_full }}"
          echo "  Latest tag: ${{ steps.meta.outputs.image_latest }}"
          echo "  Digest:     ${{ steps.push_harbor.outputs.digest }}"

      - name: Log out from Harbor
        if: always()
        shell: bash
        run: |
          docker logout "${HARBOR_REGISTRY}" >/dev/null 2>&1 || true

  deploy:
    name: Pull image and recreate container on dev-web-01

    needs:
      - build

    environment:
      name: "dev"

    runs-on: "<<REPOSITORY_NAME>>-dev-arc"

    timeout-minutes: 30

    env:
      HARBOR_USERNAME_FROM_GITHUB: ${{ secrets.HARBOR_USERNAME }}
      HARBOR_PASSWORD_FROM_GITHUB: ${{ secrets.HARBOR_PASSWORD }}
      DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}

    steps:
      - name: Create GitHub App token for centralized CI actions
        id: app-token
        uses: actions/create-github-app-token@v3.2.0
        with:
          app-id: ${{ secrets.FP_CI_APP_ID }}
          private-key: ${{ secrets.FP_CI_APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}
          repositories: ${{ env.CI_ACTIONS_REPO }}
          permission-contents: read

      - name: Checkout centralized CI actions repository
        id: checkout_ci_actions
        uses: actions/checkout@v5
        with:
          repository: ${{ github.repository_owner }}/${{ env.CI_ACTIONS_REPO }}
          ref: ${{ env.CI_ACTIONS_REF }}
          path: .ci-actions
          token: ${{ steps.app-token.outputs.token }}
          fetch-depth: 1

      - name: Status - Checkout centralized CI actions repository
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Checkout centralized CI actions repository"
          outcome: ${{ steps.checkout_ci_actions.outcome }}
          context: "env=${{ github.ref_name }} sha=${{ github.sha }}"
          success_title: "๐ŸŸข CI actions checkout"
          success_message: "๐ŸŸข Centralized CI actions are available to the deploy job."
          failure_title: "๐Ÿ”ด CI actions checkout"
          failure_message: "๐Ÿ”ด Deploy-job CI actions checkout failed."

      - name: Resolve deployment context
        id: ctx_deploy
        shell: bash
        run: |
          set -euo pipefail

          branch="${GITHUB_REF_NAME}"
          service_name="${{ github.event.repository.name }}"
          short_sha="${GITHUB_SHA::7}"

          if [ "${branch}" != "dev" ]; then
            echo "ERROR: This workflow deploys only the dev branch."
            exit 1
          fi

          {
            echo "BRANCH=${branch}"
            echo "SERVICE_NAME=${service_name}"
            echo "SHORT_SHA=${short_sha}"
            echo "IMAGE_FULL=${{ needs.build.outputs.image_full }}"
            echo "IMAGE_DIGEST=${{ needs.build.outputs.image_digest }}"
            echo "PIPELINE_CONTEXT=svc=${service_name} env=${branch} sha=${short_sha}"
          } >> "${GITHUB_ENV}"

      - name: Status - Resolve deployment context
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Resolve deployment context"
          outcome: ${{ steps.ctx_deploy.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข Deploy context"
          success_message: "๐ŸŸข Deploying '${{ needs.build.outputs.image_full }}' to dev-web-01."
          failure_title: "๐Ÿ”ด Deploy context"
          failure_message: "๐Ÿ”ด Deployment context resolution failed."

      - name: Validate deployment credentials
        id: validate_deploy_credentials
        shell: bash
        run: |
          set -euo pipefail

          harbor_user="${HARBOR_USERNAME:-${HARBOR_USERNAME_FROM_GITHUB:-}}"
          harbor_password="${HARBOR_PASSWORD:-${HARBOR_PASSWORD_FROM_GITHUB:-}}"

          test -n "${harbor_user}" ||
            { echo "ERROR: Harbor username is unavailable."; exit 1; }

          test -n "${harbor_password}" ||
            { echo "ERROR: Harbor password is unavailable."; exit 1; }

          test -n "${DEPLOY_SSH_PRIVATE_KEY}" ||
            { echo "ERROR: DEPLOY_SSH_PRIVATE_KEY is unavailable."; exit 1; }

          echo "::add-mask::${harbor_user}"
          echo "::add-mask::${harbor_password}"

      - name: Prepare deployment SSH identity
        id: prepare_ssh
        shell: bash
        run: |
          set -euo pipefail

          ssh_key_file="${RUNNER_TEMP}/service-deploy-key"
          ssh_key_candidate="${RUNNER_TEMP}/service-deploy-key.candidate"
          ssh_key_decoded="${RUNNER_TEMP}/service-deploy-key.decoded"
          known_hosts_file="${RUNNER_TEMP}/service-known-hosts"
          key_material="${DEPLOY_SSH_PRIVATE_KEY}"

          umask 077

          # Support either a normal multiline private key or a key stored
          # with literal escaped newlines (\n) in the GitHub secret.
          if [[ "${key_material}" == *'\n'* ]]; then
            printf '%b' "${key_material}" > "${ssh_key_candidate}"
          else
            printf '%s\n' "${key_material}" > "${ssh_key_candidate}"
          fi

          tr -d '\r' \
            < "${ssh_key_candidate}" \
            > "${ssh_key_file}"

          # If the secret has no PEM/OpenSSH header, also support a base64-
          # encoded private key. Whitespace is removed before decoding.
          if ! grep -Eq \
            '^-----BEGIN (OPENSSH |RSA |EC |DSA |)PRIVATE KEY-----$' \
            "${ssh_key_file}"
          then
            if grep -q '^PuTTY-User-Key-File-' "${ssh_key_file}"; then
              echo "ERROR: DEPLOY_SSH_PRIVATE_KEY contains a PuTTY .ppk key."
              echo "Export it as an OpenSSH private key before saving the GitHub secret."
              exit 1
            fi

            if grep -Eq '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-)' "${ssh_key_file}"; then
              echo "ERROR: DEPLOY_SSH_PRIVATE_KEY contains a public key, not a private key."
              exit 1
            fi

            if printf '%s' "${key_material}" |
              tr -d '[:space:]' |
              base64 --decode > "${ssh_key_decoded}" 2>/dev/null
            then
              tr -d '\r' \
                < "${ssh_key_decoded}" \
                > "${ssh_key_file}"
            fi
          fi

          chmod 600 "${ssh_key_file}"

          if ! grep -Eq \
            '^-----BEGIN (OPENSSH |RSA |EC |DSA |)PRIVATE KEY-----$' \
            "${ssh_key_file}"
          then
            echo "ERROR: DEPLOY_SSH_PRIVATE_KEY is not a recognizable OpenSSH/PEM private key."
            echo "Accepted formats: multiline private key, escaped-newline private key, or base64-encoded private key."
            exit 1
          fi

          # -P '' prevents an encrypted key from causing an interactive prompt.
          if ! ssh-keygen -y -P '' -f "${ssh_key_file}" >/dev/null 2>&1; then
            echo "ERROR: DEPLOY_SSH_PRIVATE_KEY could not be parsed by OpenSSH."
            echo "The key may be truncated, encrypted with a passphrase, or copied incorrectly."
            echo "Use the complete unencrypted private key that matches dev-web-01 authorized_keys."
            exit 1
          fi

          rm -f "${ssh_key_candidate}" "${ssh_key_decoded}"

          touch "${known_hosts_file}"
          chmod 600 "${known_hosts_file}"

          captured=false

          for attempt in $(seq 1 30); do
            if ssh-keyscan \
              -T 5 \
              -H "${DEPLOY_HOST}" \
              >> "${known_hosts_file}" 2>/dev/null
            then
              captured=true
              break
            fi

            echo "Waiting for SSH on ${DEPLOY_HOST} (attempt ${attempt}/30)..."
            sleep 10
          done

          if [ "${captured}" != "true" ]; then
            echo "ERROR: Unable to capture the SSH host key for ${DEPLOY_HOST}."
            exit 1
          fi

          {
            echo "SSH_KEY_FILE=${ssh_key_file}"
            echo "KNOWN_HOSTS_FILE=${known_hosts_file}"
          } >> "${GITHUB_ENV}"

      - name: Verify SSH and Docker on dev-web-01
        id: verify_remote
        shell: bash
        run: |
          set -euo pipefail

          ssh \
            -i "${SSH_KEY_FILE}" \
            -o IdentitiesOnly=yes \
            -o BatchMode=yes \
            -o StrictHostKeyChecking=yes \
            -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \
            "${DEPLOY_SSH_USER}@${DEPLOY_HOST}" \
            '
              set -e
              hostnamectl --static
              sudo -n true
              sudo docker version --format "{{.Server.Version}}"
            '

      - name: Status - Verify dev-web-01
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Verify dev-web-01"
          outcome: ${{ steps.verify_remote.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข dev-web-01 preflight"
          success_message: "๐ŸŸข SSH, passwordless sudo, and Docker are ready on 192.168.8.120."
          failure_title: "๐Ÿ”ด dev-web-01 preflight"
          failure_message: "๐Ÿ”ด SSH, sudo, or Docker verification failed."

      - name: Log dev-web-01 in to Harbor
        id: remote_harbor_login
        shell: bash
        run: |
          set -euo pipefail

          harbor_user="${HARBOR_USERNAME:-${HARBOR_USERNAME_FROM_GITHUB:-}}"
          harbor_password="${HARBOR_PASSWORD:-${HARBOR_PASSWORD_FROM_GITHUB:-}}"

          printf '%s' "${harbor_password}" |
            ssh \
              -i "${SSH_KEY_FILE}" \
              -o IdentitiesOnly=yes \
              -o BatchMode=yes \
              -o StrictHostKeyChecking=yes \
              -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \
              "${DEPLOY_SSH_USER}@${DEPLOY_HOST}" \
              "sudo docker login '${HARBOR_REGISTRY}' --username '${harbor_user}' --password-stdin"

      - name: Pull image and recreate <<REPOSITORY_NAME>> container
        id: deploy_container
        shell: bash
        run: |
          set -euo pipefail

          ssh \
            -i "${SSH_KEY_FILE}" \
            -o IdentitiesOnly=yes \
            -o BatchMode=yes \
            -o StrictHostKeyChecking=yes \
            -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \
            "${DEPLOY_SSH_USER}@${DEPLOY_HOST}" \
            bash -s -- \
              "${IMAGE_FULL}" \
              "${DEPLOY_CONTAINER_NAME}" \
              "${DEPLOY_HOST_PORT}" \
              "${APP_CONTAINER_PORT}" \
              "${APP_HEALTHCHECK_PATH}" \
              "${APP_REMOTE_ENV_FILE}" \
              "${APP_RUNTIME_ENV_NAME}" \
              "${APP_RUNTIME_ENV_VALUE}" \
              "${APP_URLS_ENV_NAME}" \
              "${PRODUCT_LABEL}" \
              "${DEPLOYMENT_ENVIRONMENT_LABEL}" \
              "${SERVICE_NAME}" \
              "${GITHUB_SHA}" <<'REMOTE_SCRIPT'
          set -euo pipefail

          image_full="$1"
          container_name="$2"
          host_port="$3"
          container_port="$4"
          healthcheck_path="$5"
          remote_env_file="$6"
          app_runtime_env_name="$7"
          app_runtime_env_value="$8"
          app_urls_env_name="$9"
          product_label="${10}"
          deployment_environment_label="${11}"
          service_label="${12}"
          commit_sha="${13}"

          previous_image="$(
            sudo docker inspect \
              --format '{{.Config.Image}}' \
              "${container_name}" \
              2>/dev/null || true
          )"

          env_file_args=()

          if [ -f "${remote_env_file}" ]; then
            echo "Using remote application environment file: ${remote_env_file}"
            env_file_args=(--env-file "${remote_env_file}")
          else
            echo "Remote application environment file is absent; continuing with workflow defaults."
          fi

          run_container() {
            local selected_image="$1"
            local runtime_env_args=()

            if [ -n "${app_runtime_env_name}" ]; then
              runtime_env_args+=(
                --env "${app_runtime_env_name}=${app_runtime_env_value}"
              )
            fi

            if [ -n "${app_urls_env_name}" ]; then
              runtime_env_args+=(
                --env "${app_urls_env_name}=http://+:${container_port}"
              )
            fi

            sudo docker run \
              --detach \
              --name "${container_name}" \
              --restart unless-stopped \
              --publish "${host_port}:${container_port}" \
              "${runtime_env_args[@]}" \
              "${env_file_args[@]}" \
              --label "com.aspireclan.product=${product_label}" \
              --label "com.aspireclan.environment=${deployment_environment_label}" \
              --label "com.aspireclan.service=${service_label}" \
              --label "com.aspireclan.commit=${commit_sha}" \
              "${selected_image}"
          }

          rollback() {
            if [ -z "${previous_image}" ]; then
              echo "No previous image is available for rollback."
              return 0
            fi

            echo "Rolling back to previous image: ${previous_image}"
            sudo docker rm --force "${container_name}" >/dev/null 2>&1 || true
            run_container "${previous_image}"
          }

          echo "Pulling immutable image: ${image_full}"
          sudo docker pull "${image_full}"

          if sudo docker container inspect "${container_name}" >/dev/null 2>&1; then
            echo "Removing existing container: ${container_name}"
            sudo docker rm --force "${container_name}"
          fi

          if ! run_container "${image_full}"; then
            rollback
            exit 1
          fi

          healthy=false

          for attempt in $(seq 1 30); do
            running="$(
              sudo docker inspect \
                --format '{{.State.Running}}' \
                "${container_name}" \
                2>/dev/null || true
            )"

            if [ "${running}" != "true" ]; then
              echo "Container is not running (attempt ${attempt}/30)."
              sleep 2
              continue
            fi

            docker_health="$(
              sudo docker inspect \
                --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
                "${container_name}" \
                2>/dev/null || true
            )"

            if [ "${docker_health}" = "healthy" ]; then
              healthy=true
              break
            fi

            if [ "${docker_health}" = "none" ]; then
              if command -v curl >/dev/null 2>&1; then
                if curl \
                  --fail \
                  --location \
                  --silent \
                  --show-error \
                  "http://127.0.0.1:${host_port}${healthcheck_path}" \
                  >/dev/null
                then
                  healthy=true
                  break
                fi
              elif [ "${attempt}" -ge 5 ]; then
                echo "curl is unavailable; accepting a continuously running container."
                healthy=true
                break
              fi
            fi

            echo "Waiting for application health (attempt ${attempt}/30, Docker health=${docker_health})..."
            sleep 2
          done

          if [ "${healthy}" != "true" ]; then
            echo "ERROR: The new service container did not become healthy."
            sudo docker ps --all --filter "name=^/${container_name}$"
            sudo docker logs --tail 200 "${container_name}" || true
            rollback
            exit 1
          fi

          echo "Deployment completed successfully."
          sudo docker ps \
            --filter "name=^/${container_name}$" \
            --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
          REMOTE_SCRIPT

      - name: Status - Deploy <<REPOSITORY_NAME>> container
        if: always()
        uses: ./.ci-actions/.github/actions/status-step
        with:
          step_name: "Deploy <<REPOSITORY_NAME>> container"
          outcome: ${{ steps.deploy_container.outcome }}
          context: ${{ env.PIPELINE_CONTEXT }}
          success_title: "๐ŸŸข dev-web-01 deployment"
          success_message: "๐ŸŸข '${{ needs.build.outputs.image_full }}' is running on dev-web-01."
          failure_title: "๐Ÿ”ด dev-web-01 deployment"
          failure_message: "๐Ÿ”ด Container deployment failed; rollback was attempted when a previous image was available."

      - name: Print deployment result
        if: success()
        shell: bash
        run: |
          set -euo pipefail

          echo "<<REPOSITORY_NAME>> deployment completed:"
          echo "  Host:      ${DEPLOY_HOST}"
          echo "  Container: ${DEPLOY_CONTAINER_NAME}"
          echo "  Image:     ${IMAGE_FULL}"
          echo "  URL:       http://${DEPLOY_HOST}:${DEPLOY_HOST_PORT}${APP_HEALTHCHECK_PATH}"

      - name: Log dev-web-01 out from Harbor
        if: always()
        shell: bash
        run: |
          set -euo pipefail

          if [ -n "${SSH_KEY_FILE:-}" ] &&
            [ -f "${SSH_KEY_FILE}" ] &&
            [ -n "${KNOWN_HOSTS_FILE:-}" ] &&
            [ -f "${KNOWN_HOSTS_FILE}" ]
          then
            ssh \
              -i "${SSH_KEY_FILE}" \
              -o IdentitiesOnly=yes \
              -o BatchMode=yes \
              -o StrictHostKeyChecking=yes \
              -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \
              "${DEPLOY_SSH_USER}@${DEPLOY_HOST}" \
              "sudo docker logout '${HARBOR_REGISTRY}' >/dev/null 2>&1 || true" \
              || true
          fi

      - name: Remove temporary SSH files
        if: always()
        shell: bash
        run: |
          rm -f "${SSH_KEY_FILE:-}" "${KNOWN_HOSTS_FILE:-}"

6. ๐Ÿ’พ Save and Review the Workflow

# Run from Windows PowerShell 7 after copying the generated YAML block.

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$RepositoryRoot = "<<LOCAL_REPOSITORY_PATH>>"
$WorkflowRelativePath = ".github/workflows/build-deploy-dev.yml"
$WorkflowFullPath = Join-Path $RepositoryRoot $WorkflowRelativePath

Set-Location $RepositoryRoot

New-Item `
    -ItemType Directory `
    -Path (Split-Path -Parent $WorkflowFullPath) `
    -Force `
    | Out-Null

Write-Host "Paste the generated YAML into:"
Write-Host "  $WorkflowFullPath"

Write-Host ""
Write-Host "After saving the file, run:"
Write-Host "  git status"
Write-Host "  git diff --check"
Write-Host "  git diff -- $WorkflowRelativePath"
Set-Location "<<LOCAL_REPOSITORY_PATH>>"

git status
git diff --check
git diff -- ".github/workflows/build-deploy-dev.yml"

git add ".github/workflows/build-deploy-dev.yml"
git commit -m "Add <<REPOSITORY_NAME>> dev build and deploy workflow"
git push origin "dev"

7. ๐Ÿš€ Dispatch and Verify

$TargetRepository = "fp-001-org/<<REPOSITORY_NAME>>"
$WorkflowFile = "build-deploy-dev.yml"
$SourceBranch = "dev"

gh workflow view `
    $WorkflowFile `
    --repo $TargetRepository

gh workflow run `
    $WorkflowFile `
    --repo $TargetRepository `
    --ref $SourceBranch

gh run list `
    --repo $TargetRepository `
    --workflow $WorkflowFile `
    --limit 5

The expected run creates one ephemeral ARC runner for the build job, removes it when the build completes, then creates another ephemeral runner for the deploy job. With a runner scale-set minimum of zero, no idle runner pod is expected after the workflow finishes.

Verify the deployed container on the target VM

ssh \
  -i ~/.ssh/id_ed25519_ansible \
  -o IdentitiesOnly=yes \
  acllc@192.168.8.120 \
  'sudo docker ps --filter "name=^/<<REPOSITORY_NAME>>-dev$" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"'
curl --fail --location \
  "http://192.168.8.120:8080/"

8. ๐Ÿฉบ Common Corrections

GitHub App token creation fails

Confirm the numeric App ID and complete PEM private key are stored under the generated secret names. Also confirm the App installation includes fp-001 , <<REPOSITORY_NAME>> , and fp-ci-actions .

The service project or Dockerfile is not found

Correct the service build parent, service-project-relative path, or Dockerfile-relative path. The service is copied under buildctx/<<SERVICE_BUILD_PARENT>>/<<REPOSITORY_NAME>> before validation and image construction.

The job waits for a runner

Confirm that runs-on exactly matches <<REPOSITORY_NAME>>-dev-arc , its listener is stable in arc-systems, and the GitHub repository is the repository registered by that scale set.

SSH private key validation fails

Store the complete, unencrypted OpenSSH/PEM private key. Do not store a PuTTY .ppk file or the public key. The generated workflow also accepts a literal escaped-newline form or a base64-encoded private key, but the PowerShell setup stores the original multiline key.

The new container fails health validation

Verify the host port, container port, health path, runtime variables, and remote environment file. The workflow prints container logs and attempts to restore the previous image when one was running before the deployment.

9. ๐Ÿ Completion Checkpoint

Version 2: secret acquisition guidance and deployment-key preparation are included in Section 3.

GENERATED WORKFLOW ACCEPTANCE CHECKPOINT

Repository
  GitHub repository:            fp-001-org/<<REPOSITORY_NAME>>
  Source branch:                dev
  Workflow path:                .github/workflows/build-deploy-dev.yml
  GitHub Environment:           dev

ARC
  Runner scale set / runs-on:   <<REPOSITORY_NAME>>-dev-arc
  Build job:                    fresh ephemeral runner
  Deploy job:                   second fresh ephemeral runner

Build
  Service build parent:         <<SERVICE_BUILD_PARENT>>
  Service project:              <<REPOSITORY_NAME>>.csproj
  Shared repository:            fp-001
  Common project:               ac-common/ac-common.csproj
  Dockerfile:                   Dockerfile
  Docker platform:              linux/amd64

Registry
  Harbor registry:              harbor.aspireclan.com
  Harbor project:               fp-ci-cd
  Immutable tag:                dev-<full commit SHA>
  Moving tag:                   dev-latest

Deployment
  Host:                         dev-web-01 (192.168.8.120)
  SSH user:                     acllc
  Container:                    <<REPOSITORY_NAME>>-dev
  Port mapping:                 8080:8080
  Health path:                  /
  Remote env file:              /etc/fp/<<REPOSITORY_NAME>>/dev.env
  Failed health check:          rollback to previous image when available

Security
  GitHub App token:             runtime-created and repository-scoped
  Harbor credentials:          GitHub Environment secrets or runner injection
  Deployment private key:      temporary runner file, deleted in always()
  Known hosts:                 captured with ssh-keyscan and enforced
  Secret values committed:      no