lintje 0.7.1

Lintje is an opinionated linter for Git.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
# frozen_string_literal: true

require "erb"
require "json"
require "dotenv"

PROJECT_NAME = "Lintje"
PROJECT_SLUG = "lintje"
PROJECT_MAINTAINER = "Tom de Bruijn tom@tomdebruijn.com"
PROJECT_HOMEPAGE = "https://github.com/tombruijn/lintje"
PROJECT_DESCRIPTION = "Lintje is an opinionated linter for Git."
CLOUDSMITH_REPO = "lintje/lintje"

BUILDS = {
  "x86_64-apple-darwin" => {
    :builder => :cargo,
    :artifact_filename => "lintje",
    :strip => { :local => true }
  },
  "aarch64-apple-darwin" => {
    :builder => :cargo,
    :artifact_filename => "lintje",
    :strip => { :local => true }
  },
  "x86_64-unknown-linux-gnu" => {
    :builder => :cross,
    :artifact_filename => "lintje",
    :platform => "amd64",
    :strip => { :container => true }
  },
  "aarch64-unknown-linux-gnu" => {
    :builder => :cross,
    :artifact_filename => "lintje",
    :platform => "arm64",
    :strip => { :container => true }
  },
  "x86_64-unknown-linux-musl" => {
    :builder => :cross,
    :artifact_filename => "lintje",
    :platform => "amd64",
    :strip => { :container => true }
  },
  "aarch64-unknown-linux-musl" => {
    :builder => :cross,
    :artifact_filename => "lintje",
    :platform => "arm64",
    :strip => { :container => true }
  },
  "x86_64-pc-windows-gnu" => {
    :builder => :cross,
    :artifact_filename => "lintje.exe",
    :platform => "amd64",
    :strip => { :container => "rustembedded/cross:x86_64-pc-windows-gnu" }
  }
}.freeze

DIST_DIR = "dist"
DIST_ARCHIVES_DIR = File.join(DIST_DIR, "archives")
DIST_PACKAGES_DIR = File.join(DIST_DIR, "packages")
ARCHIVES_CHECKSUMS_FILE = File.join(DIST_ARCHIVES_DIR, "checksums_256.txt")

namespace :build do
  task :prepare do
    puts "Installing cross if not installed"
    run "which cross > /dev/null 2>&1 || cargo install cross"
  end

  task :all => :prepare do
    clean_dist_dir
    BUILDS.each do |triple, options|
      build_release triple, options
    end
  end

  def build_release(triple, options)
    filename = options[:artifact_filename]
    puts "Building #{triple} (#{filename})"
    prepare_dist_for triple
    run "rustup target add #{triple}"
    run "#{options[:builder]} build --release --target #{triple}"
    FileUtils.copy(
      File.join("target", triple, "release", filename),
      File.join(DIST_ARCHIVES_DIR, triple)
    )
    strip_artifact triple, options
  end

  def strip_artifact(triple, options)
    filename = options[:artifact_filename]
    if options[:strip][:local]
      run "strip #{filename}", :chdir => File.join(DIST_ARCHIVES_DIR, triple)
    elsif options[:strip][:container]
      platform = options[:platform]
      image =
        case options[:strip][:container]
        when true
          # Build development image
          tag = "tombruijn/lintje-#{triple}:build"
          build_docker_image tag, "Dockerfile.#{triple}", :platform => platform
          tag
        else
          # Use existing image
          options[:strip].fetch(:container)
        end

      run_in_container image, <<~COMMAND, :platform => platform
        strip #{File.join(DIST_ARCHIVES_DIR, triple, filename)}
      COMMAND
    else
      raise "No strip method defined for: #{triple}"
    end
  end
end
task :build => ["build:all"]

