This article is a follow up to the initial article that discussed using source ops for updates. The original article is still there for historic reasons.
Goal
Much of what we write these days is helped by dozens of dependencies. That is awesome, but keeping your site up to date is therefore not just about your own code. You also have to regularly update your dependencies to make sure security and bug fixes are applied to your dependencies.
Running composer update on your development machine is a good start, but we can automate this tedious task away with source-operations.
Assumptions
You will need:
- An API token added to your project as an environment variable. (learn how to do this here)
High level overview
What we want our automation to do is the following:
- Create an
updatebranch from your production branch. - Run
composer update,npm update, etc… on theupdatebranch. - Run some tests on the
updatebranch to verify that the update didn’t break anything - Merge the
updateintoprod
Steps
1. Add a updater.bash file in your project.
The updater.bash file is provided as is, feel free to read over the code (it’s quite readable, I promise!). This file will be included in your own project source, so tailor it to your needs if you want.
#!/bin/bash
function trigger_source_op() {
ENV="$1"
SOURCEOP="$2"
if [ "$PLATFORMSH_CLI_TOKEN" == "" ]; then
echo "env:PLATFORMSH_CLI_TOKEN is not set, please create it."
exit 1
fi
ensureCliIsInstalled
echo "Looking for production branch... "
PRODUCTION_ENV=$(platform e:list --type=production --columns=ID --no-header --format=csv)
echo "Production branch = $PRODUCTION_ENV"
createBranchIfNotExists "$ENV" "$PRODUCTION_ENV"
runSourceOperation "$SOURCEOP" "$ENV"
waitUntilEnvIsReady "$ENV"
test_urls "$ENV"
mergeAndDelete "$ENV"
}
function ensureCliIsInstalled() {
if which platform; then
echo "Cli is already installed"
else
echo "Cli not installed, installing..."
curl -sS https://platform.sh/cli/installer | php
fi
}
function createBranchIfNotExists() {
BRANCH_NAME="$1"
BRANCH_FROM="$2"
echo "Creating branch '$BRANCH_NAME'"
CURRENT_BRANCH=$(platform e:list --type=development --columns=ID --no-header --format=csv | grep "$BRANCH_NAME")
if [ "$CURRENT_BRANCH" == "$BRANCH_NAME" ]; then
echo "Branch already exists, reactivating"
activateBranch "$BRANCH_NAME"
if platform sync -e "$BRANCH_NAME" --yes --wait code; then
echo "Branch synced"
else
echo "Failed to sync"
exit
fi
else
platform branch --force --no-clone-parent --wait "$BRANCH_NAME" "$BRANCH_FROM"
echo "Branch created"
fi
}
function activateBranch() {
ENV_NAME="$1"
echo "Activating branch '$ENV_NAME'..."
platform environment:activate "$ENV_NAME" --wait --yes
echo "Environment activated"
}
function runSourceOperation() {
SOURCEOP_NAME="$1"
ENV_NAME="$2"
echo "Running source operation '$SOURCEOP_NAME' on '$ENV_NAME'..."
if platform source-operation:run "$SOURCEOP_NAME" --environment "$ENV_NAME" --wait ; then
echo "Source op finished"
else
echo "Source op failed to run"
exit
fi
}
function waitUntilEnvIsReady() {
ENV_NAME="$1"
echo "Waiting for '$ENV_NAME' to be ready..."
until [ "$is_dirty" == "false" ] && [ "$activity_count" == "0" ]; do
sleep 10
is_dirty=$(platform e:info is_dirty -e "$ENV_NAME")
activity_count=$(platform activity:list -e "$ENV_NAME" --incomplete --format=csv | wc -l)
done
}
function test_urls() {
ENV_NAME="$1"
for url in $(platform url --pipe --environment "$ENV_NAME"); do
echo -n "Testing $url";
STATUS_RETURNED=$(curl -ILSs "$url" | grep "HTTP" | tail -n 1 | cut -d' ' -f2)
if [ "$STATUS_RETURNED" != "200" ]; then
echo " [FAILED] $STATUS_RETURNED"
exit
else
echo " [OK] $STATUS_RETURNED"
fi
done
echo "All tests passed!"
}
function mergeAndDelete() {
ENV_NAME="$1"
echo "Merging '$ENV_NAME'..."
platform merge "$ENV_NAME" --no-wait --yes
echo "Removing '$ENV_NAME'..."
platform e:delete "$ENV_NAME" --no-wait --yes
}
function update_source() {
declare -A cmds
cmds["composer.lock"]="composer update --prefer-dist --no-interaction"
cmds["Pipfile.lock"]="pipenv lock"
cmds["Gemfile.lock"]="bundle update"
cmds["package-lock.json"]="npm update"
cmds["go.sum"]="go update -u all"
cmds["yarn.lock"]="yarn upgrade"
WAS_UPDATED=false
echo "Updating source of $PLATFORM_BRANCH"
# find each directory that has a .platform.app.yaml file
for yaml in $(find . -name '.platform.app.yaml' -type f); do
DIRECTORY=$(dirname "$yaml")
# then, check each directory for the existance of package files (composer.json)
for PACKAGE_FILE in ${!cmds[@]}; do
if test -f "$DIRECTORY/$PACKAGE_FILE"; then
# and when we find one, execute the package update command (composer update, npm update, ...)
echo "$PACKAGE_FILE exists. Running ${cmds[$PACKAGE_FILE]}"
${cmds[$PACKAGE_FILE]}
WAS_UPDATED=true
fi
done
done
# if we did an update, commit the changes
if $WAS_UPDATED; then
date > last_updated_on
git add .
git commit -m "auto update"
fi
}
function help() {
echo "Usage: "
echo " bash updater trigger_source_op ENV SOURCEOP (makes sure a branch named ENV is created, and then triggers the source operation named SOURCEOP)"
echo " bash updater update_source (runs composer update and git add/commit)"
exit 1
}
ACTION="$1"
case $ACTION in
trigger_source_op)
ENV="$2"
SOURCEOP="$3"
if [ "$ENV" == "" ] || [ "$SOURCEOP" == "" ]; then
help
fi
trigger_source_op "$2" "$3"
;;
update_source)
update_source
;;
install_cli)
ensureCliIsInstalled
;;
*)
help
;;
esac
2. Define the source operation
Open up your .platform.app.yaml file and add a few lines to it to define the source operation.
source:
operations:
update_dependencies: # you can name the source operation whatever you want, but remember the name, because you'll need it when calling it (in the cron)
command: |
bash updater.bash update_source
3. Define the cron operation
The cron will run on the production environment and will preferably run in the middle of the night.
timezone: "Europe/Paris" # Change this to your timezone https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
crons:
update:
spec: '0 3 * * *' # update every night at 3am
cmd: |
if [ "$PLATFORM_ENVIRONMENT_TYPE" == "production" ]; then
bash updater.bash trigger_source_op update_dependencies psh_auto_updater_branch
fi
A deeper dive
What does the source op do?
update_source will check each directory that contains a .platform.app.yaml and by default it will run:
-
composer update --prefer-dist --no-interactionif acomposer.lockfile is found -
npm updateif apackage-lock.jsonfile is found -
bundle updateif aGemfile.lockfile is found -
go update -u allif ago.sumfile is found -
pipenv lockif aPipfile.lockfile is found -
yarn upgradeif ayarn.lockfile is found
If any files were updated, it commits the changes to the branch by running:
date > last_updated_on
git add .
git commit -m "auto update"
What does the cron do?
trigger_source_op takes 2 parameters.
- The branch name to use to run the update
- The source op to run
After some initial checks, it will:
- Create the branch name if it doesn’t exist
- Trigger the source op
- Check if each platform url still returns a HTTP 200 (or is redirected to one)
- Merge into production.
How to run the source op manually
platform source-operation:run update_dependencies -e updater_branch
The above assumes you already have a branch called updater_branch. If not, you can branch it using
platform branch --force --no-clone-parent --wait psh_auto_updater_branch <your_production_branch>
Note: you can run the source operation on your production branch directly, but I highly recommend against doing this unless you don’t value site uptime.
Conclusion
Updating dependencies with source operations was already possible. But having a handy bash script available to do the heavy lifting for us makes our .platform.app.yaml file cleaner and much easier to reason about.
We can expand on this idea further by adding notifications to tell us when the automation detects that the dependency update fails the tests. But that, my friends, is a story for another day…