From cc0577df2e1d81a8cfe6e1838e61d28b8f76d9bf Mon Sep 17 00:00:00 2001 From: Andrew Dixon Date: Wed, 27 May 2026 13:24:35 -0700 Subject: [PATCH 1/4] Implement Rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Bootleg: Timestamped Release Directories (Breaking Change) ## Context Bootleg currently deploys by unpacking a tarball directly into the workspace root, resulting in a flat `//` directory that gets overwritten on every deploy. This makes rollback impossible and leaves no deployment history. This change implements the Capistrano-style pattern proposed in [labzero/bootleg#243](https://github.com/labzero/bootleg/issues/243): each deploy unpacks into a timestamped directory under `releases/`, and a `current` symlink points at the active release. This is a **breaking change** — the flat deploy structure is removed entirely with no backward-compat mode. ## Target directory structure on `:app` hosts ``` / ├── releases/ │ ├── 20240101120000/ ← previous │ │ ├── bin/myapp │ │ ├── lib/ │ │ └── releases/ │ └── 20240115093000/ ← current │ ├── bin/myapp │ └── ... └── current -> releases/20240115093000 ``` ## Files changed - `lib/bootleg/tasks/deploy.exs` — `:unpack_release` now extracts into `releases//`, symlinks `current`, deletes tarball, optionally prunes via `config :keep_releases, N` - `lib/bootleg/tasks/start.exs`, `stop.exs`, `restart.exs`, `ping.exs`, `update.exs` — all `bin/` paths changed to `current/bin/` - `lib/bootleg/tasks/rollback.exs` (new) — re-points `current` at the second-most-recent release and restarts - `lib/mix/tasks/rollback.ex` (new) — exposes `mix bootleg.rollback` ## New optional config key ```elixir config :keep_releases, 5 # prune to last N releases after each deploy; omit to keep all ``` ## Notes `--strip-components` was dropped in favour of extract-then-move because BusyBox `tar` (Alpine Linux) silently ignores that flag. Co-Authored-By: Claude Sonnet 4.6 --- lib/bootleg/tasks/deploy.exs | 25 ++++++++++-------- lib/bootleg/tasks/ping.exs | 2 +- lib/bootleg/tasks/restart.exs | 2 +- lib/bootleg/tasks/rollback.exs | 14 ++++++++++ lib/bootleg/tasks/start.exs | 2 +- lib/bootleg/tasks/stop.exs | 2 +- lib/bootleg/tasks/update.exs | 2 +- lib/mix/tasks/rollback.ex | 14 ++++++++++ .../tasks/deploy_task_functional_test.exs | 9 +++---- test/bootleg/tasks/manage_tasks_test.exs | 1 + test/fixtures/releases/valid_archive.tar.gz | Bin 292 -> 166 bytes .../docker/fixtures/valid_archive.tar.gz | Bin 292 -> 166 bytes 12 files changed, 52 insertions(+), 21 deletions(-) create mode 100644 lib/bootleg/tasks/rollback.exs create mode 100644 lib/mix/tasks/rollback.ex diff --git a/lib/bootleg/tasks/deploy.exs b/lib/bootleg/tasks/deploy.exs index cbef836..d857e3f 100644 --- a/lib/bootleg/tasks/deploy.exs +++ b/lib/bootleg/tasks/deploy.exs @@ -35,19 +35,22 @@ task :upload_release do upload(:app, local_path, remote_path) end -# credo:disable-for-next-line Credo.Check.Design.TagTODO -# TODO: Prepare for Rollback -# * create releases/ directory -# * fetch timestamp -# * unpack into releases/[timestamp] -# * create revisions.log file -# * create shared/ directory -# task :unpack_release do - remote_path = "#{Config.app()}.tar.gz" - UI.info("Unpacking release archive: #{remote_path}") + app = Config.app() + remote_path = "#{app}.tar.gz" + keep_releases = Config.get_key(:keep_releases) + UI.info("⚡ Unpacking release archive: #{remote_path}") remote :app do - "tar -zxf #{remote_path}" + "mkdir -p releases" + "ts=$(date +%Y%m%d%H%M%S) && tar -zxf #{remote_path} && mv #{app} releases/$ts" + "ln -sfn releases/$(ls -1t releases/ | head -n 1) current" + "rm #{remote_path}" + end + + if keep_releases do + remote :app do + "ls -1dt releases/*/ | tail -n +#{keep_releases + 1} | xargs -I{} rm -rf {}" + end end end diff --git a/lib/bootleg/tasks/ping.exs b/lib/bootleg/tasks/ping.exs index 2fa1624..cbedecf 100644 --- a/lib/bootleg/tasks/ping.exs +++ b/lib/bootleg/tasks/ping.exs @@ -3,7 +3,7 @@ use Bootleg.DSL task :ping do remote :app do - "bin/#{Config.app()} ping" + "current/bin/#{Config.app()} ping" end :ok diff --git a/lib/bootleg/tasks/restart.exs b/lib/bootleg/tasks/restart.exs index 50907b6..5e7f9d9 100644 --- a/lib/bootleg/tasks/restart.exs +++ b/lib/bootleg/tasks/restart.exs @@ -3,7 +3,7 @@ use Bootleg.DSL task :restart do remote :app do - "bin/#{Config.app()} restart" + "current/bin/#{Config.app()} restart" end UI.info("#{Config.app()} restarted") diff --git a/lib/bootleg/tasks/rollback.exs b/lib/bootleg/tasks/rollback.exs new file mode 100644 index 0000000..0f81f2b --- /dev/null +++ b/lib/bootleg/tasks/rollback.exs @@ -0,0 +1,14 @@ +alias Bootleg.{Config, UI} +use Bootleg.DSL + +task :rollback do + app = Config.app() + + remote :app do + "test $(ls -1d releases/*/ 2>/dev/null | wc -l) -ge 2 || (echo 'No previous release to roll back to' && exit 1)" + "previous=$(ls -1dt releases/*/ | sed -n '2p') && ln -sfn $previous current" + "current/bin/#{app} restart" + end + + UI.info("#{app} rolled back") +end diff --git a/lib/bootleg/tasks/start.exs b/lib/bootleg/tasks/start.exs index 8f66389..07078e2 100644 --- a/lib/bootleg/tasks/start.exs +++ b/lib/bootleg/tasks/start.exs @@ -3,7 +3,7 @@ use Bootleg.DSL task :start do remote :app do - "bin/#{Config.app()} start" + "current/bin/#{Config.app()} start" end UI.info("#{Config.app()} started") diff --git a/lib/bootleg/tasks/stop.exs b/lib/bootleg/tasks/stop.exs index e2a0785..22d7f42 100644 --- a/lib/bootleg/tasks/stop.exs +++ b/lib/bootleg/tasks/stop.exs @@ -5,7 +5,7 @@ task :stop do app_name = Config.app() remote :app do - "bin/#{app_name} stop" + "current/bin/#{app_name} stop" end UI.info("#{app_name} stopped") diff --git a/lib/bootleg/tasks/update.exs b/lib/bootleg/tasks/update.exs index eef1f4a..fb065db 100644 --- a/lib/bootleg/tasks/update.exs +++ b/lib/bootleg/tasks/update.exs @@ -9,7 +9,7 @@ task :update do end task :stop_silent do - nodetool = "bin/#{Config.app()}" + nodetool = "current/bin/#{Config.app()}" remote :app do "#{nodetool} describe && (#{nodetool} stop || true)" diff --git a/lib/mix/tasks/rollback.ex b/lib/mix/tasks/rollback.ex new file mode 100644 index 0000000..ba87406 --- /dev/null +++ b/lib/mix/tasks/rollback.ex @@ -0,0 +1,14 @@ +defmodule Mix.Tasks.Bootleg.Rollback do + use Bootleg.MixTask, :rollback + + @shortdoc "Roll back to the previous release" + + @moduledoc """ + Roll back to the previous release + + # Usage: + + * mix bootleg.rollback + + """ +end diff --git a/test/bootleg/tasks/deploy_task_functional_test.exs b/test/bootleg/tasks/deploy_task_functional_test.exs index 1265d5d..6e68bac 100644 --- a/test/bootleg/tasks/deploy_task_functional_test.exs +++ b/test/bootleg/tasks/deploy_task_functional_test.exs @@ -49,18 +49,17 @@ defmodule Bootleg.Tasks.DeployTaskFunctionalTest do end) end - @tag role_opts: %{release_workspace: "/fixtures"} + @tag role_opts: %{release_workspace: "/project/test/fixtures/releases"} test "deploy/1 deploys the release to the target hosts from a remote release_workspace path" do alias Bootleg.Config File.cd!("test/fixtures", fn -> capture_io(fn -> release_name = "#{Config.version()}.tar.gz" - app_name = "#{Config.app()}.tar.gz" - assert [{:ok, _, 0, _}] = remote(:app, "[ -f /fixtures/#{release_name} ]") + assert [{:ok, _, 0, _}] = remote(:app, "[ -f /project/test/fixtures/releases/#{release_name} ]") invoke(:deploy) - assert [{:ok, _, 0, _}] = remote(:app, "[ -f #{app_name} ]") - assert [{:ok, _, 0, _}] = remote(:app, "[ -f release.txt ]") + assert [{:ok, _, 0, _}] = remote(:app, "[ -L current ]") + assert [{:ok, _, 0, _}] = remote(:app, "[ -f current/release.txt ]") end) end) end diff --git a/test/bootleg/tasks/manage_tasks_test.exs b/test/bootleg/tasks/manage_tasks_test.exs index 1f2c7bd..86aeeab 100644 --- a/test/bootleg/tasks/manage_tasks_test.exs +++ b/test/bootleg/tasks/manage_tasks_test.exs @@ -21,6 +21,7 @@ defmodule Bootleg.Tasks.ManageTasksTest do capture_io(fn -> conn = SSH.init(:app) SSH.run!(conn, "install-app build_me") + SSH.run!(conn, "ln -sfn . current") send(self(), {:connection, conn}) end) diff --git a/test/fixtures/releases/valid_archive.tar.gz b/test/fixtures/releases/valid_archive.tar.gz index 1cce2f2bee63a0403333eda88a517fb86b564b7f..03146e6466ab43ceb8ad3ea2c042f1886b8f9527 100644 GIT binary patch literal 166 zcmb2|=3oE==C>Cexegf!v_4#8>*}|_^MuOriAs?jYumOp-Mcp}Okiop1*J#-bJ>iT zD?hAyz3oKh;%O;XhF9K-WHu%StWV57F~-nt*qDa-+lPX z-Q9otSGE0n9kOhZ=j(5m&i!|fFW{)#RsZ|W{`c2!osoZfb^dpIvHicw_#jS!lJj>n N&QNZC&7i@+007}*OgjJo literal 292 zcmV+<0o(o`iwFQlmQh&%1MSnnO2a@92H>qA+85|~1y3I0B-vE(CV25s1&!dXtaaKH z6BDv4_&7cKJU)@TZO{@>+d~xL|6rKRvKeL%6C4NQnse5M1N&k{7l>%6l=S&zm@G3z zt9mJpA{oYEq~b89L`e#j3Q0)nWSW*|V|DFD$;zCq;VZ1^+LcM-W*xbXGcW-ZUs^t$=Cexegf!I9yy~>*~M2^Q}*xvg;;+XkA~Hy?b@T1eSJOPqA+85|~1y3I0B-vE(CV25s1&!dXtaaKH z6BDv4_&7cKJU)@TZO{@>+d~xL|6rKRvKeL%6C4NQnse5M1N&k{7l>%6l=S&zm@G3z zt9mJpA{oYEq~b89L`e#j3Q0)nWSW*|V|DFD$;zCq;VZ1^+LcM-W*xbXGcW-ZUs^t$= Date: Thu, 28 May 2026 14:19:37 -0700 Subject: [PATCH 2/4] Timestamp Tarball on Creation We timestamp the folder and archive name when we create the tarball from the elixir build. This lets us not worry so much about passing around the timestamp to the different parts of deploy that need it and rather just looking for the newest folder in releases/ when we update the current/ pointer. --- lib/bootleg/tasks/build/remote.exs | 13 ++++++------- lib/bootleg/tasks/deploy.exs | 16 ++++++++++------ test/support/docker/Dockerfile | 2 +- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/bootleg/tasks/build/remote.exs b/lib/bootleg/tasks/build/remote.exs index cebea8a..d3975d4 100644 --- a/lib/bootleg/tasks/build/remote.exs +++ b/lib/bootleg/tasks/build/remote.exs @@ -81,8 +81,10 @@ task :remote_generate_release do UI.info("⚡ Creating Tarball...") + time_stamp = System.os_time(:millisecond) remote :build, cd: source_path do - "tar -czf #{app_name}.tar.gz #{app_name}/" + "mv #{app_name}/ #{time_stamp}/" + "tar -czf #{time_stamp}.tar.gz #{time_stamp}/" end end @@ -128,24 +130,21 @@ end task :download_release do mix_env = config({:mix_env, "prod"}) source_path = config({:ex_path, ""}) - app_name = Config.app() - app_version = Config.version() remote_path = Path.join( source_path, - "_build/#{mix_env}/rel/#{app_name}.tar.gz" + "_build/#{mix_env}/rel/*.tar.gz" ) local_archive_folder = "#{File.cwd!()}/releases" - local_path = Path.join(local_archive_folder, "#{app_version}.tar.gz") UI.info("⚡ Downloading release archive") File.mkdir_p!(local_archive_folder) - download(:build, remote_path, local_path) + download(:build, remote_path, local_archive_folder) - UI.info("⚡ Saved: releases/#{app_version}.tar.gz") + UI.info("⚡ Saved in releases/") end task :reset_remote do diff --git a/lib/bootleg/tasks/deploy.exs b/lib/bootleg/tasks/deploy.exs index d857e3f..4e8ef3a 100644 --- a/lib/bootleg/tasks/deploy.exs +++ b/lib/bootleg/tasks/deploy.exs @@ -30,21 +30,25 @@ end task :upload_release do remote_path = "#{Config.app()}.tar.gz" local_archive_folder = "#{File.cwd!()}/releases" - local_path = Path.join(local_archive_folder, "#{Config.version()}.tar.gz") - UI.info("Uploading release archive") + tar_ball = "ls -t #{local_archive_folder} | head -1" + |> System.shell() + |> elem(0) + |> String.trim_trailing() + local_path = Path.join(local_archive_folder, tar_ball) + UI.info("⚡ Uploading release archive #{tar_ball}") upload(:app, local_path, remote_path) end task :unpack_release do app = Config.app() remote_path = "#{app}.tar.gz" - keep_releases = Config.get_key(:keep_releases) + keep_releases = Config.get_role(:app).options[:keep_releases] UI.info("⚡ Unpacking release archive: #{remote_path}") remote :app do - "mkdir -p releases" - "ts=$(date +%Y%m%d%H%M%S) && tar -zxf #{remote_path} && mv #{app} releases/$ts" - "ln -sfn releases/$(ls -1t releases/ | head -n 1) current" + "mkdir -p releases/" + "tar -zxf #{remote_path} -C releases/" + "ls -td releases/*/ | head -1 | xargs -I{} ln -sfn {} current" "rm #{remote_path}" end diff --git a/test/support/docker/Dockerfile b/test/support/docker/Dockerfile index d435d53..f22c0cc 100644 --- a/test/support/docker/Dockerfile +++ b/test/support/docker/Dockerfile @@ -3,7 +3,7 @@ FROM bitwalker/alpine-elixir:latest # Set up an Alpine Linux machine running an SSH server. # Autogenerate missing host keys. -RUN apk add --update --no-cache openssh sudo git perl-utils bash +RUN apk add --update --no-cache openssh sudo git perl-utils bash tar RUN ssh-keygen -A RUN printf "PermitUserEnvironment yes\n" >> /etc/ssh/sshd_config From 268f735a3f79fdcca70d4da61b3775ebadbb35a3 Mon Sep 17 00:00:00 2001 From: Andrew Dixon Date: Thu, 28 May 2026 16:10:58 -0700 Subject: [PATCH 3/4] Workaround SystemD not Noticing Symlink Changes --- lib/bootleg/tasks/deploy.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/bootleg/tasks/deploy.exs b/lib/bootleg/tasks/deploy.exs index 4e8ef3a..87cd8c3 100644 --- a/lib/bootleg/tasks/deploy.exs +++ b/lib/bootleg/tasks/deploy.exs @@ -50,6 +50,7 @@ task :unpack_release do "tar -zxf #{remote_path} -C releases/" "ls -td releases/*/ | head -1 | xargs -I{} ln -sfn {} current" "rm #{remote_path}" + "touch --reference current/bin/#{app} current/bin/#{app}" end if keep_releases do From f1554d5a398387cbe12adfffac579f601a74732f Mon Sep 17 00:00:00 2001 From: Andrew Dixon Date: Fri, 29 May 2026 11:35:49 -0700 Subject: [PATCH 4/4] Update Tests --- .../tasks/deploy_task_functional_test.exs | 2 +- test/fixtures/releases/valid_archive.tar.gz | Bin 166 -> 220 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bootleg/tasks/deploy_task_functional_test.exs b/test/bootleg/tasks/deploy_task_functional_test.exs index 6e68bac..dab55b5 100644 --- a/test/bootleg/tasks/deploy_task_functional_test.exs +++ b/test/bootleg/tasks/deploy_task_functional_test.exs @@ -25,7 +25,7 @@ defmodule Bootleg.Tasks.DeployTaskFunctionalTest do File.cd!("test/fixtures", fn -> capture_io(fn -> - assert_raise File.Error, fn -> invoke(:deploy) end + assert_raise SSHError, fn -> invoke(:deploy) end end) end) end diff --git a/test/fixtures/releases/valid_archive.tar.gz b/test/fixtures/releases/valid_archive.tar.gz index 03146e6466ab43ceb8ad3ea2c042f1886b8f9527..6ae688f929cb94f607bc25d47dccdf8ec566680c 100644 GIT binary patch literal 220 zcmb2|=3oE==C@aTxta|`SRSmY=JLPr?ETTlB2#siWVtHexP5&AYo?&4Nmt1}-xiin z2}aZUvxBaSiwVgeG5i^NIhf9z=TGkqB~-6ZhHEvxli)n(CE z+qcyRJe^TLEBxWT1K~ftO`oiva_vMt_vSyx&oh+&Xz0Co`X~3-Y5Uc3BaSei`SkGo z=k0sXT1{WlYWvof>rKL|`eg3eTfV)i`u^Mc+Hd=R|4(KZ#r*pBJNHXHbMOZZdx-m? Sj1SD0EM(ANU;qFB-)Id0 literal 166 zcmb2|=3oE==C>Cexegf!v_4#8>*}|_^MuOriAs?jYumOp-Mcp}Okiop1*J#-bJ>iT zD?hAyz3oKh;%O;XhF9K-WHu%StWV57F~-nt*qDa-+lPX z-Q9otSGE0n9kOhZ=j(5m&i!|fFW{)#RsZ|W{`c2!osoZfb^dpIvHicw_#jS!lJj>n N&QNZC&7i@+007}*OgjJo