diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 911646f..ecea0e3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,6 +6,9 @@ "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/rails/devcontainer/features/ruby": { + "version": "4.0.1" + }, "ghcr.io/devcontainers/features/github-cli": {} }, diff --git a/.github/ruby-versions.json b/.github/ruby-versions.json new file mode 100644 index 0000000..f9a4c32 --- /dev/null +++ b/.github/ruby-versions.json @@ -0,0 +1,33 @@ +[ + "4.0.0", + "3.4.8", + "3.4.7", + "3.4.6", + "3.4.5", + "3.4.4", + "3.4.3", + "3.4.2", + "3.4.1", + "3.4.0", + "3.3.10", + "3.3.9", + "3.3.8", + "3.3.7", + "3.3.6", + "3.3.5", + "3.3.4", + "3.3.3", + "3.3.2", + "3.3.1", + "3.3.0", + "3.2.9", + "3.2.8", + "3.2.7", + "3.2.6", + "3.2.5", + "3.2.4", + "3.2.3", + "3.2.2", + "3.2.1", + "3.2.0" +] diff --git a/.github/workflows/publish-new-image-version.yaml b/.github/workflows/publish-new-image-version.yaml index 40858de..55de5af 100644 --- a/.github/workflows/publish-new-image-version.yaml +++ b/.github/workflows/publish-new-image-version.yaml @@ -4,43 +4,22 @@ name: Build and Publish Images tags: - ruby-*.*.* jobs: + setup: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: set-matrix + run: echo "matrix=$(cat .github/ruby-versions.json | jq -c '.')" >> $GITHUB_OUTPUT + build: name: Build Images + needs: setup strategy: fail-fast: false matrix: - RUBY_VERSION: - - 4.0.0 - - 3.4.8 - - 3.4.7 - - 3.4.6 - - 3.4.5 - - 3.4.4 - - 3.4.3 - - 3.4.2 - - 3.4.1 - - 3.4.0 - - 3.3.10 - - 3.3.9 - - 3.3.8 - - 3.3.7 - - 3.3.6 - - 3.3.5 - - 3.3.4 - - 3.3.3 - - 3.3.2 - - 3.3.1 - - 3.3.0 - - 3.2.9 - - 3.2.8 - - 3.2.7 - - 3.2.6 - - 3.2.5 - - 3.2.4 - - 3.2.3 - - 3.2.2 - - 3.2.1 - - 3.2.0 + RUBY_VERSION: ${{ fromJSON(needs.setup.outputs.matrix) }} runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/test-features.yaml b/.github/workflows/test-features.yaml index 7734283..d63025f 100644 --- a/.github/workflows/test-features.yaml +++ b/.github/workflows/test-features.yaml @@ -65,3 +65,33 @@ jobs: - name: "Run shellcheck" working-directory: features run: find . -name "*.sh" -type f -exec shellcheck {} + + + ruby-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Run tests + run: bundle exec rake test + + ci: + runs-on: ubuntu-latest + needs: [test-autogenerated, test-scenarios, shellcheck, ruby-tests] + if: always() + steps: + - name: Check CI status + run: | + if [[ "${{ needs.test-autogenerated.result }}" == "failure" ]] || \ + [[ "${{ needs.test-scenarios.result }}" == "failure" ]] || \ + [[ "${{ needs.shellcheck.result }}" == "failure" ]] || \ + [[ "${{ needs.ruby-tests.result }}" == "failure" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All jobs passed successfully" diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..1454f6e --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.1 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..0231c8a --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "minitest" +gem "rake" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6a44429 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,23 @@ +GEM + remote: https://rubygems.org/ + specs: + minitest (6.0.1) + prism (~> 1.5) + prism (1.9.0) + rake (13.3.1) + +PLATFORMS + aarch64-linux + ruby + +DEPENDENCIES + minitest + rake + +CHECKSUMS + minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + +BUNDLED WITH + 4.0.3 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..995b134 --- /dev/null +++ b/Rakefile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.pattern = "test/**/*_test.rb" + t.verbose = true +end + +task default: :test diff --git a/bin/add-ruby-version b/bin/add-ruby-version index 550991d..f6319f1 100755 --- a/bin/add-ruby-version +++ b/bin/add-ruby-version @@ -1,443 +1,13 @@ -#!/usr/bin/env node +#!/usr/bin/env ruby +# frozen_string_literal: true -/** - * Ruby Version Management Script - * - * This script automates the process of adding new Ruby versions to the devcontainer - * configuration. It updates the GitHub workflow matrix, potentially updates the - * default Ruby version, bumps the feature version, and updates test files. - * - * Usage: bin/add-ruby-version - * Example: bin/add-ruby-version 3.4.5 - * - * The script will: - * 1. Validate the version format - * 2. Check if the version already exists - * 3. Add the version to the workflow matrix - * 4. Update the default version if the new version is newer - * 5. Update the README.md default version - * 6. Bump the feature version when the default changes - * 7. Update test files to use the new version - * - * @author Rails Team - * @version 1.0.0 - */ +require_relative "../lib/add_ruby_version" -const fs = require('fs'); -const yaml = require('js-yaml'); -const semver = require('semver'); +if ARGV.length != 1 + warn "Usage: bin/add-ruby-version " + warn "Example: bin/add-ruby-version 3.4.5" + exit 1 +end -// ANSI color codes for better output -const colors = { - reset: '\x1b[0m', - green: '\x1b[32m', - blue: '\x1b[34m', - yellow: '\x1b[33m', - red: '\x1b[31m', - cyan: '\x1b[36m', - magenta: '\x1b[35m' -}; - -// Regex patterns for version handling -const VERSION_PATTERN = '\\d+\\.\\d+\\.\\d+'; -const VERSION_VALIDATION_REGEX = new RegExp(`^${VERSION_PATTERN}$`); -const VERSION_REPLACEMENT_REGEX = new RegExp(`(${VERSION_PATTERN})`, 'g'); - -// File paths -const YAML_FILE = '.github/workflows/publish-new-image-version.yaml'; -const JSON_FILE = 'features/src/ruby/devcontainer-feature.json'; -const README_FILE = 'features/src/ruby/README.md'; -const TEST_FILES = [ - 'features/test/ruby/test.sh', - 'features/test/ruby/with_rbenv.sh' -]; - -// YAML formatting options -const YAML_DUMP_OPTIONS = { - indent: 2, - lineWidth: -1, - noRefs: true, - sortKeys: false, - noArrayIndent: false, - skipInvalid: false, - flowLevel: -1, - styles: {}, - schema: yaml.DEFAULT_SCHEMA, - noCompatMode: false, - condenseFlow: false, - quotingType: '"', - forceQuotes: false -}; - -// Emoji helpers -const emoji = { - search: '🔍', - edit: '📝', - check: '✅', - update: '🔄', - info: 'â„šī¸', - party: '🎉', - file: '📄', - bulb: '💡', - error: '❌' -}; - -/** - * Logs a message with optional color formatting - * @param {string} message - The message to log - * @param {string} color - The color to use (default: 'reset') - */ -function log(message, color = 'reset') { - console.log(colors[color] + message + colors.reset); -} - -/** - * Logs an error message and exits the process - * @param {string} message - The error message to display - */ -function exitWithError(message) { - log(`${emoji.error} Error: ${message}`, 'red'); - process.exit(1); -} - -// === FILE OPERATIONS === -/** - * Checks if a file exists, exits with error if not found - * @param {string} filePath - The path to check - */ -function checkFileExists(filePath) { - if (!fs.existsSync(filePath)) { - log(`${emoji.error} Error: ${filePath} not found`, 'red'); - process.exit(1); - } -} - -/** - * Reads and parses a JSON file - * @param {string} filePath - The path to the JSON file - * @returns {Object} The parsed JSON data - */ -function readJsonFile(filePath) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } catch (error) { - throw new Error(`Could not read JSON file ${filePath}: ${error.message}`); - } -} - -/** - * Writes data to a JSON file with formatting - * @param {string} filePath - The path to write to - * @param {Object} data - The data to write - */ -function writeJsonFile(filePath, data) { - try { - fs.writeFileSync(filePath, JSON.stringify(data, null, 4) + '\n'); - } catch (error) { - throw new Error(`Could not write JSON file ${filePath}: ${error.message}`); - } -} - -/** - * Reads and parses a YAML file - * @param {string} filePath - The path to the YAML file - * @returns {Object} The parsed YAML data - */ -function readYamlFile(filePath) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - return yaml.load(content); - } catch (error) { - throw new Error(`Could not read YAML file ${filePath}: ${error.message}`); - } -} - -/** - * Writes data to a YAML file with formatting - * @param {string} filePath - The path to write to - * @param {Object} data - The data to write - */ -function writeYamlFile(filePath, data) { - try { - const content = yaml.dump(data, YAML_DUMP_OPTIONS); - fs.writeFileSync(filePath, content); - } catch (error) { - throw new Error(`Could not write YAML file ${filePath}: ${error.message}`); - } -} - -/** - * Updates version numbers in test files using regex replacement - * @param {string} newVersion - The new version to replace with - * @param {string} testFile - The test file to update - * @returns {boolean} Success status - */ -function updateVersionInTestFile(newVersion, testFile) { - try { - const content = fs.readFileSync(testFile, 'utf8'); - const updatedContent = content.replace(VERSION_REPLACEMENT_REGEX, newVersion); - fs.writeFileSync(testFile, updatedContent); - return true; - } catch (error) { - throw new Error(`Could not update test file ${testFile}: ${error.message}`); - } -} - -/** - * Updates the default version in the README.md file - * @param {string} newVersion - The new default version - * @param {string} readmeFile - Path to the README.md file - * @returns {boolean} Success status - */ -function updateReadmeDefaultVersion(newVersion, readmeFile) { - try { - const content = fs.readFileSync(readmeFile, 'utf8'); - - // Update the default value in the options table - // Look for the pattern: | version | ... | string | X.Y.Z | - const readmeVersionRegex = /(\| version \| [^|]+ \| string \| )(\d+\.\d+\.\d+)( \|)/; - const updatedContent = content.replace(readmeVersionRegex, `$1${newVersion}$3`); - - fs.writeFileSync(readmeFile, updatedContent); - return true; - } catch (error) { - throw new Error(`Could not update README file ${readmeFile}: ${error.message}`); - } -} - -// === VERSION VALIDATION === -/** - * Validates if a version string matches the expected format - * @param {string} version - The version string to validate - * @returns {boolean} True if valid, false otherwise - */ -function validateVersionFormat(version) { - return VERSION_VALIDATION_REGEX.test(version); -} - -/** - * Validates that all required configuration files exist - */ -function validateConfiguration() { - // Check if files exist - checkFileExists(YAML_FILE); - checkFileExists(JSON_FILE); - checkFileExists(README_FILE); - - // Check if test files exist - for (const testFile of TEST_FILES) { - checkFileExists(testFile); - } -} - -// === BUSINESS LOGIC === - -/** - * Gets the current default Ruby version from the JSON configuration file - * @param {string} jsonFile - Path to the JSON configuration file - * @returns {string} The current default version - */ -function getCurrentDefaultVersion(jsonFile) { - try { - const data = readJsonFile(jsonFile); - return data.options.version.default; - } catch (error) { - throw new Error(`Could not read current default version: ${error.message}`); - } -} - -/** - * Checks if a version already exists in the YAML workflow file - * @param {string} version - The version to check for - * @param {string} yamlFile - Path to the YAML workflow file - * @returns {boolean} True if version exists, false otherwise - */ -function versionExistsInYaml(version, yamlFile) { - try { - const data = readYamlFile(yamlFile); - const versions = data.jobs.build.strategy.matrix.RUBY_VERSION || []; - return versions.includes(version); - } catch (error) { - throw new Error(`Could not check if version exists: ${error.message}`); - } -} - -/** - * Adds a new Ruby version to the YAML workflow file and sorts the versions - * @param {string} newVersion - The new version to add - * @param {string} yamlFile - Path to the YAML workflow file - * @returns {boolean} Success status - */ -function addVersionToYaml(newVersion, yamlFile) { - try { - // Read and parse the YAML file - const data = readYamlFile(yamlFile); - - // Get current versions and add new one - const currentVersions = data.jobs.build.strategy.matrix.RUBY_VERSION || []; - const allVersions = [...new Set([...currentVersions, newVersion])]; - const sortedVersions = allVersions.sort((a, b) => semver.rcompare(a, b)); - - // Update the data structure - data.jobs.build.strategy.matrix.RUBY_VERSION = sortedVersions; - - // Write back to file - writeYamlFile(yamlFile, data); - return true; - } catch (error) { - throw new Error(`Could not update YAML file: ${error.message}`); - } -} - -/** - * Updates the default Ruby version in the JSON configuration file - * @param {string} newVersion - The new default version - * @param {string} jsonFile - Path to the JSON configuration file - * @returns {boolean} Success status - */ -function updateDefaultInJson(newVersion, jsonFile) { - try { - const data = readJsonFile(jsonFile); - data.options.version.default = newVersion; - writeJsonFile(jsonFile, data); - return true; - } catch (error) { - throw new Error(`Could not update JSON file: ${error.message}`); - } -} - -/** - * Increments the feature version in the JSON configuration file - * @param {string} jsonFile - Path to the JSON configuration file - * @returns {Object} Object containing oldVersion and newVersion - */ -function bumpFeatureVersion(jsonFile) { - try { - const data = readJsonFile(jsonFile); - - // Parse current version and increment patch version - const currentVersion = data.version; - const incrementedVersion = semver.inc(currentVersion, 'patch'); - - data.version = incrementedVersion; - writeJsonFile(jsonFile, data); - - return { - oldVersion: currentVersion, - newVersion: incrementedVersion - }; - } catch (error) { - throw new Error(`Could not bump feature version: ${error.message}`); - } -} - -/** - * Main function that orchestrates the Ruby version addition process - */ -function main() { - // Check command line arguments - const args = process.argv.slice(2); - - if (args.length !== 1) { - log('Usage: bin/add-ruby-version ', 'red'); - log('Example: bin/add-ruby-version 3.4.5', 'yellow'); - process.exit(1); - } - - const newVersion = args[0]; - - // Validate version format - if (!validateVersionFormat(newVersion)) { - exitWithError('Invalid version format. Expected format: x.y.z (e.g., 3.4.5)'); - } - - // Validate configuration - validateConfiguration(); - - try { - // Check if version already exists - if (versionExistsInYaml(newVersion, YAML_FILE)) { - exitWithError(`Version ${newVersion} already exists in ${YAML_FILE}`); - } - - log(`${emoji.search} Checking current configuration...`, 'cyan'); - - // Get current default version - const currentDefault = getCurrentDefaultVersion(JSON_FILE); - log(`Current default version: ${currentDefault}`); - log(`New version to add: ${newVersion}`); - - // Add version to YAML file - log(''); - log(`${emoji.edit} Adding ${newVersion} to ${YAML_FILE}...`, 'blue'); - addVersionToYaml(newVersion, YAML_FILE); - log(`${emoji.check} Added to workflow matrix`, 'green'); - - // Check if new version should become the default - const comparisonResult = semver.compare(newVersion, currentDefault); - const filesModified = [YAML_FILE]; - - if (comparisonResult > 0) { - log(''); - log(`${emoji.update} New version ${newVersion} is newer than current default ${currentDefault}`, 'yellow'); - log(`Updating default version in ${JSON_FILE}...`); - updateDefaultInJson(newVersion, JSON_FILE); - log(`${emoji.check} Updated default version to ${newVersion}`, 'green'); - - // Update README with new default version - log(`Updating default version in ${README_FILE}...`); - updateReadmeDefaultVersion(newVersion, README_FILE); - log(`${emoji.check} Updated README default version to ${newVersion}`, 'green'); - - // Bump feature version when default changes - log(''); - log(`${emoji.update} Bumping feature version...`, 'yellow'); - const versionInfo = bumpFeatureVersion(JSON_FILE); - log(`${emoji.check} Feature version bumped from ${versionInfo.oldVersion} to ${versionInfo.newVersion}`, 'green'); - - filesModified.push(JSON_FILE, README_FILE); - } else { - log(''); - log(`${emoji.info} New version ${newVersion} is not newer than current default ${currentDefault}`, 'cyan'); - log('Default version remains unchanged'); - } - - // Update test files only if new version is newer than current default - log(''); - let testFilesUpdated = 0; - if (comparisonResult > 0) { - log(`${emoji.edit} Updating test files...`, 'blue'); - for (const testFile of TEST_FILES) { - updateVersionInTestFile(newVersion, testFile); - log(`${emoji.check} Updated ${testFile}`, 'green'); - filesModified.push(testFile); - testFilesUpdated++; - } - } else { - log(`${emoji.info} Skipping test file updates (new version ${newVersion} is not newer than current default ${currentDefault})`, 'cyan'); - } - - // Success message - log(''); - log(`${emoji.party} Successfully added Ruby version ${newVersion}!`, 'green'); - log(''); - log(`${emoji.file} Files modified:`, 'blue'); - filesModified.forEach(file => { - log(` â€ĸ ${file}`); - }); - - log(''); - log(`${emoji.bulb} Next steps:`, 'magenta'); - log(` 1. Review the changes: git diff`); - log(` 2. Commit the changes: git add . && git commit -m 'Add Ruby ${newVersion}'`); - log(` 3. Push changes: git push`); - - } catch (error) { - log(`${emoji.error} Error: ${error.message}`, 'red'); - process.exit(1); - } -} - -// Run the main function -main(); +result = AddRubyVersion.call(ARGV[0], working_dir: Dir.pwd) +exit(result[:success] ? 0 : 1) diff --git a/lib/add_ruby_version.rb b/lib/add_ruby_version.rb new file mode 100644 index 0000000..65bf48f --- /dev/null +++ b/lib/add_ruby_version.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" + +# Ruby Version Management Library +# +# This module automates the process of adding new Ruby versions to the devcontainer +# configuration. It updates the versions JSON file, potentially updates the +# default Ruby version, bumps the feature version, and updates test files. +module AddRubyVersion + # Custom error class for version-related errors + class Error < StandardError; end + + # File paths relative to the working directory + VERSIONS_JSON_FILE = ".github/ruby-versions.json" + FEATURE_JSON_FILE = "features/src/ruby/devcontainer-feature.json" + README_FILE = "features/src/ruby/README.md" + TEST_FILES = [ + "features/test/ruby/test.sh", + "features/test/ruby/with_rbenv.sh" + ].freeze + + # Version format pattern + VERSION_PATTERN = /\d+\.\d+\.\d+/ + VERSION_EXACT_PATTERN = /\A#{VERSION_PATTERN}\z/ + + class << self + # Main entry point for adding a Ruby version + # + # @param version [String] The Ruby version to add (e.g., "3.4.0") + # @param working_dir [String] The directory to operate in + # @param output [IO] Output stream for messages (default: $stdout) + # @return [Hash] Result with :success, :files_modified, and :message keys + def call(version, working_dir:, output: $stdout) + runner = Runner.new(version, working_dir: working_dir, output: output) + runner.call + end + end + + # Runner class that performs the actual version addition + class Runner + # ANSI color codes + COLORS = { + reset: "\e[0m", + green: "\e[32m", + blue: "\e[34m", + yellow: "\e[33m", + red: "\e[31m", + cyan: "\e[36m", + magenta: "\e[35m" + }.freeze + + # Emoji helpers + EMOJI = { + search: "🔍", + edit: "📝", + check: "✅", + update: "🔄", + info: "â„šī¸", + party: "🎉", + file: "📄", + bulb: "💡", + error: "❌" + }.freeze + + attr_reader :version, :working_dir, :output + + def initialize(version, working_dir:, output: $stdout) + @version = version + @working_dir = working_dir + @output = output + end + + def call + validate_version_format! + validate_files_exist! + validate_version_not_duplicate! + + files_modified = [] + + log("#{EMOJI[:search]} Checking current configuration...", :cyan) + + current_default = current_default_version + log("Current default version: #{current_default}") + log("New version to add: #{version}") + + # Add version to versions JSON file + log("") + log("#{EMOJI[:edit]} Adding #{version} to #{VERSIONS_JSON_FILE}...", :blue) + add_version_to_versions_file + log("#{EMOJI[:check]} Successfully added to #{VERSIONS_JSON_FILE}", :green) + files_modified << VERSIONS_JSON_FILE + + # Check if new version should become the default + if version_newer?(version, current_default) + log("") + log("#{EMOJI[:update]} New version #{version} is newer than current default #{current_default}", :yellow) + + update_default_version + log("#{EMOJI[:check]} Updated default version to #{version}", :green) + + update_readme_default_version + log("#{EMOJI[:check]} Updated README default version to #{version}", :green) + + log("") + log("#{EMOJI[:update]} Bumping feature version...", :yellow) + old_ver, new_ver = bump_feature_version + log("#{EMOJI[:check]} Feature version bumped from #{old_ver} to #{new_ver}", :green) + + files_modified << FEATURE_JSON_FILE << README_FILE + + log("") + log("#{EMOJI[:edit]} Updating test files...", :blue) + TEST_FILES.each do |test_file| + update_test_file(test_file) + log("#{EMOJI[:check]} Updated #{test_file}", :green) + files_modified << test_file + end + else + log("") + log("#{EMOJI[:info]} New version #{version} is not newer than current default #{current_default}", :cyan) + log("Default version remains unchanged") + log("") + log("#{EMOJI[:info]} Skipping test file updates (new version #{version} is not newer than current default #{current_default})", :cyan) + end + + # Success message + log("") + log("#{EMOJI[:party]} Successfully added Ruby version #{version}!", :green) + log("") + log("#{EMOJI[:file]} Files modified:", :blue) + files_modified.each { |file| log(" â€ĸ #{file}") } + + log("") + log("#{EMOJI[:bulb]} Next steps:", :magenta) + log(" 1. Review the changes: git diff") + log(" 2. Commit the changes: git add . && git commit -m 'Add Ruby #{version}'") + log(" 3. Push changes: git push") + + { success: true, files_modified: files_modified } + rescue Error => e + log("#{EMOJI[:error]} Error: #{e.message}", :red) + { success: false, error: e.message } + end + + private + + def log(message, color = :reset) + output.puts "#{COLORS[color]}#{message}#{COLORS[:reset]}" + end + + def path_for(relative_path) + File.join(working_dir, relative_path) + end + + def validate_version_format! + unless version.match?(VERSION_EXACT_PATTERN) + raise Error, "Invalid version format. Expected format: x.y.z (e.g., 3.4.5)" + end + end + + def validate_files_exist! + [VERSIONS_JSON_FILE, FEATURE_JSON_FILE, README_FILE, *TEST_FILES].each do |file| + unless File.exist?(path_for(file)) + raise Error, "#{file} not found" + end + end + end + + def validate_version_not_duplicate! + versions = read_json(VERSIONS_JSON_FILE) + if versions.include?(version) + raise Error, "Version #{version} already exists in #{VERSIONS_JSON_FILE}" + end + end + + def read_json(relative_path) + JSON.parse(File.read(path_for(relative_path))) + end + + def write_json(relative_path, data) + File.write(path_for(relative_path), JSON.pretty_generate(data) + "\n") + end + + def current_default_version + data = read_json(FEATURE_JSON_FILE) + data.dig("options", "version", "default") + end + + def version_newer?(new_version, current_version) + parse_version(new_version) > parse_version(current_version) + end + + def parse_version(version_string) + Gem::Version.new(version_string) + end + + def add_version_to_versions_file + versions = read_json(VERSIONS_JSON_FILE) + versions << version + versions.uniq! + versions.sort_by! { |v| Gem::Version.new(v) }.reverse! + write_json(VERSIONS_JSON_FILE, versions) + end + + def update_default_version + data = read_json(FEATURE_JSON_FILE) + data["options"]["version"]["default"] = version + write_json(FEATURE_JSON_FILE, data) + end + + def update_readme_default_version + path = path_for(README_FILE) + content = File.read(path) + # Update the default value in the options table + # Look for the pattern: | version | ... | string | X.Y.Z | + updated = content.gsub(/(\| version \| [^|]+ \| string \| )(\d+\.\d+\.\d+)( \|)/) do + "#{$1}#{version}#{$3}" + end + File.write(path, updated) + end + + def bump_feature_version + data = read_json(FEATURE_JSON_FILE) + old_version = data["version"] + new_version = increment_patch_version(old_version) + data["version"] = new_version + write_json(FEATURE_JSON_FILE, data) + [old_version, new_version] + end + + def increment_patch_version(version_string) + parts = version_string.split(".").map(&:to_i) + parts[2] += 1 + parts.join(".") + end + + def update_test_file(relative_path) + path = path_for(relative_path) + content = File.read(path) + updated = content.gsub(VERSION_PATTERN, version) + File.write(path, updated) + end + end +end diff --git a/test/add_ruby_version_test.rb b/test/add_ruby_version_test.rb new file mode 100644 index 0000000..3571d5b --- /dev/null +++ b/test/add_ruby_version_test.rb @@ -0,0 +1,391 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "minitest/autorun" +require "fileutils" +require "json" +require "tempfile" +require "stringio" +require_relative "../lib/add_ruby_version" + +# Test suite for the add-ruby-version script +# +# These tests verify the behavior of the Ruby version management script, +# which automates adding new Ruby versions to the devcontainer configuration. +# +# Run with: ruby test/add_ruby_version_test.rb +# Or: bundle exec ruby test/add_ruby_version_test.rb +class AddRubyVersionTest < Minitest::Test + def setup + @temp_dir = Dir.mktmpdir("add-ruby-version-test") + + # Create the directory structure + FileUtils.mkdir_p(File.join(@temp_dir, ".github")) + FileUtils.mkdir_p(File.join(@temp_dir, "features/src/ruby")) + FileUtils.mkdir_p(File.join(@temp_dir, "features/test/ruby")) + end + + def teardown + FileUtils.rm_rf(@temp_dir) + end + + def test_rejects_invalid_version_format_no_dots + setup_valid_environment + result = run_script("330") + + refute result[:success], "Should fail for version without dots" + assert_match(/invalid version format/i, result[:output]) + end + + def test_rejects_invalid_version_format_two_parts + setup_valid_environment + result = run_script("3.3") + + refute result[:success], "Should fail for version with only two parts" + assert_match(/invalid version format/i, result[:output]) + end + + def test_rejects_invalid_version_format_four_parts + setup_valid_environment + result = run_script("3.3.0.1") + + refute result[:success], "Should fail for version with four parts" + assert_match(/invalid version format/i, result[:output]) + end + + def test_rejects_invalid_version_format_with_letters + setup_valid_environment + result = run_script("3.3.0-preview1") + + refute result[:success], "Should fail for version with letters" + assert_match(/invalid version format/i, result[:output]) + end + + def test_accepts_valid_version_format + setup_valid_environment + result = run_script("3.4.0") + + assert result[:success], "Should accept valid x.y.z format: #{result[:output]}" + end + + def test_rejects_duplicate_version + setup_valid_environment(versions: ["3.3.0", "3.2.0"]) + result = run_script("3.3.0") + + refute result[:success], "Should fail when version already exists" + assert_match(/already exists/i, result[:output]) + end + + def test_accepts_new_version + setup_valid_environment(versions: ["3.3.0", "3.2.0"]) + result = run_script("3.4.0") + + assert result[:success], "Should accept new version: #{result[:output]}" + end + + def test_adds_version_to_json_file + setup_valid_environment(versions: ["3.3.0", "3.2.0"]) + run_script("3.4.0") + + versions = read_versions_json + assert_includes versions, "3.4.0", "New version should be added to JSON" + assert_includes versions, "3.3.0", "Existing versions should be preserved" + assert_includes versions, "3.2.0", "Existing versions should be preserved" + end + + def test_sorts_versions_descending + setup_valid_environment(versions: ["3.3.0", "3.2.0"]) + run_script("3.4.0") + + versions = read_versions_json + assert_equal ["3.4.0", "3.3.0", "3.2.0"], versions, "Versions should be sorted descending" + end + + def test_sorts_versions_with_double_digit_patch + setup_valid_environment(versions: ["3.3.10", "3.3.9", "3.3.0"]) + run_script("3.3.11") + + versions = read_versions_json + assert_equal ["3.3.11", "3.3.10", "3.3.9", "3.3.0"], versions, + "Versions should be sorted correctly with double-digit patch numbers" + end + + def test_adds_older_version_in_correct_position + setup_valid_environment(versions: ["3.3.0", "3.1.0"]) + run_script("3.2.0") + + versions = read_versions_json + assert_equal ["3.3.0", "3.2.0", "3.1.0"], versions, + "Older version should be inserted in correct sorted position" + end + + def test_updates_default_when_version_is_newer + setup_valid_environment(versions: ["3.3.0", "3.2.0"], default_ruby: "3.3.0") + run_script("3.4.0") + + feature = read_feature_json + assert_equal "3.4.0", feature["options"]["version"]["default"], + "Default should be updated to newer version" + end + + def test_does_not_update_default_when_version_is_older + setup_valid_environment(versions: ["3.3.0", "3.2.0"], default_ruby: "3.3.0") + run_script("3.2.5") + + feature = read_feature_json + assert_equal "3.3.0", feature["options"]["version"]["default"], + "Default should remain unchanged for older version" + end + + def test_does_not_update_default_when_version_is_same_minor + setup_valid_environment(versions: ["3.3.0"], default_ruby: "3.3.0") + run_script("3.3.1") + + feature = read_feature_json + assert_equal "3.3.1", feature["options"]["version"]["default"], + "Default should be updated for newer patch version" + end + + def test_updates_readme_when_version_is_newer + setup_valid_environment(default_ruby: "3.3.0") + run_script("3.4.0") + + readme = read_readme + assert_match(/\| string \| 3\.4\.0 \|/, readme, + "README should show new default version") + refute_match(/\| string \| 3\.3\.0 \|/, readme, + "README should not show old default version") + end + + def test_does_not_update_readme_when_version_is_older + setup_valid_environment(default_ruby: "3.3.0") + run_script("3.2.5") + + readme = read_readme + assert_match(/\| string \| 3\.3\.0 \|/, readme, + "README should keep old default version") + end + + def test_bumps_feature_version_when_default_changes + setup_valid_environment(feature_version: "2.0.0", default_ruby: "3.3.0") + run_script("3.4.0") + + feature = read_feature_json + assert_equal "2.0.1", feature["version"], + "Feature version should be bumped when default changes" + end + + def test_does_not_bump_feature_version_when_default_unchanged + setup_valid_environment(feature_version: "2.0.0", default_ruby: "3.3.0") + run_script("3.2.5") + + feature = read_feature_json + assert_equal "2.0.0", feature["version"], + "Feature version should not change when default is unchanged" + end + + def test_bumps_feature_version_patch_correctly + setup_valid_environment(feature_version: "2.1.9", default_ruby: "3.3.0") + run_script("3.4.0") + + feature = read_feature_json + assert_equal "2.1.10", feature["version"], + "Feature version patch should increment correctly" + end + + def test_updates_test_files_when_default_changes + setup_valid_environment(default_ruby: "3.3.0") + run_script("3.4.0") + + test_content = read_test_file("test.sh") + assert_match(/3\.4\.0/, test_content, "test.sh should contain new version") + + rbenv_content = read_test_file("with_rbenv.sh") + assert_match(/3\.4\.0/, rbenv_content, "with_rbenv.sh should contain new version") + end + + def test_does_not_update_test_files_when_default_unchanged + setup_valid_environment(default_ruby: "3.3.0") + run_script("3.2.5") + + test_content = read_test_file("test.sh") + assert_match(/3\.3\.0/, test_content, "test.sh should keep old version") + refute_match(/3\.2\.5/, test_content, "test.sh should not contain older version") + end + + def test_output_shows_success_message + setup_valid_environment + result = run_script("3.4.0") + + assert_match(/successfully added/i, result[:output]) + assert_match(/3\.4\.0/, result[:output]) + end + + def test_output_lists_modified_files + setup_valid_environment(default_ruby: "3.3.0") + result = run_script("3.4.0") + + assert_match(/ruby-versions\.json/i, result[:output]) + assert_match(/devcontainer-feature\.json/i, result[:output]) + assert_match(/README\.md/i, result[:output]) + end + + def test_output_shows_version_comparison + setup_valid_environment(default_ruby: "3.3.0") + result = run_script("3.4.0") + + assert_match(/current default.*3\.3\.0/i, result[:output]) + assert_match(/new version.*3\.4\.0/i, result[:output]) + end + + def test_output_indicates_skipped_updates_for_older_version + setup_valid_environment(default_ruby: "3.3.0") + result = run_script("3.2.5") + + assert_match(/not newer than current default/i, result[:output]) + assert_match(/skipping/i, result[:output]) + end + + def test_fails_when_versions_json_missing + setup_valid_environment + FileUtils.rm(File.join(@temp_dir, ".github/ruby-versions.json")) + result = run_script("3.4.0") + + refute result[:success], "Should fail when versions JSON is missing" + assert_match(/not found/i, result[:output]) + end + + def test_fails_when_feature_json_missing + setup_valid_environment + FileUtils.rm(File.join(@temp_dir, "features/src/ruby/devcontainer-feature.json")) + result = run_script("3.4.0") + + refute result[:success], "Should fail when feature JSON is missing" + assert_match(/not found/i, result[:output]) + end + + def test_fails_when_readme_missing + setup_valid_environment + FileUtils.rm(File.join(@temp_dir, "features/src/ruby/README.md")) + result = run_script("3.4.0") + + refute result[:success], "Should fail when README is missing" + assert_match(/not found/i, result[:output]) + end + + def test_fails_when_test_file_missing + setup_valid_environment + FileUtils.rm(File.join(@temp_dir, "features/test/ruby/test.sh")) + result = run_script("3.4.0") + + refute result[:success], "Should fail when test file is missing" + assert_match(/not found/i, result[:output]) + end + + def test_handles_empty_versions_array + setup_valid_environment(versions: []) + result = run_script("3.4.0") + + assert result[:success], "Should handle empty versions array: #{result[:output]}" + versions = read_versions_json + assert_equal ["3.4.0"], versions + end + + def test_handles_major_version_change + setup_valid_environment(versions: ["3.3.0"], default_ruby: "3.3.0") + run_script("4.0.0") + + feature = read_feature_json + assert_equal "4.0.0", feature["options"]["version"]["default"], + "Should handle major version upgrades" + end + + def test_preserves_other_feature_json_fields + setup_valid_environment + run_script("3.4.0") + + feature = read_feature_json + assert_equal "ruby", feature["id"], "Should preserve id field" + assert_equal "Ruby", feature["name"], "Should preserve name field" + assert_equal "Installs Ruby", feature["description"], "Should preserve description" + end + + private + + def create_versions_json(versions) + File.write(File.join(@temp_dir, ".github/ruby-versions.json"), JSON.pretty_generate(versions) + "\n") + end + + def read_versions_json + JSON.parse(File.read(File.join(@temp_dir, ".github/ruby-versions.json"))) + end + + def create_feature_json(version: "2.0.0", default_ruby: "3.3.0") + data = { + "id" => "ruby", + "version" => version, + "name" => "Ruby", + "description" => "Installs Ruby", + "options" => { + "version" => { + "type" => "string", + "default" => default_ruby, + "description" => "The ruby version to be installed" + } + } + } + File.write(File.join(@temp_dir, "features/src/ruby/devcontainer-feature.json"), JSON.pretty_generate(data) + "\n") + end + + def read_feature_json + JSON.parse(File.read(File.join(@temp_dir, "features/src/ruby/devcontainer-feature.json"))) + end + + def create_readme(default_version: "3.3.0") + content = <<~README + # Ruby + + Installs Ruby and a version manager. + + ## Options + + | Options Id | Description | Type | Default Value | + |-----|-----|-----|-----| + | version | The version of ruby to be installed | string | #{default_version} | + | versionManager | The version manager to use | string | mise | + README + File.write(File.join(@temp_dir, "features/src/ruby/README.md"), content) + end + + def read_readme + File.read(File.join(@temp_dir, "features/src/ruby/README.md")) + end + + def create_test_file(filename, version: "3.3.0") + content = <<~BASH + #!/bin/bash + set -e + check "Ruby version is set to #{version}" bash -c "ruby -v | grep #{version}" + reportResults + BASH + File.write(File.join(@temp_dir, "features/test/ruby/#{filename}"), content) + end + + def read_test_file(filename) + File.read(File.join(@temp_dir, "features/test/ruby/#{filename}")) + end + + def run_script(version) + output = StringIO.new + result = AddRubyVersion.call(version, working_dir: @temp_dir, output: output) + { output: output.string, success: result[:success] } + end + + def setup_valid_environment(versions: ["3.3.0", "3.2.0"], default_ruby: "3.3.0", feature_version: "2.0.0") + create_versions_json(versions) + create_feature_json(version: feature_version, default_ruby: default_ruby) + create_readme(default_version: default_ruby) + create_test_file("test.sh", version: default_ruby) + create_test_file("with_rbenv.sh", version: default_ruby) + end +end