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.
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
Harbor and centralized CI actions
Combined Docker build context
Deployment target and container
Container runtime environment
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."
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