From a7544136636e3ad1ba195d56bc511fbbaabafa1f Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Fri, 29 May 2026 07:34:42 +0000 Subject: [PATCH 1/2] rewrite scripts in ruby with strict rubocop linting --- .github/workflows/ci.yml | 9 +- .rubocop.yml | 84 +++++++++++++ .rubocop/no_modifier_conditional.rb | 19 +++ .rubocop/no_unless.rb | 19 +++ .scripts/auto-update | 123 ++++++++++--------- .scripts/images-additional-tests | 25 ++-- .scripts/images-deps | 18 ++- .scripts/images-resolve-inherits | 183 ++++++++++++++++------------ .scripts/images-with-extras | 70 +++++------ Gemfile | 5 + Gemfile.lock | 42 +++++++ Makefile | 10 +- 12 files changed, 418 insertions(+), 189 deletions(-) create mode 100644 .rubocop.yml create mode 100644 .rubocop/no_modifier_conditional.rb create mode 100644 .rubocop/no_unless.rb create mode 100644 Gemfile create mode 100644 Gemfile.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09cd91c04..d0676dffe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,12 +34,19 @@ jobs: complete: if: always() name: complete - needs: [build, test, action-using-artifact, push, action-using-registry] + needs: [lint, build, test, action-using-artifact, push, action-using-registry] runs-on: ubuntu-latest steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: make lint # runs rubocop in a ruby container; docker is preinstalled on runners + setup: name: 1 setup runs-on: ubuntu-latest diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..c145dff38 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,84 @@ +# RuboCop is configured to keep the scripts in .scripts/ written in plain, +# predictable Ruby. It disallows the Ruby-isms that people who don't write +# Ruby every day find surprising: method calls without parentheses, `unless`, +# and trailing (modifier) `if`/`unless`. + +require: + - ./.rubocop/no_unless.rb + - ./.rubocop/no_modifier_conditional.rb + +AllCops: + TargetRubyVersion: 3.2 + NewCops: enable + SuggestExtensions: false + Include: + - ".scripts/*" + - "**/*.rb" + Exclude: + - ".rubocop/**/*" + - "Gemfile" + +# --- Rules we explicitly want enforced --- + +# Always use parentheses around method-call arguments (no "bracket-less" calls). +Style/MethodCallWithArgsParentheses: + Enabled: true + EnforcedStyle: require_parentheses + +# Our custom cops (defined in .rubocop/) that ban surprising Ruby-isms. +Local/NoUnless: + Enabled: true + +Local/NoModifierConditional: + Enabled: true + +# --- Cops disabled because they would push code back toward the forms above --- + +# Would demand a trailing `if`/`unless` for short statements. +Style/IfUnlessModifier: + Enabled: false + +# Would demand a trailing `while`/`until` for short loops. +Style/WhileUntilModifier: + Enabled: false + +# Would rewrite `if !x` into `unless x`. +Style/NegatedIf: + Enabled: false + +# Guard clauses are written as trailing `if`/`unless`. +Style/GuardClause: + Enabled: false + +# `next if ...` is a trailing conditional. +Style/Next: + Enabled: false + +# --- Keep things plain rather than "clever" --- + +# Allow ["a", "b"] rather than requiring %w[a b]. +Style/WordArray: + Enabled: false + +Style/SymbolArray: + Enabled: false + +# Allow building an array with a plain `each` + `push` loop rather than +# requiring `map`/`flat_map`/`each_with_object`. +Style/MapIntoArray: + Enabled: false + +# Use double quotes everywhere so quoting is one less thing to think about. +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes + +# These are top-level scripts; top-level method definitions are expected. +Style/TopLevelMethodDefinition: + Enabled: false + +# Don't force these small scripts to be split up by length/complexity limits. +Metrics: + Enabled: false diff --git a/.rubocop/no_modifier_conditional.rb b/.rubocop/no_modifier_conditional.rb new file mode 100644 index 000000000..b492a7b36 --- /dev/null +++ b/.rubocop/no_modifier_conditional.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Local + # Disallows trailing (modifier) `if`/`unless`, e.g. `do_thing if cond`. + # Conditionals must be written as ordinary multi-line statements. + class NoModifierConditional < RuboCop::Cop::Base + MSG = "Do not use a trailing `if`/`unless`; write it as a multi-line statement." + + def on_if(node) + return unless node.modifier_form? + + add_offense(node.loc.keyword) + end + end + end + end +end diff --git a/.rubocop/no_unless.rb b/.rubocop/no_unless.rb new file mode 100644 index 000000000..589be4df1 --- /dev/null +++ b/.rubocop/no_unless.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Local + # Disallows `unless`. Use `if` with a negated condition instead, which + # reads more predictably for people who don't write Ruby every day. + class NoUnless < RuboCop::Cop::Base + MSG = "Do not use `unless`; use `if` with a negated condition." + + def on_if(node) + return unless node.unless? + + add_offense(node.loc.keyword) + end + end + end + end +end diff --git a/.scripts/auto-update b/.scripts/auto-update index fbee2a9b4..e8fea3bcc 100755 --- a/.scripts/auto-update +++ b/.scripts/auto-update @@ -1,8 +1,8 @@ -#!/usr/bin/env python3 +#!/usr/bin/env ruby +# frozen_string_literal: true -import json -import subprocess -import sys +require "json" +require "open3" # Auto-update script for images.json # @@ -11,65 +11,76 @@ import sys # For 'stable': uses GitHub's latest stable release # For 'rc': uses the newest release (prerelease or stable) -def main(): - if len(sys.argv) != 3 or sys.argv[2] not in ('stable', 'rc'): - print("Usage: auto-update ", file=sys.stderr) - sys.exit(1) +def main + if ARGV.length != 2 || !["stable", "rc"].include?(ARGV[1]) + abort("Usage: auto-update ") + end - image_tag, mode = sys.argv[1], sys.argv[2] + image_tag = ARGV[0] + mode = ARGV[1] - with open('images.json', 'r') as f: - images = json.load(f) + images = JSON.parse(File.read("images.json")) - # Find target image - target = next((img for img in images if img['tag'] == image_tag), None) - if not target: - print(f"Error: image '{image_tag}' not found", file=sys.stderr) - sys.exit(1) + # Find target image. + target = images.find do |image| + image["tag"] == image_tag + end + if target.nil? + abort("Error: image '#{image_tag}' not found") + end - updated = False - for dep in target['deps']: - name, repo, current = dep['name'], dep['repo'], dep['ref'] + updated = false + target["deps"].each do |dep| + name = dep["name"] + repo = dep["repo"] + current = dep["ref"] - # Skip non-release refs (branches, SHAs) - if not current.startswith('v'): - print(f"{name}: skipping, ref is not a version") - continue + # Skip non-release refs (branches, SHAs). + if !current.start_with?("v") + puts("#{name}: skipping, ref is not a version") + next + end - latest = get_latest_release(repo, include_prerelease=(mode == 'rc')) - if not latest: - print(f"{name}: skipping, no release found") - elif latest != current: - print(f"{name}: {current} -> {latest}") - dep['ref'] = latest - updated = True - else: - print(f"{name}: matches latest release") + latest = get_latest_release(repo, mode == "rc") + if latest.nil? + puts("#{name}: skipping, no release found") + elsif latest != current + puts("#{name}: #{current} -> #{latest}") + dep["ref"] = latest + updated = true + else + puts("#{name}: matches latest release") + end + end - if updated: - with open('images.json', 'w') as f: - json.dump(images, f, indent=2) - f.write('\n') + if updated + File.write("images.json", "#{JSON.pretty_generate(images)}\n") + end +end -def get_latest_release(repo, include_prerelease=False): - """Get the latest release. If include_prerelease is False, skip prereleases.""" - for rel in get_releases(repo): - if include_prerelease and 'rc' in rel['tag']: - return rel['tag'] - if not rel['prerelease']: - return rel['tag'] - return None +# Get the latest release. If include_prerelease is false, skip prereleases. +def get_latest_release(repo, include_prerelease) + get_releases(repo).each do |rel| + if include_prerelease && rel["tag"].include?("rc") + return rel["tag"] + end + if !rel["prerelease"] + return rel["tag"] + end + end + nil +end -def get_releases(repo): - """Get all releases from a repo.""" - result = subprocess.run( - ['gh', 'api', f'repos/{repo}/releases', '--jq', '[.[] | {tag: .tag_name, prerelease: .prerelease}]'], - capture_output=True, text=True - ) - if result.returncode != 0: - print(f"Error: failed to get releases for {repo}: {result.stderr}", file=sys.stderr) - sys.exit(1) - return json.loads(result.stdout) +# Get all releases from a repo. +def get_releases(repo) + stdout, stderr, status = Open3.capture3( + "gh", "api", "repos/#{repo}/releases", + "--jq", "[.[] | {tag: .tag_name, prerelease: .prerelease}]" + ) + if !status.success? + abort("Error: failed to get releases for #{repo}: #{stderr}") + end + JSON.parse(stdout) +end -if __name__ == '__main__': - main() +main diff --git a/.scripts/images-additional-tests b/.scripts/images-additional-tests index e34ede5b1..52df6aa02 100755 --- a/.scripts/images-additional-tests +++ b/.scripts/images-additional-tests @@ -1,19 +1,22 @@ -#!/usr/bin/env python3 +#!/usr/bin/env ruby +# frozen_string_literal: true -import json -import sys +require "json" # Accepts as stdin a JSON object in the format of images.json. Outputs an array # of all the additional test cases defined in the images merged together. # Usage: < images.json ./.scripts/images-additional-tests -images = json.load(sys.stdin) +images = JSON.parse($stdin.read) + tests = [] -for image in images: - tag = image['tag'] - for test in image.get('additional-tests', []): - tests.append({'image': image, **test}) - for test in image.get('tests', {}).get('additional-tests', []): - tests.append({'image': image, **test}) +images.each do |image| + image.fetch("additional-tests", []).each do |test| + tests.push({ "image" => image }.merge(test)) + end + image.fetch("tests", {}).fetch("additional-tests", []).each do |test| + tests.push({ "image" => image }.merge(test)) + end +end -print(json.dumps(tests)) +puts(JSON.generate(tests)) diff --git a/.scripts/images-deps b/.scripts/images-deps index 8d1d5e776..0fcead50a 100755 --- a/.scripts/images-deps +++ b/.scripts/images-deps @@ -1,11 +1,19 @@ -#!/usr/bin/env bash +#!/usr/bin/env ruby +# frozen_string_literal: true -set -e -set -u -set -o pipefail +require "json" # Accepts as stdin a JSON object in the format of images.json. Outputs an array # of all dependencies that need to be built across all the images. # Usage: < images.json ./.scripts/images-deps -jq -c '[ .[] | .deps[] ] | unique' +images = JSON.parse($stdin.read) + +deps = [] +images.each do |image| + image["deps"].each do |dep| + deps.push(dep) + end +end + +puts(JSON.generate(deps.uniq)) diff --git a/.scripts/images-resolve-inherits b/.scripts/images-resolve-inherits index 48632ae1a..4eac0160b 100755 --- a/.scripts/images-resolve-inherits +++ b/.scripts/images-resolve-inherits @@ -1,4 +1,7 @@ -#!/usr/bin/env python3 +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" # Accepts as stdin a JSON object in the format of images.json. # Resolves any 'inherit' fields by copying deps and config from the referenced image. @@ -20,83 +23,101 @@ # < images.json ./.scripts/images-resolve-inherits # < images.json ./.scripts/images-resolve-inherits parents.json -import json -import sys - -def main(): - """Read images from stdin, resolve all inherits, and output to stdout.""" - images = json.load(sys.stdin) - - # Load parent images from file if provided - parents = [] - if len(sys.argv) > 1: - with open(sys.argv[1], 'r') as f: - parents = json.load(f) - # Resolve any inherits within the parent images first - while has_inherits(parents): - parents = resolve_inherits(parents) - - while has_inherits(images): - images = resolve_inherits(images, parents) - print(json.dumps(images, separators=(',', ':'))) - -def has_inherits(images): - """Return True if any image has an 'inherit' field.""" - return any("inherit" in img for img in images) - -def resolve_inherits(images, parents=None): - """Resolve one level of inheritance for all images. - - For each image with an 'inherit' field: - - Copy deps from the parent that aren't already defined in the child - - Merge config from the parent (child values override parent values) - - If the parent also has an 'inherit' field, copy it (for nested resolution) - - Remove the child's 'inherit' field once resolved - - Parents can be looked up from either the input images or the optional parents list. - Input images take precedence over parents with the same tag. - - Exits with error if an image inherits from an unknown tag. - """ - if parents is None: - parents = [] - lookup = build_lookup(images) - parent_lookup = build_lookup(parents) - for image in images: - if "inherit" in image: - parent_tag = image["inherit"] - # Look up in input images first, then fall back to parents - parent = lookup.get(parent_tag) or parent_lookup.get(parent_tag) - if parent is None: - print(f"Error: image '{image['tag']}' inherits from unknown image '{parent_tag}'", file=sys.stderr) - sys.exit(1) - - # Get existing dep names in child - child_dep_names = {dep["name"] for dep in image.get("deps", [])} - - # Copy deps from parent that aren't in child - inherited_deps = [dep for dep in parent.get("deps", []) if dep["name"] not in child_dep_names] - image["deps"] = inherited_deps + image.get("deps", []) - - # Merge config from parent (child values override parent) - parent_config = parent.get("config", {}) - child_config = image.get("config", {}) - if parent_config or child_config: - merged_config = {**parent_config, **child_config} - image["config"] = merged_config - - # Remove child's inherit since it has been resolved - del image["inherit"] - - # Copy parent's inherit if present (for nested resolution) - if "inherit" in parent: - image["inherit"] = parent["inherit"] - - return images - -def build_lookup(images): - """Return a dict mapping image tags to their image objects.""" - return {img["tag"]: img for img in images} - -if __name__ == "__main__": - main() +# Read images from stdin, resolve all inherits, and output to stdout. +def main + images = JSON.parse($stdin.read) + + # Load parent images from file if provided. + parents = [] + if ARGV.any? + parents = JSON.parse(File.read(ARGV[0])) + # Resolve any inherits within the parent images first. + while inherits?(parents) + parents = resolve_inherits(parents, []) + end + end + + while inherits?(images) + images = resolve_inherits(images, parents) + end + + puts(JSON.generate(images)) +end + +# Return true if any image has an 'inherit' field. +def inherits?(images) + images.any? do |image| + image.key?("inherit") + end +end + +# Resolve one level of inheritance for all images. +# +# For each image with an 'inherit' field: +# - Copy deps from the parent that aren't already defined in the child +# - Merge config from the parent (child values override parent values) +# - If the parent also has an 'inherit' field, copy it (for nested resolution) +# - Remove the child's 'inherit' field once resolved +# +# Parents can be looked up from either the input images or the parents list. +# Input images take precedence over parents with the same tag. +# +# Exits with error if an image inherits from an unknown tag. +def resolve_inherits(images, parents) + lookup = build_lookup(images) + parent_lookup = build_lookup(parents) + + images.each do |image| + if image.key?("inherit") + parent_tag = image["inherit"] + # Look up in input images first, then fall back to parents. + parent = lookup[parent_tag] || parent_lookup[parent_tag] + if parent.nil? + abort("Error: image '#{image["tag"]}' inherits from unknown image '#{parent_tag}'") + end + + # Get existing dep names in child. + child_dep_names = [] + image.fetch("deps", []).each do |dep| + child_dep_names.push(dep["name"]) + end + + # Copy deps from parent that aren't in child. + inherited_deps = [] + parent.fetch("deps", []).each do |dep| + if !child_dep_names.include?(dep["name"]) + inherited_deps.push(dep) + end + end + image["deps"] = inherited_deps + image.fetch("deps", []) + + # Merge config from parent (child values override parent). + parent_config = parent.fetch("config", {}) + child_config = image.fetch("config", {}) + if !parent_config.empty? || !child_config.empty? + image["config"] = parent_config.merge(child_config) + end + + # Remove child's inherit since it has been resolved. + image.delete("inherit") + + # Copy parent's inherit if present (for nested resolution). + if parent.key?("inherit") + image["inherit"] = parent["inherit"] + end + end + end + + images +end + +# Return a hash mapping image tags to their image objects. +def build_lookup(images) + lookup = {} + images.each do |image| + lookup[image["tag"]] = image + end + lookup +end + +main diff --git a/.scripts/images-with-extras b/.scripts/images-with-extras index 9b9ed3b8b..aa5f98c85 100755 --- a/.scripts/images-with-extras +++ b/.scripts/images-with-extras @@ -1,14 +1,14 @@ -#!/usr/bin/env python3 +#!/usr/bin/env ruby +# frozen_string_literal: true -import json -import sys -import subprocess -import hashlib +require "json" +require "open3" +require "digest" # Accepts as stdin a JSON object in the format of images.json. # And adds some calculatble elements used during the build. # -# 1. Resolves any 'ref' values in the JSON to a revision sha. +# 1. Resolves any 'ref' values in the JSON to a revision sha. # 2. Hashes the entire dep details and injects the hash as an id into the deps # details. The id can be used to uniquely identify a dep configuration. # @@ -16,34 +16,36 @@ import hashlib # # Usage: < images.json ./.scripts/images-with-extras -images = json.load(sys.stdin) +images = JSON.parse($stdin.read) cache = {} -for image in images: - tag = image["tag"] - for dep in image["deps"]: - name = dep["name"] - repo = dep["repo"] - ref = dep["ref"] - print(f"{tag} {name} {repo} {ref} ...", file=sys.stderr) - key = (name, repo, ref) - if key in cache: - sha = cache[key] - else: - sha = subprocess.run( - ["gh", "api", f"repos/{repo}/commits/{ref}", "--jq", ".sha"], - capture_output=True, - text=True, - check=True - ).stdout.strip() - cache[key] = sha - print(f" • revision sha = {sha}", file=sys.stderr) - dep["sha"] = sha - - dep_str = json.dumps(dep, separators=(',', ':')) - id = hashlib.sha256(dep_str.encode()).hexdigest() - dep["id"] = id - print(f" • id = {id}", file=sys.stderr) - -print(json.dumps(images, separators=(',', ':'))) +images.each do |image| + tag = image["tag"] + image["deps"].each do |dep| + name = dep["name"] + repo = dep["repo"] + ref = dep["ref"] + warn("#{tag} #{name} #{repo} #{ref} ...") + + key = [name, repo, ref] + if cache.key?(key) + sha = cache[key] + else + stdout, stderr, status = Open3.capture3("gh", "api", "repos/#{repo}/commits/#{ref}", "--jq", ".sha") + if !status.success? + abort("Error: failed to get commit for #{repo}/#{ref}: #{stderr}") + end + sha = stdout.strip + cache[key] = sha + end + warn(" • revision sha = #{sha}") + dep["sha"] = sha + + dep_id = Digest::SHA256.hexdigest(JSON.generate(dep)) + dep["id"] = dep_id + warn(" • id = #{dep_id}") + end +end + +puts(JSON.generate(images)) diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..7ae700656 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "rubocop", "~> 1.60" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..09c4e1d08 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,42 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + json (2.19.7) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.28.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + regexp_parser (2.12.0) + rubocop (1.86.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + aarch64-linux + +DEPENDENCIES + rubocop (~> 1.60) + +BUNDLED WITH + 2.4.19 diff --git a/Makefile b/Makefile index a8e6083b8..ce75a2620 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -__PHONY__: run logs console build build-deps build-deps-xdr build-deps-core build-deps-horizon build-deps-friendbot build-deps-rpc build-deps-lab test +__PHONY__: run logs console build build-deps build-deps-xdr build-deps-core build-deps-horizon build-deps-friendbot build-deps-rpc build-deps-lab test lint CONTAINER_RUNTIME?=docker REVISION=$(shell git -c core.abbrev=no describe --always --exclude='*' --long --dirty) @@ -59,3 +59,11 @@ test: go run tests/test_friendbot.go go run tests/test_stellar_rpc_up.go go run tests/test_stellar_rpc_healthy.go + +# Lint the Ruby scripts in .scripts/ with rubocop. +lint: + $(CONTAINER_RUNTIME) run --rm \ + -v "$(CURDIR)":/work -w /work \ + -v quickstart-bundle:/usr/local/bundle \ + ruby:3.2 \ + sh -c "bundle install && bundle exec rubocop" From 85c7a5d26f8814f58f91726359b546905856e2b7 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Sun, 31 May 2026 18:35:29 +1000 Subject: [PATCH 2/2] go full ruby --- .rubocop.yml | 58 ++------------- .rubocop/no_modifier_conditional.rb | 19 ----- .rubocop/no_unless.rb | 19 ----- .scripts/auto-update | 61 ++++++---------- .scripts/images-additional-tests | 13 +--- .scripts/images-deps | 11 +-- .scripts/images-resolve-inherits | 105 ++++++++-------------------- .scripts/images-with-extras | 52 ++++++-------- 8 files changed, 80 insertions(+), 258 deletions(-) delete mode 100644 .rubocop/no_modifier_conditional.rb delete mode 100644 .rubocop/no_unless.rb diff --git a/.rubocop.yml b/.rubocop.yml index c145dff38..8244b36e7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,11 +1,7 @@ -# RuboCop is configured to keep the scripts in .scripts/ written in plain, -# predictable Ruby. It disallows the Ruby-isms that people who don't write -# Ruby every day find surprising: method calls without parentheses, `unless`, -# and trailing (modifier) `if`/`unless`. - -require: - - ./.rubocop/no_unless.rb - - ./.rubocop/no_modifier_conditional.rb +# RuboCop keeps the scripts in .scripts/ written in concise, idiomatic Ruby. +# Method calls still use parentheses (no bracket-less calls), but otherwise the +# scripts lean on Ruby's terse forms: guard clauses, modifier if/unless, and +# Enumerable methods. AllCops: TargetRubyVersion: 3.2 @@ -15,59 +11,13 @@ AllCops: - ".scripts/*" - "**/*.rb" Exclude: - - ".rubocop/**/*" - "Gemfile" -# --- Rules we explicitly want enforced --- - # Always use parentheses around method-call arguments (no "bracket-less" calls). Style/MethodCallWithArgsParentheses: Enabled: true EnforcedStyle: require_parentheses -# Our custom cops (defined in .rubocop/) that ban surprising Ruby-isms. -Local/NoUnless: - Enabled: true - -Local/NoModifierConditional: - Enabled: true - -# --- Cops disabled because they would push code back toward the forms above --- - -# Would demand a trailing `if`/`unless` for short statements. -Style/IfUnlessModifier: - Enabled: false - -# Would demand a trailing `while`/`until` for short loops. -Style/WhileUntilModifier: - Enabled: false - -# Would rewrite `if !x` into `unless x`. -Style/NegatedIf: - Enabled: false - -# Guard clauses are written as trailing `if`/`unless`. -Style/GuardClause: - Enabled: false - -# `next if ...` is a trailing conditional. -Style/Next: - Enabled: false - -# --- Keep things plain rather than "clever" --- - -# Allow ["a", "b"] rather than requiring %w[a b]. -Style/WordArray: - Enabled: false - -Style/SymbolArray: - Enabled: false - -# Allow building an array with a plain `each` + `push` loop rather than -# requiring `map`/`flat_map`/`each_with_object`. -Style/MapIntoArray: - Enabled: false - # Use double quotes everywhere so quoting is one less thing to think about. Style/StringLiterals: EnforcedStyle: double_quotes diff --git a/.rubocop/no_modifier_conditional.rb b/.rubocop/no_modifier_conditional.rb deleted file mode 100644 index b492a7b36..000000000 --- a/.rubocop/no_modifier_conditional.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module RuboCop - module Cop - module Local - # Disallows trailing (modifier) `if`/`unless`, e.g. `do_thing if cond`. - # Conditionals must be written as ordinary multi-line statements. - class NoModifierConditional < RuboCop::Cop::Base - MSG = "Do not use a trailing `if`/`unless`; write it as a multi-line statement." - - def on_if(node) - return unless node.modifier_form? - - add_offense(node.loc.keyword) - end - end - end - end -end diff --git a/.rubocop/no_unless.rb b/.rubocop/no_unless.rb deleted file mode 100644 index 589be4df1..000000000 --- a/.rubocop/no_unless.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module RuboCop - module Cop - module Local - # Disallows `unless`. Use `if` with a negated condition instead, which - # reads more predictably for people who don't write Ruby every day. - class NoUnless < RuboCop::Cop::Base - MSG = "Do not use `unless`; use `if` with a negated condition." - - def on_if(node) - return unless node.unless? - - add_offense(node.loc.keyword) - end - end - end - end -end diff --git a/.scripts/auto-update b/.scripts/auto-update index e8fea3bcc..460f3a60c 100755 --- a/.scripts/auto-update +++ b/.scripts/auto-update @@ -12,40 +12,28 @@ require "open3" # For 'rc': uses the newest release (prerelease or stable) def main - if ARGV.length != 2 || !["stable", "rc"].include?(ARGV[1]) - abort("Usage: auto-update ") - end - - image_tag = ARGV[0] - mode = ARGV[1] + abort("Usage: auto-update ") unless ARGV.length == 2 && %w[stable rc].include?(ARGV[1]) + image_tag, mode = ARGV images = JSON.parse(File.read("images.json")) - - # Find target image. - target = images.find do |image| - image["tag"] == image_tag - end - if target.nil? - abort("Error: image '#{image_tag}' not found") - end + target = images.find { |image| image["tag"] == image_tag } + abort("Error: image '#{image_tag}' not found") unless target updated = false target["deps"].each do |dep| - name = dep["name"] - repo = dep["repo"] - current = dep["ref"] + name, repo, ref = dep.values_at("name", "repo", "ref") # Skip non-release refs (branches, SHAs). - if !current.start_with?("v") + unless ref.start_with?("v") puts("#{name}: skipping, ref is not a version") next end - latest = get_latest_release(repo, mode == "rc") + latest = latest_release(repo, mode == "rc") if latest.nil? puts("#{name}: skipping, no release found") - elsif latest != current - puts("#{name}: #{current} -> #{latest}") + elsif latest != ref + puts("#{name}: #{ref} -> #{latest}") dep["ref"] = latest updated = true else @@ -53,33 +41,24 @@ def main end end - if updated - File.write("images.json", "#{JSON.pretty_generate(images)}\n") - end + File.write("images.json", "#{JSON.pretty_generate(images)}\n") if updated end -# Get the latest release. If include_prerelease is false, skip prereleases. -def get_latest_release(repo, include_prerelease) - get_releases(repo).each do |rel| - if include_prerelease && rel["tag"].include?("rc") - return rel["tag"] - end - if !rel["prerelease"] - return rel["tag"] - end +# Get the latest release tag, or nil if there is none. When include_prerelease +# is false, prereleases are skipped. +def latest_release(repo, include_prerelease) + release = releases(repo).find do |rel| + (include_prerelease && rel["tag"].include?("rc")) || !rel["prerelease"] end - nil + release&.fetch("tag") end -# Get all releases from a repo. -def get_releases(repo) +# Get all releases from a repo, newest first. +def releases(repo) stdout, stderr, status = Open3.capture3( - "gh", "api", "repos/#{repo}/releases", - "--jq", "[.[] | {tag: .tag_name, prerelease: .prerelease}]" + "gh", "api", "repos/#{repo}/releases", "--jq", "[.[] | {tag: .tag_name, prerelease: .prerelease}]" ) - if !status.success? - abort("Error: failed to get releases for #{repo}: #{stderr}") - end + abort("Error: failed to get releases for #{repo}: #{stderr}") unless status.success? JSON.parse(stdout) end diff --git a/.scripts/images-additional-tests b/.scripts/images-additional-tests index 52df6aa02..a4d9cf2cb 100755 --- a/.scripts/images-additional-tests +++ b/.scripts/images-additional-tests @@ -8,15 +8,8 @@ require "json" # Usage: < images.json ./.scripts/images-additional-tests images = JSON.parse($stdin.read) - -tests = [] -images.each do |image| - image.fetch("additional-tests", []).each do |test| - tests.push({ "image" => image }.merge(test)) - end - image.fetch("tests", {}).fetch("additional-tests", []).each do |test| - tests.push({ "image" => image }.merge(test)) - end +tests = images.flat_map do |image| + defined_tests = image.fetch("additional-tests", []) + image.fetch("tests", {}).fetch("additional-tests", []) + defined_tests.map { |test| { "image" => image }.merge(test) } end - puts(JSON.generate(tests)) diff --git a/.scripts/images-deps b/.scripts/images-deps index 0fcead50a..4759b29f8 100755 --- a/.scripts/images-deps +++ b/.scripts/images-deps @@ -8,12 +8,5 @@ require "json" # Usage: < images.json ./.scripts/images-deps images = JSON.parse($stdin.read) - -deps = [] -images.each do |image| - image["deps"].each do |dep| - deps.push(dep) - end -end - -puts(JSON.generate(deps.uniq)) +deps = images.flat_map { |image| image["deps"] }.uniq +puts(JSON.generate(deps)) diff --git a/.scripts/images-resolve-inherits b/.scripts/images-resolve-inherits index 4eac0160b..64cf51d9f 100755 --- a/.scripts/images-resolve-inherits +++ b/.scripts/images-resolve-inherits @@ -23,101 +23,52 @@ require "json" # < images.json ./.scripts/images-resolve-inherits # < images.json ./.scripts/images-resolve-inherits parents.json -# Read images from stdin, resolve all inherits, and output to stdout. def main images = JSON.parse($stdin.read) - - # Load parent images from file if provided. - parents = [] - if ARGV.any? - parents = JSON.parse(File.read(ARGV[0])) - # Resolve any inherits within the parent images first. - while inherits?(parents) - parents = resolve_inherits(parents, []) - end - end - - while inherits?(images) - images = resolve_inherits(images, parents) - end - + parents = ARGV.any? ? JSON.parse(File.read(ARGV[0])) : [] + parents = resolve_inherits(parents, []) while inherits?(parents) + images = resolve_inherits(images, parents) while inherits?(images) puts(JSON.generate(images)) end -# Return true if any image has an 'inherit' field. -def inherits?(images) - images.any? do |image| - image.key?("inherit") - end -end - -# Resolve one level of inheritance for all images. -# -# For each image with an 'inherit' field: -# - Copy deps from the parent that aren't already defined in the child -# - Merge config from the parent (child values override parent values) -# - If the parent also has an 'inherit' field, copy it (for nested resolution) -# - Remove the child's 'inherit' field once resolved -# -# Parents can be looked up from either the input images or the parents list. -# Input images take precedence over parents with the same tag. -# -# Exits with error if an image inherits from an unknown tag. +# Resolve one level of inheritance for all images. For each image with an +# 'inherit' field, copy the parent's deps that aren't already defined in the +# child, merge config (child overrides parent), and carry over the parent's own +# 'inherit' for nested resolution. Parents are looked up in the input images +# first, then in the optional parents list. Exits if a tag is unknown. def resolve_inherits(images, parents) - lookup = build_lookup(images) - parent_lookup = build_lookup(parents) + lookup = lookup_by_tag(images) + parent_lookup = lookup_by_tag(parents) images.each do |image| - if image.key?("inherit") - parent_tag = image["inherit"] - # Look up in input images first, then fall back to parents. - parent = lookup[parent_tag] || parent_lookup[parent_tag] - if parent.nil? - abort("Error: image '#{image["tag"]}' inherits from unknown image '#{parent_tag}'") - end + next unless image.key?("inherit") - # Get existing dep names in child. - child_dep_names = [] - image.fetch("deps", []).each do |dep| - child_dep_names.push(dep["name"]) - end + parent_tag = image["inherit"] + parent = lookup[parent_tag] || parent_lookup[parent_tag] + abort("Error: image '#{image["tag"]}' inherits from unknown image '#{parent_tag}'") unless parent - # Copy deps from parent that aren't in child. - inherited_deps = [] - parent.fetch("deps", []).each do |dep| - if !child_dep_names.include?(dep["name"]) - inherited_deps.push(dep) - end - end - image["deps"] = inherited_deps + image.fetch("deps", []) + child_dep_names = image.fetch("deps", []).map { |dep| dep["name"] } + inherited_deps = parent.fetch("deps", []).reject { |dep| child_dep_names.include?(dep["name"]) } + image["deps"] = inherited_deps + image.fetch("deps", []) - # Merge config from parent (child values override parent). - parent_config = parent.fetch("config", {}) - child_config = image.fetch("config", {}) - if !parent_config.empty? || !child_config.empty? - image["config"] = parent_config.merge(child_config) - end + merged_config = parent.fetch("config", {}).merge(image.fetch("config", {})) + image["config"] = merged_config unless merged_config.empty? - # Remove child's inherit since it has been resolved. - image.delete("inherit") - - # Copy parent's inherit if present (for nested resolution). - if parent.key?("inherit") - image["inherit"] = parent["inherit"] - end - end + image.delete("inherit") + image["inherit"] = parent["inherit"] if parent.key?("inherit") end images end +# Return true if any image has an 'inherit' field. +def inherits?(images) + images.any? { |image| image.key?("inherit") } +end + # Return a hash mapping image tags to their image objects. -def build_lookup(images) - lookup = {} - images.each do |image| - lookup[image["tag"]] = image - end - lookup +def lookup_by_tag(images) + images.to_h { |image| [image["tag"], image] } end main diff --git a/.scripts/images-with-extras b/.scripts/images-with-extras index aa5f98c85..5643bc321 100755 --- a/.scripts/images-with-extras +++ b/.scripts/images-with-extras @@ -16,36 +16,30 @@ require "digest" # # Usage: < images.json ./.scripts/images-with-extras -images = JSON.parse($stdin.read) - -cache = {} - -images.each do |image| - tag = image["tag"] - image["deps"].each do |dep| - name = dep["name"] - repo = dep["repo"] - ref = dep["ref"] - warn("#{tag} #{name} #{repo} #{ref} ...") - - key = [name, repo, ref] - if cache.key?(key) - sha = cache[key] - else - stdout, stderr, status = Open3.capture3("gh", "api", "repos/#{repo}/commits/#{ref}", "--jq", ".sha") - if !status.success? - abort("Error: failed to get commit for #{repo}/#{ref}: #{stderr}") - end - sha = stdout.strip - cache[key] = sha +def main + images = JSON.parse($stdin.read) + cache = {} + + images.each do |image| + tag = image["tag"] + image["deps"].each do |dep| + name, repo, ref = dep.values_at("name", "repo", "ref") + warn("#{tag} #{name} #{repo} #{ref} ...") + dep["sha"] = cache[[repo, ref]] ||= commit_sha(repo, ref) + warn(" • revision sha = #{dep["sha"]}") + dep["id"] = Digest::SHA256.hexdigest(JSON.generate(dep)) + warn(" • id = #{dep["id"]}") end - warn(" • revision sha = #{sha}") - dep["sha"] = sha - - dep_id = Digest::SHA256.hexdigest(JSON.generate(dep)) - dep["id"] = dep_id - warn(" • id = #{dep_id}") end + + puts(JSON.generate(images)) +end + +# Resolve a repo ref to its commit sha via the GitHub API. +def commit_sha(repo, ref) + stdout, stderr, status = Open3.capture3("gh", "api", "repos/#{repo}/commits/#{ref}", "--jq", ".sha") + abort("Error: failed to get commit for #{repo}/#{ref}: #{stderr}") unless status.success? + stdout.strip end -puts(JSON.generate(images)) +main