# cratly — publish a built site over SSH with rsync
#
# Canonical URL:  https://cratly.io/ci/v1/deploy-rsync-ssh.yml
# Documentation:  https://cratly.io/reference/pipeline
# Licence:        MIT
#
# Required masked CI/CD variables:
#   SSH_PRIVATE_KEY  private key, PEM or base64-encoded PEM (a masked variable
#                    must be single-line, which is what the base64 form is for)
#   SSH_KNOWN_HOSTS  output of `ssh-keyscan -p PORT HOST` for the target
#
# Needs a shell, ssh and rsync on the far end. Hosts offering nothing but the
# SFTP subsystem are served by deploy-sftp.yml instead.
#
# On atomicity, see `strategy` — the default assumes nothing but a filesystem
# that can rename, which is the widest ground available here.

spec:
  inputs:
    job_name:
      default: "deploy"
    stage:
      default: "deploy"
    needs:
      type: array
      default: [ "build" ]
    image:
      default: "alpine:3.20"
      description: "Any image is fine; ssh and rsync are installed if missing."
    artifact_dir:
      default: "site"
    host:
      description: "Target host name."
    user:
      description: "SSH user."
    port:
      default: "22"
    path:
      description: >-
        Absolute path on the target. With strategy `in-place` this is the
        document root; with `release-symlink` it is the directory holding
        `releases/` and the `current` symlink.
    strategy:
      default: "in-place"
      options: [ "in-place", "release-symlink" ]
      description: >-
        `in-place` uploads into a staging directory next to each file and renames
        everything into place at the end (rsync --delay-updates), then removes
        what the build dropped. It needs only rename, so it works on ordinary
        shared hosting, including document roots the host maps for you.
        `release-symlink` uploads a full copy under releases/<sha> and repoints a
        symlink, which swaps the whole site in one operation — but it needs
        symlink support, GNU coreutils (`mv -T`) and a document root you may
        point somewhere else. Plenty of hosts fail at least one of those, which
        is why it is not the default.
    keep_releases:
      default: "5"
      description: "strategy=release-symlink: how many past releases to keep for rollback."
    symlink_name:
      default: "current"
      description: "strategy=release-symlink: name of the symlink the web server serves."
    delete:
      default: "true"
      options: [ "true", "false" ]
      description: "Remove files at the target that the build no longer contains."
    rsync_extra_args:
      default: ""
    branch:
      default: "$CI_DEFAULT_BRANCH"
    environment:
      default: "production"

---

"$[[ inputs.job_name ]]":
  stage: $[[ inputs.stage ]]
  image: $[[ inputs.image ]]
  needs: $[[ inputs.needs ]]
  interruptible: false
  script:
    - |
      set -eu

      command -v rsync >/dev/null 2>&1 && command -v ssh >/dev/null 2>&1 || {
        if   command -v apk     >/dev/null 2>&1; then apk add --no-cache rsync openssh-client
        elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq rsync openssh-client
        else echo "no rsync/ssh and no known package manager in $[[ inputs.image ]]" >&2; exit 1
        fi
      }

      # Host key verification is not optional. Skipping it (StrictHostKeyChecking=no)
      # would make every publication trust whatever answers on that address — on a
      # runner that is a stranger's network as far as this job knows.
      if [ -z "${SSH_KNOWN_HOSTS:-}" ]; then
        echo "SSH_KNOWN_HOSTS is empty. Generate it with:" >&2
        echo "  ssh-keyscan -p $[[ inputs.port ]] $[[ inputs.host ]]" >&2
        echo "and store the output as a CI/CD variable — after checking the fingerprint" >&2
        echo "against what the host's operator published." >&2
        exit 1
      fi

      if [ -z "${SSH_PRIVATE_KEY:-}" ]; then
        echo "SSH_PRIVATE_KEY is empty — add the deploy key as a masked CI/CD variable." >&2
        exit 1
      fi

      mkdir -p ~/.ssh && chmod 700 ~/.ssh
      printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
      case "$SSH_PRIVATE_KEY" in
        *"BEGIN "*) printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_deploy ;;
        *)          printf '%s' "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_deploy ;;
      esac
      chmod 600 ~/.ssh/id_deploy

      SSH="ssh -i $HOME/.ssh/id_deploy -p $[[ inputs.port ]] -o IdentitiesOnly=yes"
      REMOTE="$[[ inputs.user ]]@$[[ inputs.host ]]"

      DELETE=""
      if [ "$[[ inputs.delete ]]" = "true" ]; then DELETE="--delete-after"; fi

      if [ "$[[ inputs.strategy ]]" = "release-symlink" ]; then
        RELEASE="$[[ inputs.path ]]/releases/$CI_COMMIT_SHA"
        $SSH "$REMOTE" "mkdir -p '$RELEASE'"

        # --delay-updates buys nothing here: the release directory is not the one
        # being served, and the symlink swap below is what visitors see.
        rsync -a --rsh="$SSH" $[[ inputs.rsync_extra_args ]] \
          "$[[ inputs.artifact_dir ]]/" "$REMOTE:$RELEASE/"

        # ln -sfn is remove-then-create: for a moment the site is a dangling
        # link. Creating the new link beside it and renaming over the old one
        # replaces it in a single operation instead.
        $SSH "$REMOTE" "
          set -eu
          cd '$[[ inputs.path ]]'
          ln -s 'releases/$CI_COMMIT_SHA' '.$[[ inputs.symlink_name ]].new'
          mv -Tf '.$[[ inputs.symlink_name ]].new' '$[[ inputs.symlink_name ]]'
          ls -1dt releases/*/ | tail -n +$(( $[[ inputs.keep_releases ]] + 1 )) | xargs -r rm -rf
        "
      else
        # --delay-updates collects the whole transfer in a staging directory on
        # the target and renames the files into place once it is complete, so a
        # failed or slow upload cannot leave the site half-replaced. --delete-after
        # then prunes, in that order: files are never missing before their
        # replacements have arrived.
        rsync -a --delay-updates $DELETE --rsh="$SSH" $[[ inputs.rsync_extra_args ]] \
          "$[[ inputs.artifact_dir ]]/" "$REMOTE:$[[ inputs.path ]]/"
      fi
  environment:
    name: $[[ inputs.environment ]]
  rules:
    - if: $CI_COMMIT_BRANCH == "$[[ inputs.branch ]]"