namespace :release do
  task :prepare => [
    :check_local_changes,
    :check_env,
    :prompt_confirmation,
    :check_tag_presence,
    :check_gh_install
  ]

  task :check_local_changes do
    if local_changes?
      puts "Local changes detected!"
      puts "Please commit all changes before release."
      exit 1
    end
  end

  task :check_env do
    Dotenv.load
    unless ENV["CLOUDSMITH_API_KEY"]
      puts "The CLOUDSMITH_API_KEY env var is not configured in the `.env` " \
        "file."
      puts "Please make sure the environment is configured correctly."
      exit 1
    end
  end

  task :prompt_confirmation do
    version = fetch_package_version
    cargo_lock = File.read("Cargo.lock")
    cargo_lock_updated = cargo_lock.include?(<<~LOCK)
      name = "lintje"
      version = "#{version}"
    LOCK
    unless cargo_lock_updated
      puts "Cargo.lock is not updated to be the same version as Cargo.toml! " \
        "Run `cargo build` to update the lock file."
      exit 1
    end

    answer = prompt_confirmation \
      "Do you want to publish Lintje v#{version}? (y/n) "
    unless answer
      puts "Exiting..."
      exit 0
    end
  end

  task :check_tag_presence do
    version = fetch_package_version
    if run("git tag").split("\n").include?("v#{version}")
      puts "Tag #{version} already exists. Exiting."
      puts "Please make sure to update the version in the Cargo.toml file."
      puts "Don't forget to update the CHANGELOG.md file."
      exit 1
    end
  end

  task :check_gh_install do
    run "which gh &>/dev/null"
  rescue CommandFailed
    puts "The GitHub CLI could not be found. " \
      "Please install it before continuing."
    puts "https://cli.github.com/manual/"
    puts "And run 'gh auth login'"
    exit 1
  end

  task :all => [:prepare, "build:all"] do
    version = fetch_package_version
    release_docker_release_image(version)
    build_archives
    build_packages

    tag = "v#{version}"
    run "git tag #{tag}"
    run "git push origin #{current_branch} #{tag}"
    puts "Creating release on GitHub. Please follow the prompts."
    system <<~COMMAND
      gh release create v#{version} \
        #{File.join(DIST_ARCHIVES_DIR, "*.tar.gz")} #{ARCHIVES_CHECKSUMS_FILE} \
        --title "Release #{version}"
    COMMAND

    puts "Publishing to crates.io"
    system "cargo publish"

    puts "Release of version #{version} done!"
    puts "Please update the Homebrew tap next: https://github.com/tombruijn/homebrew-lintje"
  end

  task :docker_image do
    prepare_docker_buildx
    release_docker_release_image(fetch_package_version)
  end

  task :archives do
    build_archives
  end

  def build_archives
    puts "Building release archives"
    prepare_checksums_file
    BUILDS.each do |triple, _options|
      archive_artifact triple
      add_archive_checksum triple
    end
  end

  def archive_artifact(triple)
    archive_location = File.join("..", "#{triple}.tar.gz")
    run "tar -cvzf #{archive_location} *",
      :chdir => File.join(DIST_ARCHIVES_DIR, triple)
  end

  def prepare_checksums_file
    if File.exist? ARCHIVES_CHECKSUMS_FILE
      FileUtils.remove ARCHIVES_CHECKSUMS_FILE
    end
    FileUtils.touch ARCHIVES_CHECKSUMS_FILE
  end

  def add_archive_checksum(triple)
    checksum = run(
      "shasum -a 256 #{triple}.tar.gz",
      :chdir => DIST_ARCHIVES_DIR
    )
    # Append checksum
    File.open(ARCHIVES_CHECKSUMS_FILE, "a") do |file|
      file.write checksum
    end
  end

  task :packages do
    build_packages
  end

  def build_packages
    puts "Building OS packages"
    build_debian_package "amd64", "x86_64-unknown-linux-gnu"
    build_debian_package "arm64", "aarch64-unknown-linux-gnu"
  end

  def build_debian_package(package_architecture, triple)
    package_config = BUILDS[triple]
    package_revision = "1" # Hardcoded package revision for now
    version = fetch_package_version
    package_name =
      "#{PROJECT_SLUG}-#{version}-#{package_revision}-#{package_architecture}"
    package_filename = "#{package_name}.deb"

    # Prepare packge dist dir
    package_dir = File.join(DIST_PACKAGES_DIR, package_name)
    dist_dir = File.join(DIST_ARCHIVES_DIR, triple)
    FileUtils.remove_dir package_dir if Dir.exist? package_dir
    FileUtils.mkdir_p package_dir

    # Create DEBIAN `control` file with package metadata
    debian_dir = File.join(package_dir, "DEBIAN")
    FileUtils.mkdir_p debian_dir
    File.open File.join(debian_dir, "control"), "w" do |file|
      bind = Class.new do
        def initialize(version, architecture)
          @package_slug = PROJECT_NAME
          @package_name = PROJECT_NAME
          @package_version = version
          @package_architecture = architecture
          @package_maintainer = PROJECT_MAINTAINER
          @package_description = PROJECT_DESCRIPTION
        end

        def fetch_binding
          binding
        end
      end.new(version, package_architecture).fetch_binding
      template = File.read("support/packages/deb/DEBIAN/control.erb")
      file.write ERB.new(template).result bind
    end
    # Copy executable to package dist dir.
    # The path inside the package dist dir mimics the install location on the
    # installation machine.
    bin_dir = File.join(package_dir, "usr", "bin")
    FileUtils.mkdir_p bin_dir
    FileUtils.cp(
      File.join(dist_dir, package_config[:artifact_filename]),
      bin_dir
    )

    # Build the Docker image in which to build the package
    image_tag = "ubuntu-deb:build_#{package_architecture}"
    build_docker_image image_tag,
      "Dockerfile.ubuntu-deb",
      :platform => package_architecture
    # Build and test the package
    run_in_container image_tag,
      "support/script/build_deb",
      :platform => package_architecture,
      :env => { "PACKAGE_NAME" => package_name }
    upload_debian_package(package_filename)
  end

  def upload_debian_package(filename)
    api_key = ENV["CLOUDSMITH_API_KEY"]
    file_path = File.join(DIST_PACKAGES_DIR, filename)
    # Upload the package to Cloudsmith
    response = run <<~COMMAND
      curl \
        --silent \
        --show-error \
        --upload-file #{file_path} \
        -u 'tombruijn:#{api_key}' \
        -H "Content-Sha256: $(shasum -a256 '#{file_path}' | cut -f1 -d' ')" \
        https://upload.cloudsmith.io/#{CLOUDSMITH_REPO}/#{filename}
    COMMAND
    output = JSON.parse(response)
    identifier = output.fetch("identifier")
    # Create package on Cloudsmith
    # It's currently set to any-distro/any-version, because the executable has
    # no specific dependencies or limitations for older versions of
    # distributions that I know of.
    run <<~COMMAND
      curl -X POST -H "Content-Type: application/json" \
        -u 'tombruijn:#{api_key}' \
        -d '{"package_file": "#{identifier}", "distribution": "any-distro/any-version"}' \
        https://api-prd.cloudsmith.io/v1/packages/#{CLOUDSMITH_REPO}/upload/deb/
    COMMAND
  end
