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. # Gitea Actions does not resolve `${{ inputs.* }}` inside a step's `name:` (unlike inside # `run:`, where it works fine — see the echo below) — kept static rather than showing the # literal, unresolved `${{ inputs.environment }}` text in the log. - name: Back up database if: ${{ inputs.run_db_backup }} env: SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} SSH_PORT: ${{ secrets.PI_MAIN_PORT }} SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | echo "Environment: ${{ inputs.environment }}" sudo apt-get update && sudo apt-get install -y sshpass sshpass -p "$SSH_PASS" ssh \ -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ "$SSH_USER@$SSH_HOST" \ "bash ~/scripts/backup-slpsoftware-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. # Same Gitea Actions limitation as the step above — static name, value logged via echo instead. - name: Upload release env: SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} SSH_PORT: ${{ secrets.PI_MAIN_PORT }} SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | echo "Environment: ${{ inputs.environment }}, transport: ${{ inputs.transport }}" sudo apt-get update && sudo apt-get install -y sshpass RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" sshpass -p "$SSH_PASS" ssh \ -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ "$SSH_USER@$SSH_HOST" \ "mkdir -p $RELEASE_DIR" sshpass -p "$SSH_PASS" scp \ -P "$SSH_PORT" \ -o StrictHostKeyChecking=no \ -r ${{ inputs.artifact_name }}/* \ "$SSH_USER@$SSH_HOST:$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. `mkdir -p $RELEASE_DIR/wwwroot` guards against `ln -s` failing # with "No such file or directory": dotnet publish only emits a wwwroot/ folder at all when the # source project has one with actual content, so a release built before any frontend content # exists can land with no wwwroot/ directory whatsoever, not merely an empty wwwroot/web/. - name: Link persistent website content env: SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} SSH_PORT: ${{ secrets.PI_MAIN_PORT }} SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" sshpass -p "$SSH_PASS" ssh \ -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ "$SSH_USER@$SSH_HOST" \ "mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \ mkdir -p $RELEASE_DIR/wwwroot && \ 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 env: SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} SSH_PORT: ${{ secrets.PI_MAIN_PORT }} SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" sshpass -p "$SSH_PASS" ssh \ -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ "$SSH_USER@$SSH_HOST" \ "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 env: SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} SSH_PORT: ${{ secrets.PI_MAIN_PORT }} SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | sshpass -p "$SSH_PASS" ssh \ -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ "$SSH_USER@$SSH_HOST" \ "cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf"