Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@
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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: 1 setup
runs-on: ubuntu-latest
outputs:
Expand Down
34 changes: 34 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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
NewCops: enable
SuggestExtensions: false
Include:
- ".scripts/*"
- "**/*.rb"
Exclude:
- "Gemfile"

# Always use parentheses around method-call arguments (no "bracket-less" calls).
Style/MethodCallWithArgsParentheses:
Enabled: true
EnforcedStyle: require_parentheses

# 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
106 changes: 48 additions & 58 deletions .scripts/auto-update
Original file line number Diff line number Diff line change
@@ -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
#
Expand All @@ -11,65 +11,55 @@ 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 <image-tag> <stable|rc>", file=sys.stderr)
sys.exit(1)
def main
abort("Usage: auto-update <image-tag> <stable|rc>") unless ARGV.length == 2 && %w[stable rc].include?(ARGV[1])
image_tag, mode = ARGV

image_tag, mode = sys.argv[1], sys.argv[2]
images = JSON.parse(File.read("images.json"))
target = images.find { |image| image["tag"] == image_tag }
abort("Error: image '#{image_tag}' not found") unless target

with open('images.json', 'r') as f:
images = json.load(f)
updated = false
target["deps"].each do |dep|
name, repo, ref = dep.values_at("name", "repo", "ref")

# 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)
# Skip non-release refs (branches, SHAs).
unless ref.start_with?("v")
puts("#{name}: skipping, ref is not a version")
next
end

updated = False
for dep in target['deps']:
name, repo, current = dep['name'], dep['repo'], dep['ref']
latest = latest_release(repo, mode == "rc")
if latest.nil?
puts("#{name}: skipping, no release found")
elsif latest != ref
puts("#{name}: #{ref} -> #{latest}")
dep["ref"] = latest
updated = true
else
puts("#{name}: matches latest release")
end
end

# Skip non-release refs (branches, SHAs)
if not current.startswith('v'):
print(f"{name}: skipping, ref is not a version")
continue
File.write("images.json", "#{JSON.pretty_generate(images)}\n") if updated
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")
# 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
release&.fetch("tag")
end

if updated:
with open('images.json', 'w') as f:
json.dump(images, f, indent=2)
f.write('\n')
# 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}]"
)
abort("Error: failed to get releases for #{repo}: #{stderr}") unless status.success?
JSON.parse(stdout)
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

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)

if __name__ == '__main__':
main()
main
22 changes: 9 additions & 13 deletions .scripts/images-additional-tests
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
#!/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)
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})

print(json.dumps(tests))
images = JSON.parse($stdin.read)
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))
11 changes: 6 additions & 5 deletions .scripts/images-deps
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/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.flat_map { |image| image["deps"] }.uniq
puts(JSON.generate(deps))
134 changes: 53 additions & 81 deletions .scripts/images-resolve-inherits
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -20,83 +23,52 @@
# < 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()
def main
images = JSON.parse($stdin.read)
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

# 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 = lookup_by_tag(images)
parent_lookup = lookup_by_tag(parents)

images.each do |image|
next unless image.key?("inherit")

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

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", [])

merged_config = parent.fetch("config", {}).merge(image.fetch("config", {}))
image["config"] = merged_config unless merged_config.empty?

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 lookup_by_tag(images)
images.to_h { |image| [image["tag"], image] }
end

main
Loading
Loading