Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.6.0] - 2026-09-15

### Changed

- `asdf_version` now defaults to `0.20.0`, and `asdf:setup` installs it the way
asdf is distributed since 0.16: the pre-compiled Linux binary of the release
is downloaded into `#{asdf_path}/bin`, instead of the repository being cloned
and a tag checked out. The architecture comes from `uname -m` on the target
host, so only Linux (Ubuntu) targets are supported.
- `asdf:setup` reads the installed version from `asdf version` rather than from
the `version.txt` it used to write, so an update is detected whatever
installed asdf in the first place. The stale `version.txt` and the leftover
0.15 git clone in `#{asdf_path}` can be deleted by hand.
- `asdf:map_bins` exports `ASDF_DATA_DIR`. The asdf binary no longer derives
its data directory from its own location, so a custom `asdf_path` needs it to
keep holding the plugins, the installs and the shims.

### Removed

- `asdf_repository` variable, which nothing downloads from any more.

## [1.5.5] - 2026-09-15

### Fixed
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,23 @@ However we strongly encourage you to use the `.tool-versions` for proper tool ve
If you need some special settings, set those in the stage file for your server:

# deploy.rb or stage file (staging.rb, production.rb or else)
set :asdf_version, '0.20.0' # defaults to '0.20.0'
set :asdf_setup, false # defaults to true
set :asdf_path, '~/.my_asdf_installation_path' # only needed if not '~/.asdf'
set :asdf_tools, %w{ ruby } # defaults to %{ ruby nodejs }
set :asdf_map_ruby_bins, %w{ bundle gem } # defaults to %w{ rake gem bundle ruby rails }
set :asdf_map_nodejs_bins, %w{ node npm } # defaults to %w{ node npm yarn }

### ASDF version: `:asdf_version`

The deploy installs ASDF itself, and keeps it at the requested version. On every
deploy, `asdf:setup` compares `asdf version` on the target host with
`:asdf_version` and, when they differ, downloads the pre-compiled binary of that
release into `#{fetch(:asdf_path)}/bin`.

Set `:asdf_setup` to `false` if you would rather install and update ASDF
yourself.

### Custom ASDF path: `:asdf_path`

If you have a custom ASDF setup with a different path then expected, you have
Expand Down Expand Up @@ -74,9 +86,9 @@ For example; if you just want to map `node` and `npm` nodejs binaries, you may s

## Restrictions

Capistrano can't use ASDF to install rubies, nodes or other tools yet.
So on the servers you are deploying to, you will have to manually use ASDF to install the
proper rubies, nodes or other tools.
ASDF is installed from the pre-compiled Linux binary of its GitHub release, so
the target hosts have to run Linux. Ubuntu is the only distribution this is used
on, and `uname -m` has to answer `x86_64` or `aarch64`.

## How it works

Expand Down
34 changes: 34 additions & 0 deletions lib/capistrano/asdf/release.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module Capistrano
module Asdf
# Since 0.16 asdf is a pre-compiled binary rather than a set of shell
# scripts: every install and every update downloads a release archive.
module Release
DOWNLOAD_HOST = "https://github.com/asdf-vm/asdf/releases/download"

# Ubuntu is the only supported target, so only the Linux builds that the
# asdf releases carry are mapped, keyed by what `uname -m` answers.
ARCHITECTURES = {
"x86_64" => "amd64",
"aarch64" => "arm64"
}.freeze

# The archive holds the single `asdf` executable, with no enclosing
# directory, so it untars straight into a directory of the PATH.
def self.download_url(version, machine)
architecture = ARCHITECTURES.fetch(machine) do
raise ArgumentError, "asdf publishes no Linux build for #{machine} hardware"
end

"#{DOWNLOAD_HOST}/v#{version}/asdf-v#{version}-linux-#{architecture}.tar.gz"
end

# `asdf version` answers `v0.20.0 (revision 150aaf0)`, so only the
# version number is read out of it.
def self.installed_version(output)
output.to_s[/\d+\.\d+\.\d+/]
end
end
end
end
2 changes: 1 addition & 1 deletion lib/capistrano/asdf/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Capistrano
module Asdf
VERSION = "1.5.5"
VERSION = "1.6.0"
end
end
55 changes: 31 additions & 24 deletions lib/capistrano/tasks/asdf.rake
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative "../asdf/release"

namespace :asdf do
desc "Install ASDF tools on deploy"
task :deploy do
Expand All @@ -19,29 +21,32 @@ namespace :asdf do
end
end

desc "Setup ASDF on the target host"
desc "Install ASDF, or update it to :asdf_version, on the target host"
task :setup do
if fetch(:asdf_setup)
on roles(fetch(:asdf_roles)) do
if test("[ -d #{fetch(:asdf_path)} ]")
within(fetch(:asdf_path)) do
version = capture(:cat, "version.txt").strip
if fetch(:asdf_version) == version
info "ASDF #{fetch(:asdf_version)} is already installed on #{fetch(:asdf_path)}"
else
execute :git, "fetch", "origin"
execute :git, "checkout", "v#{fetch(:asdf_version)}"
info "ASDF is updated from #{version} to #{fetch(:asdf_version)} (on #{fetch(:asdf_path)})"
end
end
else
execute :git, "clone", fetch(:asdf_repository), fetch(:asdf_path)
within(fetch(:asdf_path)) do
execute :git, "checkout", "v#{fetch(:asdf_version)}"
execute :echo, fetch(:asdf_version), ">", "version.txt"
end
info "ASDF #{fetch(:asdf_version)} is installed on #{fetch(:asdf_path)}"
end
next unless fetch(:asdf_setup)

