diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 989064ab..da99c0dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,12 +38,27 @@ jobs: - name: Test run: bundle exec rake test:unit + # Idempotent so a failed run can be re-dispatched (bit us at beta1): + # skip when the tag already exists at HEAD, fail loudly if it exists + # at a different commit -- never retag. - name: Create tag run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "v${{ github.event.inputs.version }}" -m "v${{ github.event.inputs.version }}" - git push origin "v${{ github.event.inputs.version }}" + TAG="v${{ github.event.inputs.version }}" + # checkout doesn't fetch tags; pull this one if it exists remotely. + git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null || true + if EXISTING=$(git rev-parse -q --verify "refs/tags/$TAG^{commit}"); then + if [ "$EXISTING" = "$(git rev-parse HEAD)" ]; then + echo "Tag $TAG already exists at HEAD; skipping tag creation." + else + echo "::error::Tag $TAG already exists at $EXISTING, but HEAD is $(git rev-parse HEAD). Refusing to retag." + exit 1 + fi + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + fi - name: Publish to RubyGems uses: rubygems/release-gem@v1 diff --git a/README.md b/README.md index de912834..5f4a0ba7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Stop shipping UI bugs. Take screenshots in your Capybara tests, commit baselines **Why this gem?** Baselines live in git — review UI changes in pull requests like you review code. Runs offline, works in CI, zero vendor lock-in. Unlike Percy/Chromatic (paid SaaS), nothing to sign up for. Unlike BackstopJS, no Node required. -> **2.0 experiment (alpha):** the gem is moving to a `SnapDiff` canonical namespace. Opt in with `gem "capybara-screenshot-diff", "2.0.0.alpha1"` (prereleases are never installed by default — normal installs stay on 1.x). Legacy names keep working with a one-time deprecation warning, silenceable via `SnapDiff.silence_deprecations = true` or `SNAP_DIFF_SILENCE_DEPRECATIONS=1`. See the [upgrade guide](docs/UPGRADING.md) and share feedback on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166). +> **2.0 experiment (alpha):** the gem is moving to a `SnapDiff` canonical namespace. Opt in with `gem "capybara-screenshot-diff", "2.0.0.beta1"` (or the latest 2.0.0 prerelease; prereleases are never installed by default — normal installs stay on 1.x). Legacy names keep working with a one-time deprecation warning, silenceable via `SnapDiff.silence_deprecations = true` or `SNAP_DIFF_SILENCE_DEPRECATIONS=1`. See the [upgrade guide](docs/UPGRADING.md) and share feedback on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166). > > Starting with the 2.0 prereleases the gem is also published as [`snap_diff-capybara`](https://rubygems.org/gems/snap_diff-capybara) — identical content and versions under the forward-looking name, matching this repository. Install either; don't install both. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index faba7181..d543dcbb 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -6,7 +6,7 @@ Version 2.0 introduces a new canonical namespace (`SnapDiff`) for cleaner, more discoverable code. The public DSL remains unchanged — your existing `screenshot` and `assert_matches_screenshot` calls work without modification. This guide covers the optional migration path for settings and the new namespace. -**Status:** `2.0.0.alpha1` is an opt-in prerelease. RubyGems never installs prereleases by default resolution — normal `bundle update` keeps you on the 1.x line. The final 2.0.0 ships only after adopter feedback; please report anything surprising on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166). +**Status:** `2.0.0.beta1` is an opt-in prerelease. RubyGems never installs prereleases by default resolution — normal `bundle update` keeps you on the 1.x line. The final 2.0.0 ships only after adopter feedback; please report anything surprising on [#166](https://github.com/snap-diff/snap_diff-capybara/issues/166). **Estimated upgrade time:** 5–15 minutes (most users need only the Gemfile pin) @@ -18,7 +18,7 @@ Version 2.0 introduces a new canonical namespace (`SnapDiff`) for cleaner, more ```ruby # In your Gemfile — the exact prerelease version is required to opt in -gem "capybara-screenshot-diff", "2.0.0.alpha1" +gem "capybara-screenshot-diff", "2.0.0.beta1" # or the latest 2.0.0 prerelease ``` ```bash @@ -218,7 +218,7 @@ All settings and baselines are compatible with v1.x. Simply pin your Gemfile bac ### Summary Checklist -- [ ] Pin `gem "capybara-screenshot-diff", "2.0.0.alpha1"` in your Gemfile +- [ ] Pin `gem "capybara-screenshot-diff", "2.0.0.beta1"` (or the latest 2.0.0 prerelease) in your Gemfile - [ ] Run `bundle install` - [ ] Run your test suite to verify no regressions - [ ] (Optional) Migrate config to the `SnapDiff` namespace diff --git a/lib/snap_diff.rb b/lib/snap_diff.rb index ef18e6fd..0bd5f8da 100644 --- a/lib/snap_diff.rb +++ b/lib/snap_diff.rb @@ -1,5 +1,26 @@ # frozen_string_literal: true +# Dual-install guard: capybara-screenshot-diff and snap_diff-capybara ship +# identical files. With BOTH activated, every require silently resolves +# from whichever gem activated first, so version skew between the two is +# undetectable. Refuse that setup at the entry point. Local dev from +# source loads neither spec, so the guard fires only when both are +# genuinely installed as gems. +module SnapDiff + DualInstallError = Class.new(StandardError) + + # @api private + def self.assert_single_gem!(loaded_specs = Gem.loaded_specs) + return unless loaded_specs.key?("capybara-screenshot-diff") && loaded_specs.key?("snap_diff-capybara") + + raise DualInstallError, + "Both `capybara-screenshot-diff` and `snap_diff-capybara` gems are installed. " \ + "They ship identical files, so files load from whichever gem activated first " \ + "and versions can silently diverge. Remove one of them from your Gemfile." + end +end +SnapDiff.assert_single_gem! + # None of these requires ever leads back to this file: config_legacy is a # leaf (see its own header comment), the image_compare forwarder pulls in # snap_diff/comparison plus the legacy const_missing shims, and diff --git a/lib/snap_diff/deprecation.rb b/lib/snap_diff/deprecation.rb index 7f149426..8e9e8f1f 100644 --- a/lib/snap_diff/deprecation.rb +++ b/lib/snap_diff/deprecation.rb @@ -5,12 +5,16 @@ module SnapDiff # # Internal until the v2 namespace transition; not a public contract. # - # Warn-once-per-subject deprecation helper. Dormant: nothing in the - # current codebase calls this yet -- it exists so the gated v2 shim layer - # (ADR-004's +const_missing+-based legacy constant shim and the - # mattr_accessor-to-Config method shims) has pre-tested warning - # machinery to call into once it lands. + # Warn-once-per-subject deprecation engine for the legacy-namespace + # shims: snap_diff/legacy_shims routes every +const_missing+ hit on an + # old +Capybara::Screenshot::Diff+ / +CapybaraScreenshotDiff+ constant + # through {.warn}, so each deprecated name warns exactly once per + # process (ADR-004's v2 namespace transition). module Deprecation + # Everything under lib/ is "the gem"; the first caller frame outside + # it is the user code that referenced the deprecated name (same + # filtering idea as BacktraceFilter in error_with_filtered_backtrace). + GEM_LIB_DIR = File.expand_path("..", __dir__) + File::SEPARATOR # Emission channel: Kernel#warn, not a direct +$stderr.puts+. # # Kernel#warn delegates to +Warning.warn+ (Ruby >= 2.4), so anything @@ -40,7 +44,7 @@ def warn(subject, replacement, category:) end return unless first_time - Kernel.warn(message_for(subject, replacement, category)) + Kernel.warn(message_for(subject, replacement, category, caller_locations(1))) end # @api private @@ -55,9 +59,23 @@ def reset! private - def message_for(subject, replacement, category) - "[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \ + def message_for(subject, replacement, category, locations) + message = "[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \ "use `#{replacement}` instead." + origin = origin_for(locations) + origin ? "#{message} (called from #{origin})" : message + end + + # First frame outside the gem's lib dir, formatted "file:line"; + # nil when every frame is internal (or paths are unavailable). + def origin_for(locations) + (locations || []).each do |location| + path = location.absolute_path || location.path + next if path.nil? || path.start_with?(GEM_LIB_DIR) + + return "#{path}:#{location.lineno}" + end + nil end end end diff --git a/test/unit/snap_diff_deprecation_test.rb b/test/unit/snap_diff_deprecation_test.rb index 08e133e5..04db89bc 100644 --- a/test/unit/snap_diff_deprecation_test.rb +++ b/test/unit/snap_diff_deprecation_test.rb @@ -49,6 +49,17 @@ def capture_warnings assert_match(/New::Thing/, lines.first) end + # Actionable attribution: the first caller frame OUTSIDE the gem's lib + # dir is named, so users can find the deprecated reference. This test + # file plays the part of "user code" -- the warning must point here. + test "warning names the caller's file and line" do + lines = capture_warnings do + SnapDiff::Deprecation.warn("Old::Where", "New::Where", category: :constant) + end + + assert_match(/called from #{Regexp.escape(File.expand_path(__FILE__))}:\d+/, lines.first) + end + test "warns separately for different subjects" do lines = capture_warnings do SnapDiff::Deprecation.warn("Old::A", "New::A", category: :constant) diff --git a/test/unit/snap_diff_test.rb b/test/unit/snap_diff_test.rb index 181fe59d..5f3bcf65 100644 --- a/test/unit/snap_diff_test.rb +++ b/test/unit/snap_diff_test.rb @@ -74,6 +74,47 @@ class SnapDiffTest < ActiveSupport::TestCase assert status.success?, "expected bare `require \"snap_diff\"` to annotate a difference, got:\n#{out}" end + # Acyclicity contract (the #208 deadlock-class fix): the lean + # `require "snap_diff"` entry must NEVER pull the umbrella + # capybara_screenshot_diff.rb back in. The old autoload wiring had + # snap_diff <-> capybara_screenshot_diff requiring each other, which + # produced load-order deadlocks/partially-initialized constants; #208 + # broke the cycle, but until now only discipline guarded it -- a probe + # that reintroduced the cycle left the whole suite green. This asserts + # the contract as data: after a bare require, the umbrella file must be + # absent from $LOADED_FEATURES. + test "bare require \"snap_diff\" never loads the umbrella capybara_screenshot_diff" do + script = <<~RUBY + require "snap_diff" + umbrella = $LOADED_FEATURES.grep(%r{/lib/capybara_screenshot_diff\\.rb\\z}) + abort("umbrella loaded via: \#{umbrella.join(", ")}") unless umbrella.empty? + RUBY + + out, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", script) + + assert status.success?, "expected bare `require \"snap_diff\"` to keep the umbrella unloaded, got:\n#{out}" + end + + # Dual-install guard: both gem names ship identical files, so with BOTH + # activated every require silently resolves from whichever gem activated + # first -- version skew between them is undetectable. The entry point + # refuses that setup outright. + test "raises when both capybara-screenshot-diff and snap_diff-capybara are activated" do + specs = {"capybara-screenshot-diff" => :spec, "snap_diff-capybara" => :spec} + + error = assert_raises(SnapDiff::DualInstallError) { SnapDiff.assert_single_gem!(specs) } + + assert_match(/capybara-screenshot-diff/, error.message) + assert_match(/snap_diff-capybara/, error.message) + assert_match(/remove one/i, error.message) + end + + test "dual-install guard passes single-gem installs and local dev from source" do + SnapDiff.assert_single_gem!({"capybara-screenshot-diff" => :spec}) + SnapDiff.assert_single_gem!({"snap_diff-capybara" => :spec}) + SnapDiff.assert_single_gem!({}) # local dev from source: neither spec loaded + end + test ".start yields the same objects Diff.configure yields" do yielded = [] Capybara::Screenshot::Diff.configure { |screenshot, diff| yielded << [screenshot, diff] }