Guix Tricks: Self-contained shebang scripts.

Tags:

Intro

I like scripts. You like scripts. We all like scripts. But, I don't like that when I give a script to someone else, they need to install X, Y, and Z packages for that script to work. I don't like that my scripts may behave differently depending on the user's environment. I don't like needing to install a bunch of packages in a CI environment before running my./bin/*scripts. If ONLY someone could help me!

Consider the following script:

#!/usr/bin/env -S emacs -x
;; -*- mode: emacs-lisp; -*-
;; https://orgmode.org/manual/Bare-HTML.html
(setq org-html-head ""
      org-html-head-extra ""
      org-html-head-include-default-style nil
      org-html-head-include-scripts nil
      org-html-postamble nil
      org-html-preamble nil
      org-html-use-infojs nil
      org-html-mathjax-template "")

(defvar build-status-image
  (concat "#+ATTR_HTML: :alt builds.sr.ht status
[[https://builds.sr.ht/~freakingpenguin/" (getenv "repo") "]"
"[https://builds.sr.ht/~freakingpenguin/" (getenv "repo") ".svg]]
"))

(with-current-buffer (find-file-noselect (nth 0 argv))
  (insert build-status-image)
  (org-html-export-to-html))

This is a script that takes an org-mode file and generates an HTML file. A perfect task for a CI job. Ideally, we want this script to contain all the information required to run it, including the packages it requires. Keeping this information together in one file makes it easier to track and simpler for anyone to run the script.

To both install Emacs AND run the script in one go, all we have to do is replace the shebang with this:

#!/usr/bin/env -S guix shell emacs emacs-htmlize -- emacs -x

I like this because it lets me avoid needing to learn complicated tools like Docker for something that should be a simple task. A concept like "run command X with software Y in a containerized environment" can be expressed using basic tooling (shebangs) everyone is already familiar with. I may know how Docker works and how to write Dockerfiles, but without regular, consistent use it's easy to forget their syntax and need to relearn it yet again. Shell scripts are universal. 💕

Another example

You can go one step further and run the script in a containerized environment to ensure that you captured all possible dependencies and that the behavior is consistent regardless of the user's environment.

For particularly version-sensitive scripts, you can combine this with guix time-machine. Then, all dependencies required to run the script can be near-effortlessly frozen and guaranteed to run identically between invocations.

Using guix shell in shebangs makes running arbitrary projects with Emacs'scompile-command a breeze, since it boils down the complicated task of environment setup all the way down to just a running a script. Like, for example, with how I build this site. In./bin/serve:

#!/usr/bin/env -S guix shell ruby ruby-colorize -- ruby
# -*- mode: ruby; -*-
require 'optparse'
require 'colorize'
require 'socket'

bind = "localhost"
quiet = ""
no_substitutes = ""
options = {}
OptionParser.new do |opts|
  opts.on("-p", "--public", "Bind to 0.0.0.0 instead of localhost") do
    bind = "0.0.0.0"
  end

  opts.on("-q", "--quiet", "Minimize output.") do
    quiet = "-q"
  end

  opts.on("-s" "--no-substitutes", "Do not use substitutes") do
    # Helpful if there are network issues
    no_substitutes = "--no-substitutes"
  end
end.parse!

puts "Building site".red
puts site_dir = `guix time-machine -C channels.scm -- \
                      build #{no_substitutes} #{quiet} -f guix.scm`
                  .chomp + "/site"
raise "Build Failure" unless $?.success?

ip_address = Socket.ip_address_list.detect{|intf| intf.ipv4_private?}.ip_address.to_s
puts "Serving site publicly on #{ip_address}".red unless bind == "localhost"
system("guix time-machine -C channels.scm -- shell #{no_substitutes} \
              -CN --no-cwd --expose=#{site_dir}=/srv/html python --   \
              python3 -m http.server -d /srv/html --bind #{bind}")

Now I can add (compile-command . "./bin/serve -p") to .dir-locals.el, without needing to worry about direnv, syncing separate scripts and Dockerfiles, or anything else.