Files
projectsend/update.sh
T
Ignacio Nelson 525c464327 Merge pull request #1740 from denkfabrik-li/fix/update-keeps-cache-signal
Leave the caches update.sh's own update command needs to see
2026-09-01 01:17:46 -03:00

812 lines
35 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Updates a ProjectSend installation that runs from files on a server —
# the release-zip path in INSTALL.md, not the Docker one.
#
# It replaces a sequence of nine commands, of which one (reloading PHP-FPM)
# is silently fatal to skip: with OPcache set the way production guides
# recommend, PHP never re-reads a file it has already compiled, so the
# database ends up updated while every visitor is still served the old
# code — and `php artisan` reports the new version throughout.
#
# What it will not do is decide anything for you. It asks before checking
# GitHub, asks before downloading, verifies the checksum of what it
# downloaded, and asks whether you have a backup before it touches a
# single file. The application itself still never downloads or applies
# anything: this runs when you run it.
#
# Usage:
# sudo ./update.sh # ask what is available, then apply it
# sudo ./update.sh --zip ~/ps-2.1.0.zip # apply a zip you already have
# ./update.sh --check # only report; changes nothing, needs no root
#
set -euo pipefail
REPO_API="https://api.github.com/repos/projectsend/projectsend/releases/latest"
RELEASES_PAGE="https://github.com/projectsend/projectsend/releases"
DISCORD_URL="https://discord.gg/VT9n6cyvXT"
ZIP=""
CHECK_ONLY=0
ASSUME_YES=0
FORCE=0
BACKUP=0
HAVE_BACKUP=0
NO_RESTART=0
KEEP_DOWNLOAD=0
WEB_USER=""
FPM_SERVICE=""
WORKER_SERVICE=""
BACKUP_DIR="/var/backups/projectsend"
DOWNLOAD_DIR=""
usage() {
cat <<'USAGE'
Usage: sudo ./update.sh [options]
With no options it asks whether to check GitHub for a newer release,
whether to download it, and whether you have a backup — then applies it.
--zip <path> Apply this release zip instead of asking about a
download. You fetch it; this never downloads a file
you did not ask for.
--check Report the installed and latest versions and stop.
Changes nothing and does not need root.
--backup Dump the database before applying anything.
--backup-dir <dir> Where dumps go. Default: /var/backups/projectsend
--i-have-a-backup Skip the backup question (for unattended runs).
--force Apply a zip older than what is installed, or one
whose version cannot be read. Migrations do not roll
back; this is not a casual flag.
--user <name> The user the web server runs as. Default: whoever
owns public/index.php.
--php-fpm <unit> PHP-FPM service to reload. Default: detected.
--worker <unit> Queue worker service to restart. Default:
projectsend-worker.service, if it exists.
--no-restart Do not touch systemd. You are then responsible for
reloading PHP-FPM; until you do, ProjectSend shows
your staff a banner saying so.
--download-dir <d> Where a downloaded zip goes. Default: a temp dir.
--keep-download Keep the downloaded zip after a successful update.
-y, --yes Answer yes to every question. Requires --backup or
--i-have-a-backup, so an unattended run cannot
quietly skip both.
-h, --help This.
USAGE
}
say() { printf '==> %s\n' "$*"; }
note() { printf ' %s\n' "$*"; }
warn() { printf '\033[33m %s\033[0m\n' "$*" >&2; }
fail() { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; }
# Prompts read from the terminal rather than stdin: this script may be
# piped or run from a wrapper, and a question nobody can answer would
# otherwise be answered by whatever happened to be on stdin.
ask() {
local prompt="$1" default="$2" reply
# --yes means yes to everything, not "take the default" — the two
# questions whose default is No are precisely the ones an unattended
# run still has to clear. That is why --yes is only accepted alongside
# --backup or --i-have-a-backup.
if [[ "$ASSUME_YES" == "1" ]]; then
printf '%s y\n' "$prompt"
return 0
fi
if [[ ! -r /dev/tty ]]; then
fail "No terminal to ask '$prompt'. Pass --yes (and --backup or --i-have-a-backup) for an unattended run."
fi
read -r -p "$prompt " reply < /dev/tty || true
reply="${reply:-$default}"
[[ "${reply,,}" == "y" || "${reply,,}" == "yes" ]]
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--zip) ZIP="${2:-}"; shift 2 ;;
--check) CHECK_ONLY=1; shift ;;
--backup) BACKUP=1; shift ;;
--backup-dir) BACKUP_DIR="${2:-}"; shift 2 ;;
--i-have-a-backup) HAVE_BACKUP=1; shift ;;
--force) FORCE=1; shift ;;
--user) WEB_USER="${2:-}"; shift 2 ;;
--php-fpm) FPM_SERVICE="${2:-}"; shift 2 ;;
--worker) WORKER_SERVICE="${2:-}"; shift 2 ;;
--no-restart) NO_RESTART=1; shift ;;
--download-dir) DOWNLOAD_DIR="${2:-}"; shift 2 ;;
--keep-download) KEEP_DOWNLOAD=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; echo >&2; echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
if [[ -n "$ZIP" && "$ZIP" =~ ^https?:// ]]; then
usage >&2
echo >&2
echo "--zip takes a file, not a URL. Run without --zip and answer yes to the download question, or fetch it yourself first." >&2
exit 2
fi
if [[ "$ASSUME_YES" == "1" && "$BACKUP" == "0" && "$HAVE_BACKUP" == "0" && "$CHECK_ONLY" == "0" ]]; then
fail "--yes needs either --backup (take one now) or --i-have-a-backup (you already did). An unattended update must not skip both."
fi
}
# The version literal, read without booting PHP: config/projectsend.php
# references an application enum, so it cannot simply be required.
#
# `| head -1` is deliberately absent from these pipelines. Under
# `set -o pipefail` head closes the pipe as soon as it has its line, the
# process feeding it dies of SIGPIPE, and the whole command substitution
# then reports failure — intermittently, depending on whether the producer
# finished first. Taking the first line in bash afterwards has no such race.
first_line() {
printf '%s' "${1%%$'\n'*}"
}
version_in_file() {
first_line "$(sed -n "s/^ *'version' *=> *'\([^']*\)'.*/\1/p" "$1" || true)"
}
version_in_zip() {
first_line "$(unzip -p "$1" config/projectsend.php 2>/dev/null | sed -n "s/^ *'version' *=> *'\([^']*\)'.*/\1/p" || true)"
}
# Compares the numeric cores only. GNU sort -V puts 2.1.0-rc.1 *after*
# 2.1.0, which is backwards for SemVer, so a prerelease is never compared
# here — the caller refuses that case instead.
version_newer() {
local a="${1%%-*}" b="${2%%-*}"
[[ "$a" != "$b" ]] && [[ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -1)" == "$a" ]]
}
in_container() {
[[ -f /.dockerenv || -f /run/.containerenv ]]
}
resolve_install_dir() {
# After the re-exec below this script no longer lives in the install,
# so the directory is passed through the environment instead.
if [[ -n "${PROJECTSEND_UPDATE_DIR:-}" ]]; then
INSTALL_DIR="$PROJECTSEND_UPDATE_DIR"
else
INSTALL_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
fi
for required in artisan public/index.php config/projectsend.php; do
[[ -e "$INSTALL_DIR/$required" ]] \
|| fail "$INSTALL_DIR does not look like a ProjectSend installation ($required is missing). Run this from the install directory."
done
}
# Runs before anything can overwrite this file. The release zip contains
# update.sh, step "Replace the files" writes it, and bash reads its own
# script lazily by byte offset — it would carry on executing whatever
# happened to be at that offset in the new file.
reexec_from_copy() {
[[ "${PROJECTSEND_UPDATE_REEXEC:-}" == "1" ]] && return 0
local self
# Template ends in X's with no suffix: busybox mktemp rejects
# anything after them, and the official image is Alpine.
self="$(mktemp "${TMPDIR:-/tmp}/projectsend-update-XXXXXX")"
cp -- "${BASH_SOURCE[0]}" "$self"
export PROJECTSEND_UPDATE_REEXEC=1 PROJECTSEND_UPDATE_DIR="$INSTALL_DIR" PROJECTSEND_UPDATE_SELF="$self"
exec bash "$self" "$@"
}
latest_release() {
command -v curl >/dev/null 2>&1 || { warn "curl is not installed, so this cannot check for a release."; return 1; }
local body
body="$(curl -fsS --max-time 10 -H 'User-Agent: ProjectSend' "$REPO_API" 2>/dev/null)" || {
warn "Could not reach GitHub. Look at $RELEASES_PAGE yourself, then re-run with --zip."
return 1
}
LATEST_VERSION="$(first_line "$(printf '%s' "$body" | sed -n 's/.*"tag_name" *: *"v\{0,1\}\([^"]*\)".*/\1/p' || true)")"
LATEST_URL="$(first_line "$(printf '%s' "$body" | tr ',' '\n' | sed -n 's/.*"browser_download_url" *: *"\([^"]*projectsend-[0-9][^"]*\.zip\)".*/\1/p' || true)")"
# v1's tags are still an r-number scheme on that repository, and
# comparing 2.0.1 with r2098 is meaningless — the same guard
# CheckForUpdatesCommand applies.
if [[ ! "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
warn "The latest tag ($LATEST_VERSION) is not a version this can compare. See $RELEASES_PAGE."
return 1
fi
return 0
}
download_release() {
local target="$1"
say "Downloading $(basename "$LATEST_URL")"
curl -fL --progress-bar -o "$target" "$LATEST_URL" || fail "The download failed. Nothing has been changed."
# Published beside the zip by the release process. Its absence is worth
# saying out loud rather than passing over quietly.
# -L, like the download above: GitHub redirects release assets to
# object storage, and without it curl cheerfully saves the redirect
# body and reports success.
if curl -fsSL --max-time 30 -o "$target.sha256" "$LATEST_URL.sha256" 2>/dev/null; then
say "Verifying the checksum"
# Compared by hand rather than with `sha256sum -c`: GNU spells the
# quiet flag --status and busybox spells it -s, and the official
# image is busybox. Comparing two strings needs neither.
local expected actual
expected="$(first_line "$(cat "$target.sha256")")"
expected="${expected%% *}"
actual="$(sha256sum "$target" | awk '{print $1}')"
[[ -n "$expected" && "$expected" == "$actual" ]] \
|| fail "Checksum mismatch on $target.
expected: ${expected:-<none published>}
got: $actual
That is not the file this release published. It has been left in place for you to look at."
note "Checksum matches."
else
warn "No .sha256 was published beside this release, so the download could not be verified."
ask "Continue with an unverified download? [y/N]" "n" || fail "Stopped. Nothing has been changed."
fi
}
detect_web_user() {
if [[ -z "$WEB_USER" ]]; then
WEB_USER="$(stat -c '%U' "$INSTALL_DIR/public/index.php" 2>/dev/null || echo www-data)"
fi
id -u "$WEB_USER" >/dev/null 2>&1 || fail "No such user: $WEB_USER. Pass --user with the account your web server runs as."
WEB_GROUP="$(id -gn "$WEB_USER")"
}
as_web_user() {
if command -v runuser >/dev/null 2>&1; then
( cd "$INSTALL_DIR" && runuser -u "$WEB_USER" -- "$@" )
else
( cd "$INSTALL_DIR" && su -s /bin/sh "$WEB_USER" -c "$(printf '%q ' "$@")" )
fi
}
detect_services() {
command -v systemctl >/dev/null 2>&1 || { SYSTEMD=0; return 0; }
SYSTEMD=1
if [[ -z "$FPM_SERVICE" ]]; then
local candidates
candidates="$(systemctl list-unit-files --no-legend --type=service 'php*fpm*.service' 2>/dev/null | awk '{print $1}')"
case "$(printf '%s' "$candidates" | grep -c . || true)" in
0) FPM_SERVICE="" ;;
1) FPM_SERVICE="$candidates" ;;
*)
FPM_SERVICE="$(first_line "$(printf '%s\n' "$candidates" | while read -r unit; do
systemctl is-active --quiet "$unit" && echo "$unit"
done || true)")"
;;
esac
if [[ -z "$FPM_SERVICE" ]]; then
warn "Could not work out which PHP-FPM service to reload. Pass --php-fpm <unit>, or reload it yourself afterwards."
fi
fi
if [[ -z "$WORKER_SERVICE" ]]; then
local worker
worker="$(systemctl list-unit-files --no-legend 'projectsend-worker.service' 2>/dev/null || true)"
# An `[[ ... ]] && assignment` here would be the function's last
# command, so a missing worker unit would return 1 and take the
# whole script down with it under `set -e`.
if [[ -n "$worker" ]]; then
WORKER_SERVICE="projectsend-worker.service"
fi
fi
return 0
}
# The worker unit predates the zips queue, and a worker that is not
# watching it finishes no zip downloads while cheerfully sending every
# email — with nothing in any log to say why. That is the worst shape a
# regression can take, so the updater repairs the unit rather than
# leaving a note in a changelog nobody reads on a server.
#
# Only the case that is unambiguous: a command with no --queue at all,
# which consumes `default` and nothing else. A unit that already names
# queues is somebody's deliberate arrangement — possibly with a second
# worker for zips — so that one is described, not rewritten.
ensure_worker_watches_zips() {
[[ "${SYSTEMD:-0}" == "1" && -n "${WORKER_SERVICE:-}" ]] || return 0
local unit_file exec_line
unit_file="$(systemctl show -p FragmentPath --value "$WORKER_SERVICE" 2>/dev/null || true)"
# Empty means systemd does not know that unit — almost always a
# mistyped --worker. Said out loud rather than skipped quietly: this
# whole function exists so that zip downloads do not silently never
# finish, and skipping it in silence produces exactly the outcome it
# is here to prevent, with the operator believing it was checked.
#
# (`systemctl show` answers an unknown *property* the same way, with an
# empty value and a zero exit. FragmentPath is right, and this is the
# sentence that would tell somebody if a future edit made it wrong.)
if [[ -z "$unit_file" ]]; then
warn "Could not read the unit file for $WORKER_SERVICE — systemd does not seem to know it."
warn "Skipping the check that your worker watches the 'zips' queue. If it does not,"
warn "zip downloads will never finish and nothing will say why. Check the name and rerun,"
warn "or see INSTALL.md for the queue:work line the worker needs."
return 0
fi
# Readable but not writable: the file is somebody else's to change —
# a root-owned unit under a non-root update, or a read-only /etc. The
# repair below cannot run, but the diagnosis still can.
if [[ ! -w "$unit_file" ]]; then
exec_line="$(sed -n 's/^ExecStart=//p' "$unit_file" 2>/dev/null | head -n 1)"
if [[ "$exec_line" == *queue:work* && "$exec_line" != *zips* ]]; then
warn "$WORKER_SERVICE does not watch the 'zips' queue, and $unit_file is not writable here."
warn "Zip downloads will never finish until it does. Change: queue:work -> queue:work --queue=default,zips"
fi
return 0
fi
exec_line="$(sed -n 's/^ExecStart=//p' "$unit_file" | head -n 1)"
case "$exec_line" in
*queue:work*) ;;
*) return 0 ;;
esac
if [[ "$exec_line" == *"--queue="* ]]; then
case "$exec_line" in
*zips*) return 0 ;;
*)
warn "$WORKER_SERVICE names its own queues and does not include 'zips'."
warn "Zip downloads will never finish unless some worker watches that queue."
warn "Add 'zips' to its --queue list, or run a second worker with --queue=zips."
return 0
;;
esac
fi
say "Your background worker predates the zips queue"
note "Building a zip download runs on its own queue now. $WORKER_SERVICE watches"
note "'default' only, so zips would never finish and nothing would say why."
note "Change: queue:work -> queue:work --queue=default,zips"
if ! ask "Update $unit_file? [Y/n]" "y"; then
warn "Left alone. Zip downloads will not finish until a worker watches the zips queue."
return 0
fi
cp -p "$unit_file" "$unit_file.projectsend-bak" 2>/dev/null || true
# Anchored on the literal command so a unit with other arguments keeps
# them, and matched once: a Type=notify unit can carry several
# ExecStart lines and only the first is this worker.
if sed -i '0,/^ExecStart=/{s|\(^ExecStart=.*queue:work\)|\1 --queue=default,zips|}' "$unit_file"; then
systemctl daemon-reload || warn "Could not reload systemd. Run: systemctl daemon-reload"
note "Updated. A copy of the old unit is at $unit_file.projectsend-bak"
else
warn "Could not edit $unit_file. Add --queue=default,zips to its ExecStart line yourself."
fi
return 0
}
env_value() {
local raw
raw="$(first_line "$(sed -n "s/^$1=//p" "$INSTALL_DIR/.env" 2>/dev/null || true)")"
raw="${raw%\"}"; raw="${raw#\"}"
raw="${raw%\'}"; raw="${raw#\'}"
printf '%s' "$raw"
}
take_backup() {
local connection database host port user password stamp target
connection="$(env_value DB_CONNECTION)"; connection="${connection:-mysql}"
database="$(env_value DB_DATABASE)"
host="$(env_value DB_HOST)"; host="${host:-127.0.0.1}"
port="$(env_value DB_PORT)"; port="${port:-3306}"
user="$(env_value DB_USERNAME)"
password="$(env_value DB_PASSWORD)"
stamp="$(date -u +%Y%m%d-%H%M%S)"
[[ "$BACKUP_DIR" == "$INSTALL_DIR/public"* ]] && fail "Refusing to write a database dump under public/ — it would be downloadable."
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
case "$connection" in
mysql|mariadb)
target="$BACKUP_DIR/projectsend-$INSTALLED_VERSION-$stamp.sql"
say "Dumping the database to $target"
# Password through the environment: an argument would be
# readable in `ps` by every user on the machine.
MYSQL_PWD="$password" mysqldump --single-transaction --routines --triggers \
-h "$host" -P "$port" -u "$user" "$database" > "$target" \
|| fail "The database dump failed. Nothing has been changed — fix this, or re-run without --backup once you have a backup of your own."
;;
pgsql)
target="$BACKUP_DIR/projectsend-$INSTALLED_VERSION-$stamp.sql"
say "Dumping the database to $target"
PGPASSWORD="$password" pg_dump -h "$host" -p "$port" -U "$user" "$database" > "$target" \
|| fail "The database dump failed. Nothing has been changed."
;;
sqlite)
target="$BACKUP_DIR/projectsend-$INSTALLED_VERSION-$stamp.sqlite"
say "Copying the database to $target"
cp -- "$database" "$target" || fail "Could not copy the SQLite database. Nothing has been changed."
;;
*)
fail "Unknown DB_CONNECTION '$connection' — take a backup yourself and re-run with --i-have-a-backup."
;;
esac
BACKUP_TAKEN="$target"
note "This is the database only. Your uploaded files in storage/app/files/ are not in it."
}
cleanup() {
local status=$?
if [[ "${SITE_IS_DOWN:-0}" == "1" ]]; then
as_web_user php artisan up >/dev/null 2>&1 || true
SITE_IS_DOWN=0
fi
[[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]] && rm -rf "$TMP_DIR"
[[ -n "${PROJECTSEND_UPDATE_SELF:-}" && -f "${PROJECTSEND_UPDATE_SELF:-}" ]] && rm -f "$PROJECTSEND_UPDATE_SELF"
if [[ "$status" != "0" && "${APPLYING:-0}" == "1" ]]; then
echo >&2
warn "The update did not finish."
warn "The site has been taken out of maintenance mode, but its files may be part-way updated."
[[ -n "${BACKUP_TAKEN:-}" ]] && warn "Your database dump is at $BACKUP_TAKEN."
# Skipped when the failure already printed instructions of its own,
# so the operator is never given two different sets at once.
if [[ "${RECOVERY_PRINTED:-0}" != "1" ]]; then
warn "To finish by hand: cd $INSTALL_DIR && sudo -u $WEB_USER php artisan projectsend:update"
[[ -n "${FPM_SERVICE:-}" ]] && warn "Then reload PHP: sudo systemctl reload $FPM_SERVICE"
fi
fi
exit $status
}
report_versions() {
note "Installed: $INSTALLED_VERSION"
if latest_release; then
if version_newer "$LATEST_VERSION" "$INSTALLED_VERSION"; then
note "Latest: $LATEST_VERSION"
echo
note "Read the changelog for $LATEST_VERSION first, and make sure you have a backup."
if in_container; then
note "This is a container, so its code comes from its image:"
note " docker compose pull && docker compose up -d"
else
note "Then: sudo ./update.sh"
fi
else
note "Latest: $LATEST_VERSION — you are up to date."
note "Re-applying the same release with --zip restores files that were modified or lost."
fi
fi
}
main() {
parse_args "$@"
resolve_install_dir
INSTALLED_VERSION="$(version_in_file "$INSTALL_DIR/config/projectsend.php")"
[[ -n "$INSTALLED_VERSION" ]] || INSTALLED_VERSION="unknown"
if [[ "$CHECK_ONLY" == "1" ]]; then
say "ProjectSend at $INSTALL_DIR"
report_versions
exit 0
fi
if in_container; then
note "Installed: $INSTALLED_VERSION"
echo
fail "This is a container: its code comes from its image, and replacing files inside it would be undone by the next recreate.
Update it with: docker compose pull && docker compose up -d"
fi
[[ "$EUID" -eq 0 ]] || fail "This needs root, to reload PHP-FPM and to write files owned by the web server user. Run: sudo ./update.sh"
reexec_from_copy "$@"
trap cleanup EXIT INT TERM
say "ProjectSend at $INSTALL_DIR"
# ---- what to apply -------------------------------------------------
if [[ -z "$ZIP" ]]; then
note "Installed: $INSTALLED_VERSION"
if ask "Check GitHub for a newer release? [Y/n]" "y" && latest_release; then
note "Latest: $LATEST_VERSION"
if ! version_newer "$LATEST_VERSION" "$INSTALLED_VERSION"; then
note "You are already on the latest release."
ask "Download and re-apply $LATEST_VERSION anyway, to restore modified files? [y/N]" "n" \
|| { note "Nothing to do."; exit 0; }
fi
if ask "Download $(basename "$LATEST_URL") and verify its checksum? [Y/n]" "y"; then
DOWNLOAD_DIR="${DOWNLOAD_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/projectsend-download.XXXXXX")}"
mkdir -p "$DOWNLOAD_DIR"
ZIP="$DOWNLOAD_DIR/$(basename "$LATEST_URL")"
download_release "$ZIP"
else
note "Download it yourself, then run:"
note " sudo ./update.sh --zip $(basename "$LATEST_URL")"
note " $LATEST_URL"
exit 0
fi
fi
fi
[[ -n "$ZIP" ]] || fail "Nothing to apply. Pass --zip <file>, or re-run and let it fetch the release."
[[ -f "$ZIP" ]] || fail "No such file: $ZIP"
# ---- is it sane, and is it forwards? -------------------------------
NEW_VERSION="$(version_in_zip "$ZIP")"
if [[ -z "$NEW_VERSION" ]]; then
[[ "$FORCE" == "1" ]] || fail "Could not read a version out of $ZIP. If you are sure it is a ProjectSend release, re-run with --force."
NEW_VERSION="unknown"
fi
# `unzip -p` rather than parsing a listing: it answers "is this exact
# entry there" on both Info-ZIP and busybox, and it involves no pipe —
# `unzip -l | grep -q` looks equivalent but is not, because grep -q
# closes the pipe, unzip takes SIGPIPE, and pipefail then reports a
# perfectly good zip as broken.
for entry in artisan public/index.php vendor/autoload.php; do
unzip -p "$ZIP" "$entry" >/dev/null 2>&1 \
|| fail "$ZIP has no $entry at its root — that is not a ProjectSend release zip.
If it unpacks into a folder of its own, this is not the file we publish; use the one from $RELEASES_PAGE."
done
if unzip -p "$ZIP" '.env' >/dev/null 2>&1; then
fail "$ZIP contains a .env. A release zip never does; refusing to overwrite your configuration."
fi
if [[ "$NEW_VERSION" != "unknown" && "$NEW_VERSION" != "$INSTALLED_VERSION" ]] && ! version_newer "$NEW_VERSION" "$INSTALLED_VERSION"; then
[[ "$FORCE" == "1" ]] || fail "$ZIP is version $NEW_VERSION and this installation is on $INSTALLED_VERSION.
Migrations only move forwards — there is no command that undoes them. To go back, restore the database dump you took before updating.
If you know what you are doing, re-run with --force."
fi
detect_web_user
detect_services
# ---- the backup question -------------------------------------------
if [[ "$HAVE_BACKUP" == "0" && "$BACKUP" == "0" ]]; then
if ! ask "Have you backed up the database and your files? [y/N]" "n"; then
warn "An update that goes wrong can lose data, and a migration cannot be undone."
ask "Continue anyway? [y/N]" "n" || fail "Stopped. Nothing has been changed."
if ask "Take a database dump now? [Y/n]" "y"; then
BACKUP=1
else
warn "Continuing without a backup, at your own risk."
fi
fi
fi
# ---- confirm --------------------------------------------------------
echo
say "About to update this installation"
note "Directory: $INSTALL_DIR"
note "Version: $INSTALLED_VERSION -> $NEW_VERSION"
note "Zip: $ZIP"
note "As user: $WEB_USER"
[[ "$BACKUP" == "1" ]] && note "Backup: yes, to $BACKUP_DIR"
if [[ "$NO_RESTART" == "1" || "${SYSTEMD:-0}" == "0" ]]; then
note "Restart: nothing (you must reload PHP-FPM yourself)"
else
note "Restart: ${FPM_SERVICE:-<none found>}${WORKER_SERVICE:+, $WORKER_SERVICE}"
fi
echo
ask "Proceed? [y/N]" "n" || fail "Stopped. Nothing has been changed."
APPLYING=1
[[ "$BACKUP" == "1" ]] && take_backup
# ---- apply ----------------------------------------------------------
say "Putting the site into maintenance mode"
as_web_user php artisan down --retry=60 >/dev/null
SITE_IS_DOWN=1
say "Unpacking $ZIP"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/projectsend-unpack.XXXXXX")"
unzip -q "$ZIP" -d "$TMP_DIR"
# Belt and braces. The zip has no storage/ content and no .env, but
# this is the copy that could destroy somebody's library, so it is
# made structurally impossible rather than merely unlikely.
rm -rf "$TMP_DIR/storage" "$TMP_DIR/.env"
say "Replacing the application files"
# vendor/ and public/build/ belong wholly to the release: merged, they
# keep orphaned classes and orphaned hashed assets from the old one.
rm -rf "$INSTALL_DIR/vendor" "$INSTALL_DIR/public/build"
# The compiled manifests and the cached configuration, and only those.
#
# The manifests must not survive the swap: they name the old release's
# package providers, and the first artisan run after the copy would try
# to load classes this version no longer ships. The cached
# configuration must not survive it either, for a sharper reason --
# it is read at every boot, so projectsend:update would read the
# *previous* release's version out of it, record that as the version it
# applied, and run the migrations under the old configuration.
#
# The route and event caches stay. Their presence is the signal
# projectsend:update reads to decide what to rebuild
# (UpdateInstallation::warmCaches), and it clears them itself moments
# later anyway. Wiping them here made an installation that had cached
# its routes look exactly like one that never had, so nothing was ever
# rebuilt and INSTALL.md's promise -- "projectsend:update notices they
# are in place and rebuilds them for you after every update" -- quietly
# stopped being true for anybody who updates with this script. Neither
# cache is read at boot: both are arrays of class names, consulted when
# a route is matched or an event dispatched.
#
# The warning about a cached configuration is said here, because after
# this the command cannot see one to warn about. Same sentence it uses,
# so an operator meets one wording rather than two.
if [[ -f "$INSTALL_DIR/bootstrap/cache/config.php" ]]; then
warn "A cached configuration was found and cleared. Do not run config:cache on this application — it stops TRUSTED_PROXIES from being read at all. See INSTALL.md."
fi
rm -f "$INSTALL_DIR/bootstrap/cache/config.php"
rm -f "$INSTALL_DIR/bootstrap/cache/packages.php"
rm -f "$INSTALL_DIR/bootstrap/cache/services.php"
cp -a "$TMP_DIR"/. "$INSTALL_DIR"/
# Symlinks are skipped on purpose. A symlink's own ownership decides
# nothing — access is governed by whatever it points at — and some
# filesystems refuse to change it at all: a bind mount from a host
# (public/storage on a developer's box is where this turned up), an NFS
# export with root_squash. Aborting an update over a cosmetic detail on
# a link is not a trade worth making, so they are left alone rather than
# tolerated by ignoring errors that might have mattered.
find "$INSTALL_DIR" \! -type l -exec chown "$WEB_USER:$WEB_GROUP" {} + \
|| warn "Some files could not be given to $WEB_USER. Check ownership under $INSTALL_DIR before trusting this install."
chmod -R u+rwX "$INSTALL_DIR/storage" "$INSTALL_DIR/bootstrap/cache"
say "Bringing the installation in line with the new code"
# The command comes from the code that was just unpacked, so a release
# older than this script does not have it. Only reachable by re-applying
# or forcing an old release — but the error Symfony prints for an unknown
# command is a list of thirteen unrelated ones, which is no help at all
# to somebody whose site is in maintenance mode.
local commands
commands="$(as_web_user php artisan list --raw --no-ansi 2>/dev/null || true)"
if [[ "$commands" != *"projectsend:update"* ]]; then
# The generic recovery block in cleanup() would otherwise name the
# very command this release does not have.
RECOVERY_PRINTED=1
fail "The release you unpacked ($NEW_VERSION) predates this update script: it has no projectsend:update command.
Finish it by hand, as $WEB_USER:
php artisan migrate --force
php artisan projectsend:ensure-roles
php artisan optimize:clear
php artisan queue:restart
Then reload PHP-FPM, and bring the site back with: php artisan up"
fi
as_web_user php artisan projectsend:update
# ---- restart --------------------------------------------------------
if [[ "$NO_RESTART" == "1" ]]; then
warn "Not touching systemd, as asked."
warn "PHP is still running the old code until you reload it — ProjectSend will keep telling your staff so."
# Said rather than done: the check below edits a unit file, which is
# exactly what --no-restart asks us not to do. But a worker that
# predates the zips queue is broken whether or not we are allowed to
# touch it, and this is the only place that knows to mention it.
warn "Your worker's queues were not checked either. If it predates the zips queue, zip downloads never finish — run without --no-restart, or add 'zips' to its --queue list yourself."
elif [[ "${SYSTEMD:-0}" == "1" && -n "${FPM_SERVICE:-}" ]]; then
say "Reloading $FPM_SERVICE"
systemctl reload "$FPM_SERVICE" 2>/dev/null || systemctl restart "$FPM_SERVICE" \
|| warn "Could not reload $FPM_SERVICE. Do it yourself, or PHP keeps serving the old code."
if [[ -n "${WORKER_SERVICE:-}" ]]; then
# Before the restart, so the worker comes back on the command
# it is going to keep rather than needing a second bounce.
ensure_worker_watches_zips
say "Restarting $WORKER_SERVICE"
systemctl restart "$WORKER_SERVICE" || warn "Could not restart $WORKER_SERVICE."
fi
else
warn "No PHP-FPM service was found to reload. Reload PHP yourself now, or it keeps serving the old code."
# The worker is a different unit from PHP-FPM, and not finding one
# says nothing about the other: a host running mod_php, or one where
# the FPM unit is named in a way this script does not recognise, can
# still have a systemd worker that predates the zips queue. The
# function returns immediately unless systemd and a worker unit are
# both there, so reaching it from here costs nothing and stops this
# branch being the one place the queue is never mentioned.
ensure_worker_watches_zips
# Paired with the edit, exactly as the FPM branch above pairs them,
# and for the reason its comment gives: the new --queue argument
# only reaches the worker when systemd next starts it from
# ExecStart. projectsend:update's queue:restart cannot deliver it —
# that makes a worker pick up new *code*, and it has already run by
# the time we get here, so the worker came back on the old command
# line. Without this, the unit is edited, the operator is told so,
# and zips still never finish: a false completion, which is worse
# than the silence this branch used to keep.
if [[ "${SYSTEMD:-0}" == "1" && -n "${WORKER_SERVICE:-}" ]]; then
say "Restarting $WORKER_SERVICE"
systemctl restart "$WORKER_SERVICE" || warn "Could not restart $WORKER_SERVICE."
fi
fi
say "Bringing the site back up"
as_web_user php artisan up >/dev/null
SITE_IS_DOWN=0
if [[ "$KEEP_DOWNLOAD" == "0" && -n "${DOWNLOAD_DIR:-}" && "$ZIP" == "$DOWNLOAD_DIR"/* ]]; then
rm -rf "$DOWNLOAD_DIR"
fi
APPLYING=0
echo
say "ProjectSend $NEW_VERSION is installed and running."
[[ -n "${BACKUP_TAKEN:-}" ]] && note "Database dump: $BACKUP_TAKEN"
note "Check the dashboard's System card, then System -> Settings -> Scheduler, then download a file."
invitation
}
# The last thing on the screen, and the only moment in this script where
# nothing is at stake: an update that just worked is a better time to
# mention the community than an install, where somebody is mid-task.
#
# Printed, not asked. The answer to "would you like to join?" is a browser,
# and this runs over SSH on a server that has none — so a y/n whose only
# outcome is printing the URL anyway would be a keystroke charged for
# nothing. It would also be answered "yes" by --yes, on behalf of a cron
# job that cannot join anything.
invitation() {
echo
say "Come and say hello"
note "ProjectSend has a Discord: release news, help when something is not"
note "behaving, and other people running the same software. We are in it too."
note "$DISCORD_URL"
}
main "$@"