end
task :release => ["release:all"]

def clean_dist_dir
  return unless Dir.exist? DIST_DIR

  FileUtils.remove_dir DIST_DIR
end

def prepare_dist_for(triple)
  FileUtils.mkdir_p(DIST_ARCHIVES_DIR)
  target_dist_dir = File.join(DIST_ARCHIVES_DIR, triple)
  FileUtils.remove_dir(target_dist_dir) if Dir.exist? target_dist_dir
  FileUtils.mkdir_p(target_dist_dir)
end

# Run a command
#
# Outputs the STDOUT and STDERR while it's running and returns the STDOUT AND
# STDERR as the method return value.
def run(command, chdir: nil)
  chdir_label = " (#{chdir})" if chdir
  puts "Running command: #{command}#{chdir_label}"
  read, write = IO.pipe
  options = { [:out, :err] => write }
  options[:chdir] = chdir if chdir
  pid = spawn command, options
  output_lines = []
  thread =
    Thread.new do
      while line = read.readline # rubocop:disable Lint/AssignmentInCondition
        # Output lines as the program runs
        puts "| #{line}"
        # Store the output for later
        output_lines << line
      end
    rescue EOFError
      # Do nothing, nothing to read anymore
    end
  _pid, status = Process.wait2 pid
  write.close
  thread.join
  output = output_lines.join
  raise CommandFailed.new(command, output) unless status.success?

  puts
  output
