name: Deploy NAS Compose on: workflow_dispatch: inputs: host: description: "NAS SSH host" required: true default: "192.168.100.19" port: description: "NAS SSH port" required: true default: "22" user: description: "NAS SSH user" required: true default: "admin" deploy_base: description: "Base deployment directory on the NAS" required: true default: "/home/admin/geo-rankly" image_tag: description: "Docker image tag. Defaults to the short commit SHA." required: false default: "" keep_releases: description: "Number of old release directories to keep" required: false default: "5" permissions: contents: read jobs: deploy: name: Deploy Docker Compose to NAS runs-on: ubuntu-latest timeout-minutes: 180 env: NAS_HOST: ${{ inputs.host }} NAS_PORT: ${{ inputs.port }} NAS_USER: ${{ inputs.user }} DEPLOY_BASE: ${{ inputs.deploy_base }} REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} KEEP_RELEASES: ${{ inputs.keep_releases }} NAS_SSH_PASSWORD: ${{ secrets.NAS_SSH_PASSWORD }} NAS_DEPLOY_ENV: ${{ secrets.NAS_DEPLOY_ENV }} DOCKER_BUILDKIT: "1" steps: - name: Checkout uses: actions/checkout@v4 - name: Install SSH tools run: | apt-get update apt-get install -y openssh-client sshpass tar - name: Resolve deployment metadata run: | set -eu if [ -z "${NAS_SSH_PASSWORD:-}" ]; then echo "::error::Missing repository secret NAS_SSH_PASSWORD" exit 1 fi case "${NAS_PORT}" in *[!0-9]*|"") echo "::error::NAS SSH port must be numeric" exit 1 ;; esac case "${DEPLOY_BASE}" in /*) ;; *) echo "::error::deploy_base must be an absolute path" exit 1 ;; esac case "${DEPLOY_BASE}" in *[!A-Za-z0-9_./-]*) echo "::error::deploy_base contains unsupported characters" exit 1 ;; esac case "${KEEP_RELEASES}" in *[!0-9]*|"") KEEP_RELEASES=5 ;; esac image_tag="${REQUESTED_IMAGE_TAG}" if [ -z "${image_tag}" ]; then image_tag="$(git rev-parse --short=12 HEAD)" fi case "${image_tag}" in *[!A-Za-z0-9_.-]*|"") echo "::error::image_tag may only contain letters, numbers, underscore, dot, and dash" exit 1 ;; esac release_id="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)" { echo "IMAGE_TAG=${image_tag}" echo "RELEASE_ID=${release_id}" echo "KEEP_RELEASES=${KEEP_RELEASES}" } >> "${GITHUB_ENV}" echo "Image tag: ${image_tag}" echo "Release ID: ${release_id}" echo "Deploy base: ${DEPLOY_BASE}" - name: Prepare SSH known hosts run: | set -eu mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keyscan -p "${NAS_PORT}" "${NAS_HOST}" >> ~/.ssh/known_hosts - name: Upload release to NAS run: | set -eu remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" release_dir="${DEPLOY_BASE}/releases/${RELEASE_ID}" SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "mkdir -p '${release_dir}' '${DEPLOY_BASE}/shared'" git archive --format=tar.gz HEAD \ | SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "tar -xzf - -C '${release_dir}'" - name: Upload deploy env when provided run: | set -eu if [ -z "${NAS_DEPLOY_ENV:-}" ]; then echo "NAS_DEPLOY_ENV is empty; keeping existing NAS .env or creating from .env.example during deploy." exit 0 fi remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" printf '%s\n' "${NAS_DEPLOY_ENV}" \ | SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "cat > '${DEPLOY_BASE}/shared/.env' && chmod 600 '${DEPLOY_BASE}/shared/.env'" - name: Deploy with Docker Compose run: | set -eu remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "DEPLOY_BASE='${DEPLOY_BASE}' RELEASE_ID='${RELEASE_ID}' IMAGE_TAG='${IMAGE_TAG}' KEEP_RELEASES='${KEEP_RELEASES}' sh -se" <<'REMOTE_SCRIPT' set -eu release_dir="${DEPLOY_BASE}/releases/${RELEASE_ID}" shared_dir="${DEPLOY_BASE}/shared" lock_dir="${DEPLOY_BASE}/.deploy.lock" if ! mkdir "${lock_dir}" 2>/dev/null; then echo "Another deployment is already running: ${lock_dir}" exit 1 fi trap 'rmdir "${lock_dir}"' EXIT INT TERM if [ ! -f "${shared_dir}/.env" ]; then cp "${release_dir}/deploy/.env.example" "${shared_dir}/.env" chmod 600 "${shared_dir}/.env" echo "Created ${shared_dir}/.env from .env.example. Edit it on the NAS before production traffic if secrets are still placeholders." fi docker compose version IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 docker compose \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ config >/tmp/geo-rankly-compose.yaml IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 docker compose \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ up -d --build --remove-orphans ln -sfn "${release_dir}" "${DEPLOY_BASE}/current" IMAGE_TAG="${IMAGE_TAG}" docker compose \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ ps if [ "${KEEP_RELEASES}" -gt 0 ] 2>/dev/null; then find "${DEPLOY_BASE}/releases" -mindepth 1 -maxdepth 1 -type d \ | sort -r \ | awk "NR>${KEEP_RELEASES}" \ | xargs -r rm -rf fi REMOTE_SCRIPT - name: Deployment summary run: | summary_file="${GITHUB_STEP_SUMMARY:-/tmp/nas-compose-deploy-summary.md}" { echo "### NAS Docker Compose deployment" echo "" echo "- Host: ${NAS_HOST}:${NAS_PORT}" echo "- Deploy base: ${DEPLOY_BASE}" echo "- Release: ${RELEASE_ID}" echo "- Image tag: ${IMAGE_TAG}" echo "" echo "Useful NAS commands:" echo "" echo '```sh' echo "cd ${DEPLOY_BASE}/current" echo "docker compose --env-file ${DEPLOY_BASE}/shared/.env -f deploy/docker-compose.yaml ps" echo "docker compose --env-file ${DEPLOY_BASE}/shared/.env -f deploy/docker-compose.yaml logs -f tenant-api" echo '```' } >> "${summary_file}"