version = fetch(:asdf_version)
on roles(fetch(:asdf_roles)) do
bin_path = "#{fetch(:asdf_path)}/bin"
installed = if test("[ -x #{bin_path}/asdf ]")
Capistrano::Asdf::Release.installed_version(capture(:asdf, "version", raise_on_non_zero_exit: false))
end

if installed == version
info "ASDF #{version} is already installed on #{fetch(:asdf_path)}"
next
end

url = Capistrano::Asdf::Release.download_url(version, capture(:uname, "-m"))
execute :mkdir, "-p", bin_path
# `asdf update` refuses to upgrade the binary it ships as, so the release
# archive is downloaded over the installed one.
execute "curl -fsSL #{url} | tar -xzf - -C #{bin_path} asdf"

if installed
info "ASDF is updated from #{installed} to #{version} (on #{fetch(:asdf_path)})"
else
info "ASDF #{version} is installed on #{fetch(:asdf_path)}"
end
end
end
Expand Down Expand Up @@ -93,6 +98,9 @@ namespace :asdf do
asdf_home = fetch(:asdf_path).sub(%r{\A~(?=/|\z)}, "$HOME")
path = "#{asdf_home}/shims:#{asdf_home}/bin:" + (SSHKit.config.default_env[:path] || "$PATH")
SSHKit.config.default_env[:path] = path
# Since 0.16 the asdf binary no longer derives its data directory from its
# own location, so a custom :asdf_path has to be handed over explicitly.
SSHKit.config.default_env[:asdf_data_dir] = asdf_home

asdf_prefix = fetch(:asdf_prefix, -> { "#{fetch(:asdf_path)}/bin/asdf exec" })
SSHKit.config.command_map[:asdf] = "#{fetch(:asdf_path)}/bin/asdf"
Expand Down Expand Up @@ -122,8 +130,7 @@ end
namespace :load do
task :defaults do
set :asdf_path, fetch(:asdf_path, "~/.asdf")
set :asdf_repository, fetch(:asdf_repository, "https://github.com/asdf-vm/asdf.git")
set :asdf_version, fetch(:asdf_version, "0.15.0")
set :asdf_version, fetch(:asdf_version, "0.20.0")
set :asdf_setup, fetch(:asdf_setup, true)
set :asdf_roles, fetch(:asdf_roles, :all)
set :asdf_ruby_use_jemalloc, fetch(:asdf_ruby_use_jemalloc, true)
Expand Down
18 changes: 13 additions & 5 deletions test/capistrano/test_map_bins.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,38 @@ def teardown
def test_the_exported_path_holds_no_tilde
Rake::Task["asdf:map_bins"].invoke

assert_equal "$HOME/.asdf/shims:$HOME/.asdf/bin:$PATH", exported_path
assert_equal "$HOME/.asdf/shims:$HOME/.asdf/bin:$PATH", exported("PATH")
end

def test_it_keeps_a_path_already_set_by_the_application
SSHKit.config.default_env = {path: "/opt/custom/bin:$PATH"}

Rake::Task["asdf:map_bins"].invoke

assert_equal "$HOME/.asdf/shims:$HOME/.asdf/bin:/opt/custom/bin:$PATH", exported_path
assert_equal "$HOME/.asdf/shims:$HOME/.asdf/bin:/opt/custom/bin:$PATH", exported("PATH")
end

# Since 0.16 the asdf binary reads its data directory from the environment
# instead of deriving it from its own location.
def test_it_exports_the_asdf_data_dir
Rake::Task["asdf:map_bins"].invoke

assert_equal "$HOME/.asdf", exported("ASDF_DATA_DIR")
end

def test_an_absolute_asdf_path_is_left_alone
set :asdf_path, "/opt/asdf"

Rake::Task["asdf:map_bins"].invoke

assert_equal "/opt/asdf/shims:/opt/asdf/bin:$PATH", exported_path
assert_equal "/opt/asdf/shims:/opt/asdf/bin:$PATH", exported("PATH")
end

# What the shell actually receives, tilde expansion included.
def exported_path
def exported(variable)
command = SSHKit::Command.new(:node, "--version").to_command

command[/PATH="([^"]*)"/, 1]
command[/#{variable}="([^"]*)"/, 1]
end
end
end
35 changes: 35 additions & 0 deletions test/capistrano/test_release.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require "test_helper"

require "capistrano/asdf/release"

module Capistrano
module Asdf
class TestRelease < Minitest::Test
def test_it_points_at_the_linux_build_of_the_host_hardware
assert_equal "https://github.com/asdf-vm/asdf/releases/download/v0.20.0/asdf-v0.20.0-linux-amd64.tar.gz",
Release.download_url("0.20.0", "x86_64")
assert_equal "https://github.com/asdf-vm/asdf/releases/download/v0.20.0/asdf-v0.20.0-linux-arm64.tar.gz",
Release.download_url("0.20.0", "aarch64")
end

def test_it_refuses_hardware_asdf_publishes_no_build_for
error = assert_raises(ArgumentError) { Release.download_url("0.20.0", "riscv64") }

assert_includes error.message, "riscv64"
end

def test_it_reads_the_version_number_out_of_the_asdf_output
assert_equal "0.20.0", Release.installed_version("v0.20.0 (revision 150aaf0)\n")
# What the 0.15 shell script the servers still run answers, so that an
# update is detected whatever installed ASDF in the first place.
assert_equal "0.15.0", Release.installed_version("v0.15.0-31e8c93\n")
end

def test_it_reads_no_version_out_of_a_failed_call
assert_nil Release.installed_version("")
end
end
end
end
Loading