deploy-scp.yaml: download, back up the production database before touching anything, upload into a timestamped release, symlink the persistent website in, switch current and restart, verify /health with retries, prune old releases only once that check passes. No sudo, no container actions, one shared interface U5 will call next.
151 lines
7.6 KiB
YAML
151 lines
7.6 KiB
YAML
name: Deploy (SCP)
|
|
|
|
# Reusable deploy workflow, invoked by continuous_integration.yaml (U5) for both the automatic
|
|
# test deploy and the opt-in production deploy. One workflow, one input contract, so a second
|
|
# transport (e.g. FTPS for a future shared-hosting target) can be added later as an alternative
|
|
# without restructuring this workflow or its callers (FR-02, D-02, NFR-09).
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
artifact_name:
|
|
description: 'Build artifact to download and deploy'
|
|
required: true
|
|
type: string
|
|
environment:
|
|
description: 'Environment label, used in log output and step naming'
|
|
required: true
|
|
type: string
|
|
deploy_path:
|
|
description: 'Release root on the target host for this environment'
|
|
required: true
|
|
type: string
|
|
service_name:
|
|
description: 'systemd --user unit to restart after switching releases'
|
|
required: true
|
|
type: string
|
|
health_check_url:
|
|
description: 'Public /health URL to verify after restart'
|
|
required: true
|
|
type: string
|
|
run_db_backup:
|
|
description: 'Take a database backup before deploying (production only)'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
transport:
|
|
description: 'Transport mechanism. Only "scp" is implemented today; reserved for FTPS later'
|
|
required: false
|
|
type: string
|
|
default: scp
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Compute release timestamp
|
|
id: release
|
|
run: echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Download build artifact
|
|
uses: actions/download-artifact@v3
|
|
with:
|
|
name: ${{ inputs.artifact_name }}
|
|
path: ${{ inputs.artifact_name }}
|
|
|
|
# Production-only database backup, taken before anything on the host changes (FR-20, D-26).
|
|
# The backup script itself lives on the host (created once during Operations host setup) and
|
|
# is only invoked here — no database connection string or credential is ever known to this
|
|
# workflow, keeping D-16 ("runtime secrets live in host environment variables") intact.
|
|
- name: Back up database (${{ inputs.environment }})
|
|
if: ${{ inputs.run_db_backup }}
|
|
run: |
|
|
sudo apt-get update && sudo apt-get install -y sshpass
|
|
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
|
-p ${{ secrets.PI_MAIN_PORT }} \
|
|
-o StrictHostKeyChecking=no \
|
|
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
|
"bash ~/scripts/backup-slpmodularcms-db.sh ${{ inputs.environment }}"
|
|
|
|
# Uploads the published output to a fresh, timestamped release directory rather than
|
|
# overwriting the live one (FR-06, D-27) — the atomic switch happens in a later step, once
|
|
# this upload and the website-symlink step below have both succeeded.
|
|
#
|
|
# Plain shell step rather than a container SCP action: container-based actions fail on this
|
|
# runner with "failed to attach to container: unable to upgrade to tcp, received 409", a known
|
|
# limitation of Podman's Docker-compatible API for the attach/log-streaming that container
|
|
# actions rely on (D-05). A plain scp command needs no nested container.
|
|
- name: Upload release to ${{ inputs.environment }} (${{ inputs.transport }})
|
|
run: |
|
|
sudo apt-get update && sudo apt-get install -y sshpass
|
|
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
|
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
|
-p ${{ secrets.PI_MAIN_PORT }} \
|
|
-o StrictHostKeyChecking=no \
|
|
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
|
"mkdir -p $RELEASE_DIR"
|
|
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp \
|
|
-P ${{ secrets.PI_MAIN_PORT }} \
|
|
-o StrictHostKeyChecking=no \
|
|
-r ${{ inputs.artifact_name }}/* \
|
|
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }}:"$RELEASE_DIR"/
|
|
|
|
# The customer's public website (wwwroot/web) must survive every CMS deploy (FR-08, ASM-01).
|
|
# It lives outside the swapped release directory in a persistent shared/ folder, and is
|
|
# symlinked into each new release. mkdir -p is idempotent, so this is also correct on the very
|
|
# first-ever deploy, before any website workspace has published anything there (U1 already
|
|
# tolerates a missing wwwroot/web at startup). The publish output's own wwwroot/web (empty, or
|
|
# containing only the placeholder page) is removed before the symlink is created, so it never
|
|
# shadows the persistent content.
|
|
- name: Link persistent website content
|
|
run: |
|
|
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
|
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
|
-p ${{ secrets.PI_MAIN_PORT }} \
|
|
-o StrictHostKeyChecking=no \
|
|
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
|
"mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \
|
|
rm -rf $RELEASE_DIR/wwwroot/web && \
|
|
ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web"
|
|
|
|
# Atomic release switch (FR-06, D-27): `ln -sfn` replaces the `current` symlink target in a
|
|
# single filesystem operation, so there is no moment where `current` points at a half-written
|
|
# directory. The process is then restarted so it picks up the new assemblies — a running .NET
|
|
# process holds on to the ones it already loaded.
|
|
- name: Switch current release and restart service
|
|
run: |
|
|
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
|
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
|
-p ${{ secrets.PI_MAIN_PORT }} \
|
|
-o StrictHostKeyChecking=no \
|
|
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
|
"ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \
|
|
systemctl --user restart ${{ inputs.service_name }}"
|
|
|
|
# Verifies the restart actually produced a healthy process before this run is allowed to
|
|
# report success. Retries absorb ordinary process-startup time; a run that never turns healthy
|
|
# fails the job without touching `current` or pruning (see below) — no automatic rollback.
|
|
# A previous release is always still on disk to restore from manually (D-26).
|
|
- name: Verify /health
|
|
run: |
|
|
for attempt in $(seq 1 10); do
|
|
if curl -f -s -o /dev/null "${{ inputs.health_check_url }}"; then
|
|
echo "Health check passed on attempt $attempt"
|
|
exit 0
|
|
fi
|
|
echo "Health check attempt $attempt failed, retrying..."
|
|
sleep 3
|
|
done
|
|
echo "Health check did not pass after 10 attempts"
|
|
exit 1
|
|
|
|
# Retention: keep `current` plus exactly one previous release, so a manual rollback is always
|
|
# a re-point-and-restart away without rebuilding. Only runs after a passing health check —
|
|
# pruning after a failed check could leave the only other release as the sole survivor.
|
|
- name: Prune old releases
|
|
run: |
|
|
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
|
-p ${{ secrets.PI_MAIN_PORT }} \
|
|
-o StrictHostKeyChecking=no \
|
|
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
|
"cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf"
|