diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f393c9c0..4c1b41893 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -419,6 +419,86 @@ jobs: - name: C:/msys64/mingw64/bin/gcc.exe not installed run: ruby -e "abort if File.exist?('C:/msys64/mingw64/bin/gcc.exe')" + testBundleFrozen: + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest, windows-latest ] + ruby: [ '3.1', '3.2', '3.3', '3.4' ] + name: "Test bundle-frozen on ${{ matrix.os }} with Ruby ${{ matrix.ruby }}" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - name: Create test Gemfile with locked dependencies + shell: bash + run: | + cat > Gemfile <<'EOF' + source 'https://rubygems.org' + gem 'rake', '~> 13.0' + gem 'minitest', '~> 5.16' + EOF + - uses: ./ + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + bundle-frozen: true + - name: Verify frozen config was set + shell: bash + run: | + FROZEN_VALUE=$(bundle config frozen) + echo "Bundle frozen config: $FROZEN_VALUE" + if echo "$FROZEN_VALUE" | grep -q "true"; then + echo "✓ Bundle frozen config is set to true" + else + echo "Error: Bundle frozen config was not set to 'true'" + exit 1 + fi + - name: Verify bundle install succeeded with frozen lockfile + shell: bash + run: | + bundle exec ruby -v + echo "✓ Bundle install with frozen lockfile succeeded" + - name: Test that modifying Gemfile causes failure with bundle-frozen + shell: bash + run: | + echo "gem 'json', '~> 2.6'" >> Gemfile + if bundle install 2>&1 | grep -q "frozen"; then + echo "✓ Bundle correctly detected frozen lockfile violation" + else + echo "Warning: Expected bundle install to fail or warn about frozen lockfile" + fi + git checkout Gemfile + + testBundleNotFrozen: + name: "Test without bundle-frozen (control)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create test Gemfile + run: | + cat > Gemfile <<'EOF' + source 'https://rubygems.org' + gem 'rake', '~> 13.0' + EOF + - uses: ./ + with: + ruby-version: '3.4' + bundler-cache: true + bundle-frozen: false + - name: Verify frozen config was not set + run: | + FROZEN_VALUE=$(bundle config frozen) + echo "Bundle frozen config: $FROZEN_VALUE" + if echo "$FROZEN_VALUE" | grep -q "true"; then + echo "Error: Bundle frozen config should not be set when bundle-frozen is false" + exit 1 + fi + echo "✓ Bundle frozen config is not set to true (as expected)" + - name: Verify bundle install succeeded + run: | + bundle exec ruby -v + echo "✓ Bundle install succeeded without frozen flag" + lint: runs-on: ubuntu-22.04 steps: diff --git a/README.md b/README.md index e9ea56f89..4f205ffdf 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,20 @@ This caching speeds up installing gems significantly and avoids too many request It needs a `Gemfile` (or `$BUNDLE_GEMFILE` or `gems.rb`) under the [`working-directory`](#working-directory). If there is a `Gemfile.lock` (or `$BUNDLE_GEMFILE.lock` or `gems.locked`), `bundle config --local deployment true` is used. +#### bundle-frozen + +When using `bundler-cache: true`, you can optionally set `bundle-frozen: true` to enforce that the `Gemfile.lock` is not modified during `bundle install`: +```yaml + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + bundle-frozen: true +``` + +This runs `bundle config --local frozen true` before bundle install, which disallows changes to the Gemfile.lock. +This is useful in CI to ensure the lockfile is up to date and prevents accidental modifications. + To use a `Gemfile` which is not at the root or has a different name, set `BUNDLE_GEMFILE` in the `env` at the job level as shown in the [example](#matrix-of-gemfiles). diff --git a/action.yml b/action.yml index 3e052125e..abcb68541 100644 --- a/action.yml +++ b/action.yml @@ -25,6 +25,9 @@ inputs: bundler-cache: description: 'Run "bundle install", and cache the result automatically. Either true or false.' default: 'false' + bundle-frozen: + description: 'Run "bundle config --local frozen true" before bundle install to disallow changes to the Gemfile.lock. Either true or false.' + default: 'false' working-directory: description: 'The working directory to use for resolving paths for .ruby-version, .tool-versions, mise.toml and Gemfile.lock.' cache-version: diff --git a/bundler.js b/bundler.js index e0d117931..db6302ba1 100644 --- a/bundler.js +++ b/bundler.js @@ -137,7 +137,28 @@ export async function installBundler(bundlerVersionInput, rubygemsInputSet, lock return bundlerVersion } -export async function bundleInstall(gemfile, lockFile, platform, engine, rubyVersion, bundlerVersion, cacheVersion) { +async function applyBundleConfig(configOptions, envOptions = {}) { + // Apply bundle config settings based on input options + // This function makes it easy to add new bundle config options in the future + const configs = [] + + if (configOptions.frozen === 'true') { + configs.push({ name: 'frozen', value: 'true', description: 'frozen' }) + } + + // Add more config options here as needed in the future + // Example: + // if (configOptions.jobs) { + // configs.push({ name: 'jobs', value: configOptions.jobs, description: 'jobs' }) + // } + + for (const config of configs) { + console.log(`Setting bundle config ${config.description} to ${config.value}`) + await exec.exec('bundle', ['config', '--local', config.name, config.value], envOptions) + } +} + +export async function bundleInstall(gemfile, lockFile, platform, engine, rubyVersion, bundlerVersion, cacheVersion, bundleFrozen = 'false') { if (gemfile === null) { console.log('Could not determine gemfile path, skipping "bundle install" and caching') return false @@ -158,6 +179,9 @@ export async function bundleInstall(gemfile, lockFile, platform, engine, rubyVer await exec.exec('bundle', ['config', '--local', 'path', bundleCachePath], envOptions) + // Apply bundle config options + await applyBundleConfig({ frozen: bundleFrozen }, envOptions) + if (fs.existsSync(lockFile)) { await exec.exec('bundle', ['config', '--local', 'deployment', 'true'], envOptions) } else { @@ -196,6 +220,7 @@ export async function bundleInstall(gemfile, lockFile, platform, engine, rubyVer // Number of jobs should scale with runner, up to a point const jobs = Math.min(os.availableParallelism(), 8) + // Always run 'bundle install' to list the gems await exec.exec('bundle', ['install', '--jobs', `${jobs}`]) diff --git a/dist/index.js b/dist/index.js index 5f32d1cb1..10e90e12f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -151,7 +151,28 @@ async function installBundler(bundlerVersionInput, rubygemsInputSet, lockFile, p return bundlerVersion } -async function bundleInstall(gemfile, lockFile, platform, engine, rubyVersion, bundlerVersion, cacheVersion) { +async function applyBundleConfig(configOptions, envOptions = {}) { + // Apply bundle config settings based on input options + // This function makes it easy to add new bundle config options in the future + const configs = [] + + if (configOptions.frozen === 'true') { + configs.push({ name: 'frozen', value: 'true', description: 'frozen' }) + } + + // Add more config options here as needed in the future + // Example: + // if (configOptions.jobs) { + // configs.push({ name: 'jobs', value: configOptions.jobs, description: 'jobs' }) + // } + + for (const config of configs) { + console.log(`Setting bundle config ${config.description} to ${config.value}`) + await exec.exec('bundle', ['config', '--local', config.name, config.value], envOptions) + } +} + +async function bundleInstall(gemfile, lockFile, platform, engine, rubyVersion, bundlerVersion, cacheVersion, bundleFrozen = 'false') { if (gemfile === null) { console.log('Could not determine gemfile path, skipping "bundle install" and caching') return false @@ -172,6 +193,9 @@ async function bundleInstall(gemfile, lockFile, platform, engine, rubyVersion, b await exec.exec('bundle', ['config', '--local', 'path', bundleCachePath], envOptions) + // Apply bundle config options + await applyBundleConfig({ frozen: bundleFrozen }, envOptions) + if (fs.existsSync(lockFile)) { await exec.exec('bundle', ['config', '--local', 'deployment', 'true'], envOptions) } else { @@ -210,6 +234,7 @@ async function bundleInstall(gemfile, lockFile, platform, engine, rubyVersion, b // Number of jobs should scale with runner, up to a point const jobs = Math.min(os.availableParallelism(), 8) + // Always run 'bundle install' to list the gems await exec.exec('bundle', ['install', '--jobs', `${jobs}`]) @@ -85264,6 +85289,7 @@ const inputDefaults = { 'rubygems': 'default', 'bundler': 'Gemfile.lock', 'bundler-cache': 'false', + 'bundle-frozen': 'false', 'working-directory': '.', 'cache-version': bundler.DEFAULT_CACHE_VERSION, 'self-hosted': 'false', @@ -85347,8 +85373,13 @@ async function setupRuby(options = {}) { } if (inputs['bundler-cache'] === 'true') { + // Note: To add new bundle config options in the future: + // 1. Add the input to action.yml + // 2. Add it to inputDefaults above + // 3. Pass it to bundleInstall (or create a bundleConfig object to pass multiple options) + // 4. Update the applyBundleConfig function in bundler.js await common.time('bundle install', async () => - bundler.bundleInstall(gemfile, lockFile, platform, engine, version, bundlerVersion, inputs['cache-version'])) + bundler.bundleInstall(gemfile, lockFile, platform, engine, version, bundlerVersion, inputs['cache-version'], inputs['bundle-frozen'])) } core.setOutput('ruby-prefix', rubyPrefix) diff --git a/index.js b/index.js index 62e46feb9..8f6686b6d 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ const inputDefaults = { 'rubygems': 'default', 'bundler': 'Gemfile.lock', 'bundler-cache': 'false', + 'bundle-frozen': 'false', 'working-directory': '.', 'cache-version': bundler.DEFAULT_CACHE_VERSION, 'self-hosted': 'false', @@ -97,8 +98,13 @@ export async function setupRuby(options = {}) { } if (inputs['bundler-cache'] === 'true') { + // Note: To add new bundle config options in the future: + // 1. Add the input to action.yml + // 2. Add it to inputDefaults above + // 3. Pass it to bundleInstall (or create a bundleConfig object to pass multiple options) + // 4. Update the applyBundleConfig function in bundler.js await common.time('bundle install', async () => - bundler.bundleInstall(gemfile, lockFile, platform, engine, version, bundlerVersion, inputs['cache-version'])) + bundler.bundleInstall(gemfile, lockFile, platform, engine, version, bundlerVersion, inputs['cache-version'], inputs['bundle-frozen'])) } core.setOutput('ruby-prefix', rubyPrefix)