Skip to content

Commit

Permalink
Write Brewfile.lock.json files
Browse files Browse the repository at this point in the history
Starting with a caveat: we will never have a "proper" lockfile in Homebrew/homebrew-bundle that installs all your dependencies at exactly the versions you want because Homebrew doesn't work that way (and never will because e.g. pinning OpenSSL at the same version forever is a bad idea).

I was somewhat inspired by a conversation with `@jnewland` to give this a go. What I'm thinking at a high-level this lockfile does is say "on this given configuration Homebrew/homebrew-bundle installed these versions of packages successfully". If a run is successful we'll dump various data about what succeeded into a lockfile. Within teams people may vary on the OS versions and even OS (macOS vs. Linux) they use so these will be (eventually) used as separate namespaces for system information so the lockfile can store every high-level configuration in which things worked.

`brew bundle` will output a `Brewfile.lock.json` in the same directory as the `Brewfile` if all dependencies are installed successfully. This contains dependency and system status information which can be useful in debugging `brew bundle` failures and replicating a "last known good build" state.

You can opt-out of this behaviour by setting the `HOMEBREW_BUNDLE_NO_LOCK` environment variable or passing the `--no-lock option`.

You may wish to check this file into the same version control system as your `Brewfile` (or ensure your version control system ignores it if you'd prefer to rely on debugging information from a local machine).

For example, for the following `Brewfile`:
```ruby
brew "ack"
cask "fork"
mas "StopTheMadness", id: 1376402589
tap "homebrew/homebrew-bundle"
```

The `Brewfile.lock.json` would be:
```json
{
  "entries": {
    "brew": {
      "ack": {
        "version": "3.2.0",
        "bottle": false
      },
      "readline": {
        "version": "8.0.1",
        "bottle": {
          "cellar": ":any",
          "prefix": "/usr/local",
          "files": {
            "catalina": {
              "url": "https://homebrew.bintray.com/bottles/readline-8.0.1.catalina.bottle.tar.gz",
              "sha256": "ab3c966f4cae7d0f3ecc5688bb989820c3261f5ed547a08c84186ba7f53bdd9c"
            },
            "mojave": {
              "url": "https://homebrew.bintray.com/bottles/readline-8.0.1.mojave.bottle.tar.gz",
              "sha256": "3c754391e9d243835811d128771ca0f1a565024100fd2c2871534353d46aaf0e"
            },
            "high_sierra": {
              "url": "https://homebrew.bintray.com/bottles/readline-8.0.1.high_sierra.bottle.tar.gz",
              "sha256": "ae341a036139a92a47396aabc773ffcf40a17fc388aaadf0147f688c72ece987"
            },
            "sierra": {
              "url": "https://homebrew.bintray.com/bottles/readline-8.0.1.sierra.bottle.tar.gz",
              "sha256": "f234d1ff8148bf08b0ac31e661f2e96b5c6e64df26a45d2392056c9077f964af"
            }
          }
        }
      }
    },
    "cask": {
      "fork": {
        "version": "1.0.72.2"
      }
    },
    "mas": {
      "StopTheMadness": {
        "id": "1376402589",
        "version": "8.6"
      }
    },
    "tap": {
      "homebrew/bundle": {
        "revision": "045be0715c7cd41f0e914c9faf9dd42e7e1fae59"
      }
    }
  },
  "system": {
    "macos": {
      "catalina": {
        "HOMEBREW_VERSION": "2.1.16-39-g5595dea",
        "HOMEBREW_PREFIX": "/usr/local",
        "Homebrew/homebrew-core": "a426105d937988c2d7dbe6b4e6fc1589dfb29e10",
        "CLT": "1100.0.33.8",
        "Xcode": "11.2",
        "macOS": "10.15.1"
      }
    }
  }
}
```
  • Loading branch information
MikeMcQuaid committed Nov 12, 2019
1 parent 799c5cc commit 8d23c65
Show file tree
Hide file tree
Showing 14 changed files with 300 additions and 29 deletions.
20 changes: 1 addition & 19 deletions .gitignore
@@ -1,22 +1,4 @@
*.gem
*.rbc
.bundle
.config
.yardoc
Brewfile
Brewfile.lock.json
Gemfile.lock
InstalledFiles
_yardoc
bin/
coverage
doc/
lib/bundler/man
pkg
rdoc
spec/reports
test/tmp
test/version_tmp
tmp
.ruby-gemset
.ruby-version
.envrc
6 changes: 6 additions & 0 deletions README.md
Expand Up @@ -63,6 +63,12 @@ You can skip the installation of dependencies by adding space-separated values t
- `HOMEBREW_BUNDLE_MAS_SKIP`
- `HOMEBREW_BUNDLE_TAP_SKIP`

`brew bundle` will output a `Brewfile.lock.json` in the same directory as the `Brewfile` if all dependencies are installed successfully. This contains dependency and system status information which can be useful in debugging `brew bundle` failures and replicating a "last known good build" state.

You can opt-out of this behaviour by setting the `HOMEBREW_BUNDLE_NO_LOCK` environment variable or passing the `--no-lock option`.

You may wish to check this file into the same version control system as your `Brewfile` (or ensure your version control system ignores it if you'd prefer to rely on debugging information from a local machine).

### Dump

You can create a `Brewfile` from all the existing Homebrew packages you have installed with:
Expand Down
1 change: 1 addition & 0 deletions lib/bundle.rb
Expand Up @@ -29,6 +29,7 @@
require "bundle/dumper"
require "bundle/installer"
require "bundle/lister"
require "bundle/locker"
require "bundle/commands/install"
require "bundle/commands/dump"
require "bundle/commands/cleanup"
Expand Down
3 changes: 2 additions & 1 deletion lib/bundle/extend/os/linux/skipper.rb
Expand Up @@ -3,8 +3,9 @@
module Bundle
module Skipper
class << self
def skip?(entry)
def skip?(entry, silent: false)
return generic_skip?(entry) unless [:cask, :mas].include?(entry.type)
return true if silent

puts Formatter.warning "Skipping #{entry.type} #{entry.name} (on Linux)"
true
Expand Down
13 changes: 9 additions & 4 deletions lib/bundle/installer.rb
Expand Up @@ -42,13 +42,18 @@ def install(entries)
end
end

if failure.zero?
puts Formatter.success("Homebrew Bundle complete! #{success} Brewfile #{Bundle::Dsl.pluralize_dependency(success)} now installed.")
else
unless failure.zero?
puts Formatter.error("Homebrew Bundle failed! #{failure} Brewfile #{Bundle::Dsl.pluralize_dependency(failure)} failed to install.")
if (lock = Bundle::Locker.lockfile) && lock.exist?
puts Formatter.error("Check for differences in your #{lock.basename}!")
end
return false
end

failure.zero?
Bundle::Locker.lock(entries)

puts Formatter.success("Homebrew Bundle complete! #{success} Brewfile #{Bundle::Dsl.pluralize_dependency(success)} now installed.")
true
end
end
end
142 changes: 142 additions & 0 deletions lib/bundle/locker.rb
@@ -0,0 +1,142 @@
# frozen_string_literal: true

require "tap"
require "os"
require "development_tools"

module Bundle
module Locker
module_function

def lockfile
Brewfile.path.dirname/"Brewfile.lock.json"
end

def write_lockfile?
!ARGV.include?("--no-lock") && ENV["HOMEBREW_BUNDLE_NO_LOCK"].nil?
end

def lock(entries)
return false unless write_lockfile?

lock = JSON.parse(lockfile.read) if lockfile.exist?
lock ||= {}
lock["entries"] ||= {}
lock["system"] ||= {}

entries.each do |entry|
next if Bundle::Skipper.skip?(entry, silent: true)

entry_type_key = entry.type.to_s
options = entry.options
lock["entries"][entry_type_key] ||= {}
lock["entries"][entry_type_key][entry.name] = case entry.type
when :brew
brew_list_info[entry.name]
when :cask
options.delete(:args) if options[:args].blank?
{ version: cask_list[entry.name] }
when :mas
options.delete(:id)
mas_list[entry.name]
when :tap
options.delete(:clone_target) if options[:clone_target].blank?
options.delete(:pin) if options[:pin] == false
{ revision: Tap.fetch(entry.name).git_head }
end

if options.present?
lock["entries"][entry_type_key][entry.name]["options"] =
options.deep_stringify_keys
end
end

if OS.mac?
lock["system"]["macos"] ||= {}
version, hash = system_macos
lock["system"]["macos"][version] = hash
elsif OS.linux?
lock["system"]["linux"] ||= {}
version, hash = system_linux
lock["system"]["linux"][version] = hash
end

json = JSON.pretty_generate(lock)
lockfile.unlink if lockfile.exist?
lockfile.write(json)

true
end

def brew_list_info
@brew_list_info ||= begin
name_bottles = JSON.parse(`brew info --json=v1 --installed`)
.inject({}) do |name_bottles, f|
bottle = f["bottle"]["stable"]
bottle&.delete("rebuild")
bottle&.delete("root_url")
bottle ||= false
name_bottles[f["name"]] = bottle
name_bottles
end
`brew list --versions`.lines
.inject({}) do |name_versions_bottles, line|
name, version, = line.split
name_versions_bottles[name] = {
version: version,
bottle: name_bottles[name],
}
name_versions_bottles
end
end
end

def cask_list
@cask_list ||= begin
`brew cask list --versions`.lines
.inject({}) do |name_versions, line|
name, version, = line.split
name_versions[name] = version
name_versions
end
end
end

def mas_list
@mas_list ||= begin
`mas list`.lines
.inject({}) do |name_id_versions, line|
line = line.split
id = line.shift
version = line.pop.delete("()")
name = line.join(" ")
name_id_versions[name] = {
id: id,
version: version,
}
name_id_versions
end
end
end

def system_macos
[MacOS.version.to_sym.to_s, {
"HOMEBREW_VERSION" => HOMEBREW_VERSION,
"HOMEBREW_PREFIX" => HOMEBREW_PREFIX.to_s,
"Homebrew/homebrew-core" => CoreTap.instance.git_head,
"CLT" => MacOS::CLT.version.to_s,
"Xcode" => MacOS::Xcode.version.to_s,
"macOS" => MacOS.full_version.to_s,
}]
end

def system_linux
[OS::Linux.os_version, {
"HOMEBREW_VERSION" => HOMEBREW_VERSION,
"HOMEBREW_PREFIX" => HOMEBREW_PREFIX.to_s,
"Homebrew/linuxbrew-core" => CoreTap.instance.git_head,
"GCC" => DevelopmentTools.non_apple_gcc_version("gcc"),
}]
end
end
end
3 changes: 2 additions & 1 deletion lib/bundle/skipper.rb
Expand Up @@ -3,7 +3,7 @@
module Bundle
module Skipper
class << self
def skip?(entry)
def skip?(entry, silent: false)
entry_type_skips = Array(skipped_entries[entry.type])
return false if entry_type_skips.empty?

Expand All @@ -12,6 +12,7 @@ def skip?(entry)
# occasion).
entry_ids = [entry.name, entry.options[:id]&.to_s].compact
return false if (entry_type_skips & entry_ids).empty?
return true if silent

puts Formatter.warning "Skipping #{entry.name}"
true
Expand Down
2 changes: 2 additions & 0 deletions spec/bundle/commands/install_command_spec.rb
Expand Up @@ -5,6 +5,7 @@
describe Bundle::Commands::Install do
before do
allow_any_instance_of(IO).to receive(:puts)
allow(Bundle::Locker).to receive(:write_lockfile?).and_return(false)
end

context "when a Brewfile is not found" do
Expand Down Expand Up @@ -43,6 +44,7 @@
allow(Bundle::CaskInstaller).to receive(:install).and_return(:failed)
allow(Bundle::MacAppStoreInstaller).to receive(:install).and_return(:failed)
allow(Bundle::TapInstaller).to receive(:install).and_return(:failed)
allow(Bundle::Locker).to receive(:lockfile).and_return(Pathname(__dir__))

allow(ARGV).to receive(:value).and_return(nil)
allow_any_instance_of(Pathname).to receive(:read)
Expand Down
89 changes: 89 additions & 0 deletions spec/bundle/locker_spec.rb
@@ -0,0 +1,89 @@
# frozen_string_literal: true

require "spec_helper"

describe Bundle::Locker do
subject(:locker) { described_class }

context ".lockfile" do
it "returns a Pathname" do
allow(Bundle::Brewfile).to receive(:path).and_return(Pathname("Brewfile"))
expect(locker.lockfile.class).to be Pathname
end
end

context ".write_lockfile?" do
it "returns false if --no-lock is passed" do
allow(ARGV).to receive(:include?).with("--no-lock").and_return(true)
expect(locker.write_lockfile?).to be false
end

it "returns false if HOMEBREW_BUNDLE_NO_LOCK is set" do
ENV["HOMEBREW_BUNDLE_NO_LOCK"] = "1"
expect(locker.write_lockfile?).to be false
end

it "returns true without --no-lock or HOMEBREW_BUNDLE_NO_LOCK" do
ENV["HOMEBREW_BUNDLE_NO_LOCK"] = nil
expect(locker.write_lockfile?).to be true
end
end

context ".lock" do
context "writes Brewfile.lock.json" do
let(:lockfile) { Pathname("Brewfile.json.lock") }
let(:brew_options) { { restart_service: true } }
let(:entries) do
[
Bundle::Dsl::Entry.new(:brew, "mysql", brew_options),
Bundle::Dsl::Entry.new(:cask, "adoptopenjdk8"),
Bundle::Dsl::Entry.new(:mas, "Xcode", id: 497799835),
Bundle::Dsl::Entry.new(:tap, "homebrew/homebrew-cask-versions"),
]
end

before do
allow(locker).to receive(:lockfile).and_return(lockfile)
allow(brew_options).to receive(:deep_stringify_keys)
.and_return( { "restart_service" => true } )
allow(locker).to receive(:`).with("brew info --json=v1 --installed").and_return <<~EOS
[
{
"name":"mysql",
"bottle":{
"stable":{}
}
}
]
EOS
allow(locker).to receive(:`).with("brew list --versions").and_return("mysql 8.0.18")
end

context "on macOS" do
before do
allow(OS).to receive(:mac?).and_return(true)

allow(locker).to receive(:`).with("brew cask list --versions").and_return("adoptopenjdk8 8,232:b09")
allow(locker).to receive(:`).with("mas list").and_return("497799835 Xcode (11.2)")
end

it do
expect(lockfile).to receive(:write)
expect(locker.lock(entries)).to be true
end
end

context "on Linux" do
before do
allow(OS).to receive(:mac?).and_return(false)
allow(OS).to receive(:linux?).and_return(true)
end

it do
expect(lockfile).to receive(:write)
expect(locker.lock(entries)).to be true
end
end
end
end
end
2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Expand Up @@ -15,7 +15,7 @@ def linux?
if macos?
minimum_coverage 100
else
minimum_coverage 98
minimum_coverage 97
end
end

Expand Down
7 changes: 7 additions & 0 deletions spec/stub/development_tools.rb
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class DevelopmentTools
def self.non_apple_gcc_version(*)
"9.2.0"
end
end
1 change: 1 addition & 0 deletions spec/stub/global.rb
Expand Up @@ -2,3 +2,4 @@

HOMEBREW_PREFIX = Pathname.new("/usr/local")
HOMEBREW_REPOSITORY = Pathname.new("/usr/local/Homebrew")
HOMEBREW_VERSION = "2.1.0"

0 comments on commit 8d23c65

Please sign in to comment.