Module: GemChecksums

Defined in:
lib/gem_checksums.rb,
lib/gem_checksums/version.rb

Overview

Semantic version for the GemChecksums namespace

Defined Under Namespace

Modules: Version Classes: Error

Constant Summary collapse

VERSION_REGEX =

Final clause of Regex (?=\.gem) is a positive lookahead assertion See: https://learnbyexample.github.io/Ruby_Regexp/lookarounds.html#positive-lookarounds Used to pattern match against a gem package name, which always ends with .gem. The positive lookahead ensures it is present, and prevents it from being captured.

/((\d+\.\d+\.\d+)([-.][0-9A-Za-z-]+)*)(?=\.gem)/.freeze
RUNNING_AS =
File.basename($PROGRAM_NAME)
BUILD_TIME_ERROR_MESSAGE =
"Environment variable SOURCE_DATE_EPOCH must be set. You'll need to rebuild the gem. See README.md of stone_checksums"
GIT_DRY_RUN_ENV =
ENV.fetch("GEM_CHECKSUMS_GIT_DRY_RUN", "false").casecmp("true") == 0
CHECKSUMS_DIR =
ENV.fetch("GEM_CHECKSUMS_CHECKSUMS_DIR", "checksums")
PACKAGE_DIR =
ENV.fetch("GEM_CHECKSUMS_PACKAGE_DIR", "pkg")
BUILD_TIME_WARNING =
<<-BUILD_TIME_WARNING
WARNING: Build time not provided via environment variable SOURCE_DATE_EPOCH.
When using Bundler < 2.7.0, you must set SOURCE_DATE_EPOCH *before* building
the gem to ensure consistent SHA-256 & SHA-512 checksums.

PREFERRED: Upgrade to Bundler >= 2.7.0, which uses a constant timestamp for gem builds,
         making SOURCE_DATE_EPOCH unnecessary for reproducible checksums.

IMPORTANT: If you choose to set the build time via SOURCE_DATE_EPOCH,
         you must re-build the gem, i.e. `bundle exec rake build` or `gem build`.

How to set the build time (only needed for Bundler < 2.7.0):

In zsh shell:
- export SOURCE_DATE_EPOCH=$EPOCHSECONDS && echo $SOURCE_DATE_EPOCH
- If the echo above has no output, then it didn't work.
- Note that you'll need the `zsh/datetime` module enabled.

In fish shell:
- set -x SOURCE_DATE_EPOCH (date +%s)
- echo $SOURCE_DATE_EPOCH

In bash shell:
- export SOURCE_DATE_EPOCH=$(date +%s) && echo $SOURCE_DATE_EPOCH

BUILD_TIME_WARNING

Class Method Summary collapse

Class Method Details

.current_project_specObject



240
241
242
243
244
245
# File 'lib/gem_checksums.rb', line 240

def current_project_spec
  gemspecs = Dir["*.gemspec"]
  return unless gemspecs.length == 1

  Gem::Specification.load(gemspecs.first)
end

.generate(git_dry_run: false) ⇒ void

This method returns an undefined value.

Script, stolen from myself, from https://github.com/rubygems/guides/pull/325 NOTE (Bundler < 2.7.0): SOURCE_DATE_EPOCH must be set in your environment prior to building the gem. Bundler >= 2.7.0 uses a constant timestamp internally, so SOURCE_DATE_EPOCH is no longer required. This ensures that the gem build, and the gem checksum will use the same timestamp, and thus will match the SHA-256 checksum generated for every gem on Rubygems.org. Generate SHA-256 and SHA-512 checksums for a built .gem and commit them.

Behavior regarding reproducible builds depends on Bundler version:

  • Bundler >= 2.7.0: SOURCE_DATE_EPOCH is not required; Bundler uses a constant timestamp.
  • Bundler < 2.7.0: you must set SOURCE_DATE_EPOCH, or upgrade Bundler. If GEM_CHECKSUMS_ASSUME_YES=true is set, the check proceeds non-interactively, but SOURCE_DATE_EPOCH is still required.