end

def prepare_docker_buildx
  build_name = "lintje-builder"
  output = run "docker buildx ls"
  return if output.include?(build_name)

  run "docker buildx create --name #{build_name} --bootstrap"
  run "docker buildx use #{build_name}"
end

def symlink_dist_target(old, new)
  Dir.chdir DIST_ARCHIVES_DIR do
    File.symlink(old, new)
  end
end

def remove_dist_target(target)
  dist_target_dir = File.join(DIST_ARCHIVES_DIR, target)
  return unless File.exist?(dist_target_dir)

  FileUtils.remove_dir(dist_target_dir)
end

def release_docker_release_image(version)
  # Create symlinks for the directory names the build images look for based on
  # the architecture name as used by Docker, which do not always match with
  # what is used by Rust.
  symlink_dist_target("x86_64-unknown-linux-musl", "amd64-unknown-linux-musl")
  symlink_dist_target("aarch64-unknown-linux-musl", "arm-unknown-linux-musl")
  symlink_dist_target("aarch64-unknown-linux-musl", "arm64-unknown-linux-musl")

  image = "tombruijn/lintje"
  platforms = %w[linux/arm64 linux/amd64 linux/arm/v7]
  run <<~COMMAND
    docker buildx build \
    --push \
    --platform=#{platforms.join(",")} \
    --tag #{image}:#{version} \
    .
  COMMAND
  # Also push as the new latest version
  run <<~COMMAND
    docker buildx build \
    --push \
    --platform=#{platforms.join(",")} \
    --tag #{image}:latest \
    .
  COMMAND
ensure
  # Remove symlinks after the images are made and push so it doesn't stick
  # around only to confuse me about its purpose
  remove_dist_target("amd64-unknown-linux-musl")
  remove_dist_target("arm-unknown-linux-musl")
  remove_dist_target("arm64-unknown-linux-musl")
end

def build_docker_image(image, dockerfile, platform: nil)
  puts "Building docker image: #{image} (support/docker/#{dockerfile})"
  platform_option = "--platform=#{platform_for_docker platform}" if platform
  run <<~COMMAND, :chdir => "support/docker"
    docker build \
      #{platform_option} \
      --file #{dockerfile} \
      --tag #{image} \
      .
  COMMAND
end

def run_in_container(image, command, env: nil, platform: nil)
  platform_option = "--platform=#{platform_for_docker platform}" if platform
  if env
    mapped_env = env.map { |key, value| "#{key}=#{value}" }.join(" ")
    env_option = "--env #{mapped_env}"
  end
  run <<~COMMAND
    docker run \
      --rm \
      -it \
      --volume "#{__dir__}:/project" \
      --workdir "/project" \
      #{platform_option} \
      #{env_option} \
      #{image} \
      #{command}
  COMMAND
end

def platform_for_docker(platform)
  case platform
  when "amd64"
    "linux/amd64"
  when "arm64"
    "linux/arm64/v8"
  else
    raise "Unknown platform: #{platform}"
  end
end

def fetch_package_version
  File.read("Cargo.toml").scan(/version = "(.*)"/).first.first
end

def local_changes?
  `git status -s -u`.split("\n").each do |change|
    change.gsub!(/^.. /, "")
  end.any?
end

def current_branch
  `git rev-parse --abbrev-ref HEAD`.chomp
end

def prompt_confirmation(message)
  loop do
    print message
    input = fetch_input.strip
    case input
    when "y", "Y", "yes"
      return true
    when "n", "N", "no"
      return false
    end
  end
end

def fetch_input
  input = $stdin.gets
  input ? input.chomp : ""
rescue Interrupt
  puts "\nExiting..."
  exit 1
end

class CommandFailed < StandardError
  def initialize(command, output)
    @command = command
    @output = output
    super()
  end

  def message
    "The command has failed to run: #{@command}\nOutput:\n#{@output}"
  end
end