diff --git a/.github/actions/build-and-publish-image/action.yml b/.github/actions/build-and-publish-image/action.yml index 3ae2cf4..75a2410 100644 --- a/.github/actions/build-and-publish-image/action.yml +++ b/.github/actions/build-and-publish-image/action.yml @@ -1,9 +1,19 @@ name: Build and Publish Image description: Steps for building an image for a specific ruby version inputs: + publish_manifest: + required: false + default: "false" + description: "Set to true to publish a multi-arch manifest instead of building" ruby_version: required: true description: "The version of Ruby to build the image for" + build_platform: + required: false + description: "The single platform to build (linux/amd64 or linux/arm64)" + platform_suffix: + required: false + description: "Platform suffix for arch-specific tags (amd64 or arm64)" image_tag: required: true description: "The tag to use for the image" @@ -16,18 +26,31 @@ inputs: runs: using: "composite" steps: + - name: Validate required inputs + shell: bash + run: | + if [[ "${{ inputs.publish_manifest }}" != "true" ]]; then + if [[ -z "${{ inputs.build_platform }}" || -z "${{ inputs.platform_suffix }}" ]]; then + echo "build_platform and platform_suffix are required when publish_manifest is false" + exit 1 + fi + fi + - name: Checkout (GitHub) + if: ${{ inputs.publish_manifest != 'true' }} uses: actions/checkout@v6 with: ref: ${{ inputs.image_tag }} - - name: Set up QEMU for multi-architecture builds - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx + if: ${{ inputs.publish_manifest != 'true' }} uses: docker/setup-buildx-action@v3 with: - platforms: linux/amd64,linux/arm64 + platforms: ${{ inputs.build_platform }} + + - name: Set up Docker Buildx (manifest) + if: ${{ inputs.publish_manifest == 'true' }} + uses: docker/setup-buildx-action@v3 - name: Set Image version env variable run: echo "IMAGE_VERSION=$(echo ${{ inputs.image_tag }} | tr -d ruby-)" >> $GITHUB_ENV @@ -41,16 +64,25 @@ runs: password: ${{ inputs.gh_token }} - name: Pre-build Dev Container Image + if: ${{ inputs.publish_manifest != 'true' }} uses: devcontainers/ci@v0.3 env: RUBY_VERSION: ${{ inputs.ruby_version }} BUILDX_NO_DEFAULT_ATTESTATIONS: true with: imageName: ghcr.io/rails/devcontainer/images/ruby - imageTag: ${{ env.IMAGE_VERSION }}-${{ inputs.ruby_version }},${{ inputs.ruby_version }} + imageTag: ${{ env.IMAGE_VERSION }}-${{ inputs.ruby_version }}-${{ inputs.platform_suffix }},${{ inputs.ruby_version }}-${{ inputs.platform_suffix }} + cacheFrom: ghcr.io/rails/devcontainer/images/ruby:${{ inputs.ruby_version }}-${{ inputs.platform_suffix }} subFolder: images/ruby push: always - platform: linux/amd64,linux/arm64 + platform: ${{ inputs.build_platform }} - - name: Checkout (GitHub) - uses: actions/checkout@v6 + - name: Create and push multi-arch manifest + if: ${{ inputs.publish_manifest == 'true' }} + shell: bash + run: | + docker buildx imagetools create \ + -t ghcr.io/rails/devcontainer/images/ruby:${IMAGE_VERSION}-${{ inputs.ruby_version }} \ + -t ghcr.io/rails/devcontainer/images/ruby:${{ inputs.ruby_version }} \ + ghcr.io/rails/devcontainer/images/ruby:${IMAGE_VERSION}-${{ inputs.ruby_version }}-amd64 \ + ghcr.io/rails/devcontainer/images/ruby:${IMAGE_VERSION}-${{ inputs.ruby_version }}-arm64 diff --git a/.github/workflows/publish-images-reusable.yaml b/.github/workflows/publish-images-reusable.yaml new file mode 100644 index 0000000..4dee05f --- /dev/null +++ b/.github/workflows/publish-images-reusable.yaml @@ -0,0 +1,77 @@ +name: Publish Images Reusable + +on: + workflow_call: + inputs: + ruby_versions_json: + required: true + type: string + description: JSON array of Ruby versions + image_versions_json: + required: true + type: string + description: JSON array of image tags + repository_owner: + required: true + type: string + description: Repository owner for GHCR login + secrets: + gh_token: + required: true + +defaults: + run: + shell: bash + +jobs: + build: + name: Build Images + strategy: + fail-fast: false + matrix: + RUBY_VERSION: ${{ fromJSON(inputs.ruby_versions_json) }} + IMAGE_VERSION: ${{ fromJSON(inputs.image_versions_json) }} + TARGET: + - RUNNER: ubuntu-24.04 + BUILD_PLATFORM: linux/amd64 + PLATFORM_SUFFIX: amd64 + - RUNNER: ubuntu-24.04-arm + BUILD_PLATFORM: linux/arm64 + PLATFORM_SUFFIX: arm64 + + runs-on: ${{ matrix.TARGET.RUNNER }} + permissions: + contents: read + packages: write + steps: + - name: Build and Publish Image + uses: ./.github/actions/build-and-publish-image + with: + ruby_version: ${{ matrix.RUBY_VERSION }} + build_platform: ${{ matrix.TARGET.BUILD_PLATFORM }} + platform_suffix: ${{ matrix.TARGET.PLATFORM_SUFFIX }} + image_tag: ${{ matrix.IMAGE_VERSION }} + gh_token: ${{ secrets.gh_token }} + repository_owner: ${{ inputs.repository_owner }} + + publish-manifests: + name: Publish Multi-Arch Manifests + needs: build + strategy: + fail-fast: false + matrix: + RUBY_VERSION: ${{ fromJSON(inputs.ruby_versions_json) }} + IMAGE_VERSION: ${{ fromJSON(inputs.image_versions_json) }} + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - name: Publish Multi-Arch Manifest + uses: ./.github/actions/build-and-publish-image + with: + publish_manifest: "true" + ruby_version: ${{ matrix.RUBY_VERSION }} + image_tag: ${{ matrix.IMAGE_VERSION }} + gh_token: ${{ secrets.gh_token }} + repository_owner: ${{ inputs.repository_owner }} diff --git a/.github/workflows/publish-new-image-version.yaml b/.github/workflows/publish-new-image-version.yaml index a7d34f4..f0c7775 100644 --- a/.github/workflows/publish-new-image-version.yaml +++ b/.github/workflows/publish-new-image-version.yaml @@ -5,7 +5,7 @@ name: Build and Publish Images - ruby-*.*.* jobs: setup: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: @@ -13,24 +13,16 @@ jobs: - id: set-matrix run: echo "matrix=$(cat .github/ruby-versions.json | jq -c '.')" >> $GITHUB_OUTPUT - build: - name: Build Images + publish: + name: Publish Images needs: setup - strategy: - fail-fast: false - matrix: - RUBY_VERSION: ${{ fromJSON(needs.setup.outputs.matrix) }} - runs-on: ubuntu-latest + uses: ./.github/workflows/publish-images-reusable.yaml + with: + ruby_versions_json: ${{ needs.setup.outputs.matrix }} + image_versions_json: ${{ format('["{0}"]', github.ref_name) }} + repository_owner: ${{ github.repository_owner }} + secrets: + gh_token: ${{ secrets.GITHUB_TOKEN }} permissions: contents: read packages: write - steps: - - name: Checkout (GitHub) - uses: actions/checkout@v6 - - name: Build and Publish Image - uses: ./.github/actions/build-and-publish-image - with: - ruby_version: ${{ matrix.RUBY_VERSION }} - image_tag: ${{ github.ref_name }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - repository_owner: ${{ github.repository_owner }} diff --git a/.github/workflows/publish-new-ruby-versions.yaml b/.github/workflows/publish-new-ruby-versions.yaml index 94a3cc0..d5b502f 100644 --- a/.github/workflows/publish-new-ruby-versions.yaml +++ b/.github/workflows/publish-new-ruby-versions.yaml @@ -6,34 +6,45 @@ on: ruby_versions: type: string required: true - description: List of ruby versions to build. Should be an array ["3.3.1","3.2.4"] + description: Comma or newline-separated Ruby versions (example: 3.3.1, 3.2.4) image_versions: type: string - required: true - description: List of image versions to build. Should be an array ["ruby-1.1.0"] + required: false + description: Optional comma or newline-separated image versions (example: 1.1.0, 1.2.0 or ruby-1.1.0). If empty, latest ruby-* tag is used. jobs: - build: - name: Build Images - - strategy: - fail-fast: false - matrix: - RUBY_VERSION: ${{ fromJSON(github.event.inputs.ruby_versions)}} - IMAGE_VERSION: ${{ fromJSON(github.event.inputs.image_versions)}} - - runs-on: ubuntu-latest - permissions: - contents: read - packages: write + setup: + name: Normalize Inputs + runs-on: ubuntu-24.04 + outputs: + ruby_versions_json: ${{ steps.normalize.outputs.ruby_versions_json }} + image_versions_json: ${{ steps.normalize.outputs.image_versions_json }} steps: - name: Checkout (GitHub) uses: actions/checkout@v6 - - name: Build and Publish Image - uses: ./.github/actions/build-and-publish-image + - name: Set up Ruby + uses: ruby/setup-ruby@v1 with: - ruby_version: ${{ matrix.RUBY_VERSION }} - image_tag: ${{ matrix.IMAGE_VERSION }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - repository_owner: ${{ github.repository_owner }} + ruby-version: .ruby-version + + - id: normalize + env: + RUBY_VERSIONS: ${{ github.event.inputs.ruby_versions }} + IMAGE_VERSIONS: ${{ github.event.inputs.image_versions }} + REPOSITORY: ${{ github.repository }} + run: bin/normalize-publish-inputs + + publish: + name: Publish Images + needs: setup + uses: ./.github/workflows/publish-images-reusable.yaml + with: + ruby_versions_json: ${{ needs.setup.outputs.ruby_versions_json }} + image_versions_json: ${{ needs.setup.outputs.image_versions_json }} + repository_owner: ${{ github.repository_owner }} + secrets: + gh_token: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write diff --git a/bin/normalize-publish-inputs b/bin/normalize-publish-inputs new file mode 100755 index 0000000..03e0d47 --- /dev/null +++ b/bin/normalize-publish-inputs @@ -0,0 +1,68 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "optparse" +require_relative "../lib/commands/publish_input_normalizer" + +options = { + ruby_versions: ENV["RUBY_VERSIONS"], + image_versions: ENV["IMAGE_VERSIONS"] || "", + repository: ENV["REPOSITORY"] || ENV["GITHUB_REPOSITORY"] +} + +parser = OptionParser.new do |opts| + opts.banner = "Usage: ruby bin/normalize-publish-inputs [--ruby-versions LIST] [--image-versions LIST] [--repository OWNER/REPO]" + + opts.on("--ruby-versions VALUE", "Comma/newline-separated Ruby versions") do |value| + options[:ruby_versions] = value + end + + opts.on("--image-versions VALUE", "Optional comma/newline-separated image versions") do |value| + options[:image_versions] = value + end + + opts.on("--repository VALUE", "Repository in OWNER/REPO format (defaults to GITHUB_REPOSITORY)") do |value| + options[:repository] = value + end + + opts.on("-h", "--help", "Show this help") do + puts opts + exit 0 + end +end + +begin + parser.parse! + + unless options[:ruby_versions] && !options[:ruby_versions].strip.empty? + warn "--ruby-versions is required" + warn parser + exit 1 + end + + result = Commands::PublishInputNormalizer.call( + ruby_versions_input: options[:ruby_versions], + image_versions_input: options[:image_versions], + repository: options[:repository] + ) + + if ENV["GITHUB_OUTPUT"] + File.open(ENV["GITHUB_OUTPUT"], "a") do |f| + f.puts "ruby_versions_json=#{result[:ruby_versions_json]}" + f.puts "image_versions_json=#{result[:image_versions_json]}" + end + else + puts JSON.pretty_generate({ + ruby_versions_json: result[:ruby_versions_json], + image_versions_json: result[:image_versions_json] + }) + end +rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e + warn e.message + warn parser + exit 1 +rescue Commands::PublishInputNormalizer::Error => e + warn e.message + exit 1 +end diff --git a/lib/commands/publish_input_normalizer.rb b/lib/commands/publish_input_normalizer.rb new file mode 100644 index 0000000..6a00495 --- /dev/null +++ b/lib/commands/publish_input_normalizer.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "json" +require "open3" +require "rubygems" + +module Commands + class PublishInputNormalizer + class Error < StandardError; end + + class << self + def call(ruby_versions_input:, image_versions_input:, repository:, latest_tag_fetcher: nil) + new( + ruby_versions_input: ruby_versions_input, + image_versions_input: image_versions_input, + repository: repository, + latest_tag_fetcher: latest_tag_fetcher + ).call + end + end + + attr_reader :ruby_versions_input, :image_versions_input, :repository, :latest_tag_fetcher + + def initialize(ruby_versions_input:, image_versions_input:, repository:, latest_tag_fetcher: nil) + @ruby_versions_input = ruby_versions_input.to_s + @image_versions_input = image_versions_input.to_s + @repository = repository.to_s + @latest_tag_fetcher = latest_tag_fetcher || method(:fetch_latest_image_tag) + end + + def call + ruby_versions = normalize_list(ruby_versions_input) + raise Error, "Ruby versions input is empty" if ruby_versions.empty? + + images_source = image_versions_input + if blank_list?(images_source) + images_source = latest_tag_fetcher.call(repository) + end + + image_versions = normalize_image_versions(normalize_list(images_source)) + raise Error, "Image versions input is empty" if image_versions.empty? + + { + ruby_versions: ruby_versions, + image_versions: image_versions, + ruby_versions_json: JSON.generate(ruby_versions), + image_versions_json: JSON.generate(image_versions) + } + end + + private + + def blank_list?(value) + value.to_s.gsub(/[\s,]/, "").empty? + end + + def normalize_list(value) + value + .to_s + .split(/[\n,]/) + .map(&:strip) + .reject(&:empty?) + end + + def normalize_image_versions(versions) + versions.map { |version| version.start_with?("ruby-") ? version : "ruby-#{version}" } + end + + def fetch_latest_image_tag(repo) + raise Error, "Repository is required to discover latest image tag" if repo.empty? + + stdout, status = Open3.capture2( + "git", + "ls-remote", + "--tags", + "--refs", + "https://github.com/#{repo}.git", + "refs/tags/ruby-*" + ) + + unless status.success? + raise Error, "Unable to resolve latest ruby-* image tag" + end + + tags = stdout.lines.map do |line| + ref = line.split[1] + ref&.sub("refs/tags/", "") + end.compact + + latest = tags.max_by { |tag| version_for(tag) } + raise Error, "Unable to resolve latest ruby-* image tag" unless latest + + latest + end + + def version_for(tag) + raw = tag.sub(/^ruby-/, "") + Gem::Version.new(raw) + rescue ArgumentError + Gem::Version.new("0") + end + end +end diff --git a/test/commands/normalize_publish_inputs_script_test.rb b/test/commands/normalize_publish_inputs_script_test.rb new file mode 100644 index 0000000..53d5b2c --- /dev/null +++ b/test/commands/normalize_publish_inputs_script_test.rb @@ -0,0 +1,78 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "minitest/autorun" +require "open3" +require "rbconfig" +require "tempfile" + +class NormalizePublishInputsScriptTest < Minitest::Test + def test_reads_values_from_env_when_args_are_omitted + output = Tempfile.new("github-output") + + status = run_script( + { + "RUBY_VERSIONS" => "3.3.1,3.2.4", + "IMAGE_VERSIONS" => "ruby-1.1.0", + "REPOSITORY" => "rails/devcontainer", + "GITHUB_OUTPUT" => output.path + } + ) + + assert status.success? + assert_equal '["3.3.1","3.2.4"]', parse_output(output.path).fetch("ruby_versions_json") + ensure + output&.close! + end + + def test_prefers_args_over_env_values + output = Tempfile.new("github-output") + + status = run_script( + { + "RUBY_VERSIONS" => "3.0.0", + "IMAGE_VERSIONS" => "ruby-1.1.0", + "REPOSITORY" => "rails/devcontainer", + "GITHUB_OUTPUT" => output.path + }, + "--ruby-versions", "3.3.1", + "--image-versions", "ruby-1.2.0", + "--repository", "rails/devcontainer" + ) + + assert status.success? + result = parse_output(output.path) + assert_equal '["3.3.1"]', result.fetch("ruby_versions_json") + assert_equal '["ruby-1.2.0"]', result.fetch("image_versions_json") + ensure + output&.close! + end + + private + + def run_script(env, *args) + _stdout, _stderr, status = Open3.capture3( + env, + RbConfig.ruby, + script_path, + *args, + chdir: repo_root + ) + status + end + + def parse_output(path) + File.read(path).lines.to_h do |line| + key, value = line.strip.split("=", 2) + [key, value] + end + end + + def script_path + File.join(repo_root, "bin/normalize-publish-inputs") + end + + def repo_root + File.expand_path("../..", __dir__) + end +end diff --git a/test/commands/publish_input_normalizer_test.rb b/test/commands/publish_input_normalizer_test.rb new file mode 100644 index 0000000..c059437 --- /dev/null +++ b/test/commands/publish_input_normalizer_test.rb @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../../lib/commands/publish_input_normalizer" + +class Commands::PublishInputNormalizerTest < Minitest::Test + def test_normalizes_comma_and_newline_lists + result = Commands::PublishInputNormalizer.call( + ruby_versions_input: "3.3.1, 3.2.4\n3.1.9", + image_versions_input: "1.1.0, ruby-1.2.0", + repository: "rails/devcontainer" + ) + + assert_equal ["3.3.1", "3.2.4", "3.1.9"], result[:ruby_versions] + assert_equal ["ruby-1.1.0", "ruby-1.2.0"], result[:image_versions] + assert_equal '["3.3.1","3.2.4","3.1.9"]', result[:ruby_versions_json] + assert_equal '["ruby-1.1.0","ruby-1.2.0"]', result[:image_versions_json] + end + + def test_uses_latest_image_tag_when_image_versions_blank + fetcher = ->(_repo) { "ruby-2.0.0" } + + result = Commands::PublishInputNormalizer.call( + ruby_versions_input: "3.3.1", + image_versions_input: " , \n", + repository: "rails/devcontainer", + latest_tag_fetcher: fetcher + ) + + assert_equal ["ruby-2.0.0"], result[:image_versions] + assert_equal '["ruby-2.0.0"]', result[:image_versions_json] + end + + def test_raises_for_empty_ruby_versions + error = assert_raises(Commands::PublishInputNormalizer::Error) do + Commands::PublishInputNormalizer.call( + ruby_versions_input: " , \n", + image_versions_input: "1.1.0", + repository: "rails/devcontainer" + ) + end + + assert_match(/Ruby versions input is empty/, error.message) + end + + def test_raises_when_latest_tag_is_missing + fetcher = ->(_repo) { nil } + + error = assert_raises(Commands::PublishInputNormalizer::Error) do + Commands::PublishInputNormalizer.call( + ruby_versions_input: "3.3.1", + image_versions_input: "", + repository: "rails/devcontainer", + latest_tag_fetcher: fetcher + ) + end + + assert_match(/Image versions input is empty/, error.message) + end +end