The generated checksum files are written to the directory configured via GEM_CHECKSUMS_CHECKSUMS_DIR (default: "checksums"). By default, the newest .gem in GEM_CHECKSUMS_PACKAGE_DIR (default: "pkg") is used, unless a specific .gem path is passed as the first CLI argument when running under Rake or the gem_checksums CLI.

By default this command will exec a git add && git commit to include the checksum files. When git_dry_run is true, or GEM_CHECKSUMS_GIT_DRY_RUN=true, the planned dry-run commit is printed and temporary files are cleaned up without touching the Git index.

Parameters:

  • git_dry_run (Boolean) (defaults to: false)

    when true, perform a dry-run and do not leave files staged



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
# File 'lib/gem_checksums.rb', line 88

def generate(git_dry_run: false)
  git_dry_run_flag = (git_dry_run || GIT_DRY_RUN_ENV) ? "--dry-run" : nil
  warn("Will run git commit with --dry-run") if git_dry_run_flag

  # Header: identify the gem and version being run
  begin
    puts "[ stone_checksums #{::StoneChecksums::Version::VERSION} ]"
  rescue
    # If for any reason the version constant isn't available, skip header gracefully
  end

  # Bundler version gate for reproducibility requirements
  bundler_ver = Gem::Version.new(Bundler::VERSION)

  requires_epoch = bundler_ver < Gem::Version.new("2.7.0")

  if requires_epoch
    # For older bundler, ask the user whether to proceed, or quit to update.
    proceed = ENV.fetch("GEM_CHECKSUMS_ASSUME_YES", "").casecmp("true").zero?

    unless proceed
      # Non-interactive prompt: advise and abort
      prompt_msg = <<-PROMPT
Detected Bundler #{bundler_ver || "(unknown)"} which is older than 2.7.0.
For reproducible builds without SOURCE_DATE_EPOCH, please update Bundler to >= 2.7.0.
If you still want to proceed with this older Bundler, you must set SOURCE_DATE_EPOCH and re-run.
Tip: set GEM_CHECKSUMS_ASSUME_YES=true to proceed non-interactively (still requires SOURCE_DATE_EPOCH).
      PROMPT
      warn(prompt_msg)
      # Continue to enforce SOURCE_DATE_EPOCH below; if not set, this will raise.
    end

    build_time = ENV.fetch("SOURCE_DATE_EPOCH", "")
    build_time_missing = !(build_time =~ /\d{10,}/)

    if build_time_missing
      warn(BUILD_TIME_WARNING)
      raise Error, BUILD_TIME_ERROR_MESSAGE
    end
  end

  gem_path_parts =
    case RUNNING_AS
    when "rake", "gem_checksums"
      first_arg = ARGV.first
      first_arg.respond_to?(:split) ? first_arg.split("/") : []
    else # e.g. "rspec"
      []
    end

  if gem_path_parts.any?
    gem_name = gem_path_parts.last
    gem_pkg = File.join(gem_path_parts)
    puts "Looking for: #{gem_pkg.inspect}"
    gems = Dir[gem_pkg]
    raise Error, "Unable to find gem #{gem_pkg}" if gems.empty?

    puts "Found: #{gems.inspect}"
  else
    gem_pkgs = File.join(PACKAGE_DIR, "*.gem")
    puts "Looking for: #{gem_pkgs.inspect}"
    gems = Dir[gem_pkgs]
    raise Error, "Unable to find gems #{gem_pkgs}" if gems.empty?

    # Sort by newest last
    # [ "my_gem-2.3.9.gem", "my_gem-2.3.11.pre.alpha.4.gem", "my_gem-2.3.15.gem", ... ]
    gems.sort_by! { |gem| Gem::Version.new(gem[VERSION_REGEX]) }
    gem_pkg = preferred_project_package(gems) || gems.last
    gem_path_parts = gem_pkg.split("/")
    gem_name = gem_path_parts.last
    puts "Found: #{gems.length} gems; selected #{gem_name}"
  end

  validate_project_package!(gem_pkg)

  pkg_bits = File.read(gem_pkg)

  # SHA-512 digest is 8 64-bit words
  digest512_64bit = Digest::SHA512.new.hexdigest(pkg_bits)
  digest512_64bit_path = "#{CHECKSUMS_DIR}/#{gem_name}.sha512"
  Dir.mkdir(CHECKSUMS_DIR) unless Dir.exist?(CHECKSUMS_DIR)
  File.write(digest512_64bit_path, digest512_64bit)

  # SHA-256 digest is 8 32-bit words
  digest256_32bit = Digest::SHA256.new.hexdigest(pkg_bits)
  digest256_32bit_path = "#{CHECKSUMS_DIR}/#{gem_name}.sha256"
  File.write(digest256_32bit_path, digest256_32bit)

  version = gem_name[VERSION_REGEX]

  git_commit_flag = git_dry_run_flag ? "--dry-run" : nil
  git_add_flag = git_dry_run_flag ? "--dry-run" : nil
  git_cmd = <<-GIT_MSG.rstrip
git add #{git_add_flag} #{CHECKSUMS_DIR}/* && \
git commit #{git_commit_flag} -m "🔒️ Checksums for v#{version}"
  GIT_MSG

  if git_dry_run_flag
    git_cmd += <<-CLEANUP_MSG
&& \
echo "Cleaning up in dry run mode" && \
rm -f #{digest512_64bit_path} && \
rm -f #{digest256_32bit_path}
    CLEANUP_MSG
  end

  puts <<-RESULTS
[ GEM: #{gem_name} ]
[ VERSION: #{version} ]
[ GEM PKG LOCATION: #{gem_pkg} ]
[ CHECKSUM SHA-256: #{digest256_32bit} ]
[ CHECKSUM SHA-512: #{digest512_64bit} ]
[ CHECKSUM SHA-256 PATH: #{digest256_32bit_path} ]
[ CHECKSUM SHA-512 PATH: #{digest512_64bit_path} ]

... Running ...

#{git_cmd}
  RESULTS

  if git_dry_run_flag
    warn("Cleaning up in dry run mode")
    FileUtils.rm_f([digest512_64bit_path, digest256_32bit_path])
  else
    # `exec` will replace the current process with the git process, and exit.
    # Within the generate method, Ruby code placed after the `exec` *will not be run*:
    #   See: https://www.akshaykhot.com/call-shell-commands-in-ruby
    # But we can't exit the process when testing from RSpec,
    #   since that would exit the parent RSpec process
    exec(git_cmd)
  end
end

.install_tasksvoid

This method returns an undefined value.

Make this gem's rake tasks available in your Rakefile:

require "gem_checksums"


58
59
60
# File 'lib/gem_checksums.rb', line 58

def install_tasks
  load("gem_checksums/tasks.rb")
end

.preferred_project_package(gems) ⇒ Object



248
249
250
251
252
253
254
# File 'lib/gem_checksums.rb', line 248

def preferred_project_package(gems)
  project_spec = current_project_spec
  return unless project_spec

  expected_name = "#{project_spec.name}-#{project_spec.version}.gem"
  gems.find { |gem| File.basename(gem) == expected_name }
end

.validate_package_against_project?(gem_pkg, project_spec) ⇒ Boolean

Returns:

  • (Boolean)


257
258
259
# File 'lib/gem_checksums.rb', line 257

def validate_package_against_project?(gem_pkg, project_spec)
  File.basename(File.expand_path(PACKAGE_DIR)) == "pkg" || File.basename(gem_pkg).start_with?("#{project_spec.name}-")
end

.validate_project_package!(gem_pkg) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/gem_checksums.rb', line 222

def validate_project_package!(gem_pkg)
  project_spec = current_project_spec
  return unless project_spec
  return unless validate_package_against_project?(gem_pkg, project_spec)

  package_spec = Gem::Package.new(gem_pkg).spec
  return if package_spec.name == project_spec.name && package_spec.version == project_spec.version

  raise Error, [
    "Built gem version mismatch for #{gem_pkg}.",
    "Current gemspec resolves to #{project_spec.name} #{project_spec.version}, but selected package is #{package_spec.name} #{package_spec.version}.",
    "Remove stale packages or pass the intended .gem path explicitly before generating checksums."
  ].join("\n")
rescue Gem::Package::Error => error
  raise Error, "Unable to inspect built gem #{gem_pkg}: #{error.message}"
end