Skip to content

Commit f8442ea

Browse files
Fix baseline badge level change detection, CDN purge, and email notification (#2763)
When a project earns or loses a baseline badge level, the system was not detecting the change, so no congratulations/warning flash message or email was sent. Things worked fine in the metal series, but while we were adding baseline we stubbed out parts because we added only one baseline level at first. However, that's no longer true. Also, update_all_badge_percentages (run after criteria rule changes) was not purging the CDN, leaving stale badge images cached. Specific fixes: - current_working_level: return project.baseline_badge_level (e.g. 'in_progress', 'baseline-1') for baseline sections, not the fixed criteria_level string. This makes gain/loss detection work for baseline the same way it already worked for metal badges. - update_all_badge_percentages: call FastlyRails.purge_all on completion. When criteria rules change and all badge percentages are recalculated, the CDN cache is now cleared automatically. No-op in test/development where Fastly credentials are absent. To force recalculation and purge the CDN cache in production: heroku run --app production-bestpractices rake update_all_badge_percentages - email_owner: accept badge_suffix parameter ('badge' or 'baseline') from caller instead of deriving it internally, keeping structural decisions out of the mailer. - Sections.badge_url_suffix: new single source of truth for the badge image URL path suffix per series type, backed by BADGE_URL_SUFFIX map. Both call sites (controller and view) now use this instead of independent ternary expressions. - gained_level.text.erb: use @badge_suffix in badge embedding snippets so baseline badge earners see /baseline not /badge. Also fix a pre-existing bug where the HTML snippet had <% @hostname %> (missing =) silently dropping the hostname. - docs/baseline_update.md: document the recalculation migration step needed when completing a baseline update transition (activating future criteria), and note that it automatically purges the CDN. - Tests: fix broken current_working_level test (was asserting old wrong behavior), add explicit in_progress->baseline-1 test, add email_owner tests for metal vs baseline suffix. Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1ed9cdc commit f8442ea

9 files changed

Lines changed: 108 additions & 13 deletions

File tree

app/controllers/projects_controller.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,22 +1569,24 @@ def successful_update(format, old_badge_level, criteria_level)
15691569
new_badge_level: new_badge_level
15701570
)
15711571
end
1572+
badge_suffix = Sections.badge_url_suffix(criteria_level)
15721573
ReportMailer.email_owner(
1573-
@project, old_badge_level, new_badge_level, lost_level
1574+
@project, old_badge_level, new_badge_level, lost_level, badge_suffix
15741575
).deliver_now
15751576
end
15761577
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
15771578
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
15781579

15791580
# Determines the current working level based on criteria level or badge level.
1580-
# For baseline levels, returns the criteria level being edited.
1581+
# For baseline levels, returns the project's actual achieved baseline badge level
1582+
# so that gains/losses are detected correctly (e.g., 'in_progress' -> 'baseline-2').
15811583
# For traditional badge levels, returns the project's actual badge level.
15821584
# @param criteria_level [String, nil] The criteria level being edited
15831585
# @param project [Project] The project whose badge level to check
15841586
# @return [String] The current working level
15851587
def current_working_level(criteria_level, project)
1586-
if Project::CRITERIA_SERIES[:baseline].include?(criteria_level)
1587-
criteria_level
1588+
if Sections.section_type(criteria_level) == :baseline
1589+
project.baseline_badge_level
15881590
else
15891591
project.badge_level
15901592
end

app/mailers/report_mailer.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ def subject_for(old_badge_level, new_badge_level, lost_level)
7070
# @param old_badge_level [String] The previous badge level
7171
# @param new_badge_level [String] The new badge level
7272
# @param lost_level [Boolean] True if the project lost badge status
73+
# @param badge_suffix [String] URL suffix for badge image ('badge' or 'baseline')
7374
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
7475
# rubocop:disable Metrics/PerceivedComplexity
75-
def email_owner(project, old_badge_level, new_badge_level, lost_level)
76+
def email_owner(project, old_badge_level, new_badge_level, lost_level, badge_suffix)
7677
return if project.nil? || project.id.nil? || project.user_id.nil?
7778

7879
@project = project
@@ -89,6 +90,7 @@ def email_owner(project, old_badge_level, new_badge_level, lost_level)
8990
@email_destination = user.email
9091
@new_level = new_badge_level
9192
@old_level = old_badge_level
93+
@badge_suffix = badge_suffix
9294
@hostname = ENV.fetch('PUBLIC_HOSTNAME', 'localhost')
9395
set_standard_headers
9496
I18n.with_locale(user.preferred_locale.to_sym) do

app/models/project.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,12 @@ def self.update_all_badge_percentages(levels)
682682
end
683683
end
684684
Project.skip_callbacks = false
685+
# Purge the entire CDN cache because badge levels may have changed for
686+
# many projects, and we have no cheap way to know which ones changed.
687+
# This method is run rarely (only when criteria rules change), so a
688+
# full purge is acceptable. It is a no-op in test/development where
689+
# Fastly credentials are absent.
690+
FastlyRails.purge_all
685691
end
686692
# rubocop:enable Metrics/MethodLength
687693

app/views/projects/_form_early.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
badge_img_src = is_baseline ? project.baseline_badge_src_url : project.badge_src_url
2525
badge_value = is_baseline ? project.baseline_badge_value : project.badge_level
2626
badge_level = is_baseline ? project.baseline_badge_level : project.badge_level
27-
badge_url_suffix = is_baseline ? 'baseline' : 'badge'
27+
badge_url_suffix = Sections.badge_url_suffix(criteria_level)
2828
badge_alt_text = is_baseline ? 'OpenSSF Baseline' : 'OpenSSF Best Practices'
2929
%>
3030
<%= t("projects.form_early.#{badge_i18n_key}.description_1") %>

app/views/report_mailer/gained_level.text.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
<%= t 'report_mailer.gained_level_part2' %>
55
* <%= t 'report_mailer.in_markdown_add' %>
6-
[![OpenSSF Best Practices](https://<%= @hostname %>/projects/<%= @project.id %>/badge)](https://<%= @hostname %>/projects/<%= @project.id %>)
6+
[![OpenSSF Best Practices](https://<%= @hostname %>/projects/<%= @project.id %>/<%= @badge_suffix %>)](https://<%= @hostname %>/projects/<%= @project.id %>)
77
* <%= t 'report_mailer.in_html_add' %>
8-
<a href="https://<%= @hostname %>/projects/<%= @project.id %>"><img src="https://<% @hostname %>/projects/<%= @project.id %>/badge"></a>
8+
<a href="https://<%= @hostname %>/projects/<%= @project.id %>"><img src="https://<%= @hostname %>/projects/<%= @project.id %>/<%= @badge_suffix %>"></a>
99

1010
<%= t 'report_mailer.gained_level_part3' %>
1111

config/initializers/01_section_names.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,20 @@ module Sections
159159
def self.section_type(section)
160160
SECTION_TYPE_MAP[section.to_s]
161161
end
162+
163+
# Authoritative mapping from badge series type to its badge image URL suffix.
164+
# This is the single source of truth for badge URL path endings
165+
# (e.g., /projects/1/badge vs /projects/1/baseline).
166+
# Add an entry here when introducing a new badge series.
167+
BADGE_URL_SUFFIX = {
168+
metal: 'badge',
169+
baseline: 'baseline'
170+
}.freeze
171+
172+
# Returns the badge image URL suffix for the given section.
173+
# @param section [String] the section name (canonical, obsolete, or internal)
174+
# @return [String] the URL suffix, e.g. 'badge' or 'baseline'
175+
def self.badge_url_suffix(section)
176+
BADGE_URL_SUFFIX.fetch(section_type(section), 'badge')
177+
end
162178
end

docs/baseline_update.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,52 @@ criteria from display, and close out the version notice.
439439
```
440440

441441
5. **Restart the application.** The newly activated criteria will be
442-
included in `Criteria.active(level)` and will count toward badge
443-
percentages. Obsolete criteria will no longer appear on the form.
442+
included in `Criteria.active(level)`. Obsolete criteria will no
443+
longer appear on the form.
444444

445-
6. **Verify** from the Rails console:
445+
6. **Recalculate badge percentages and purge the CDN.** After the
446+
application restarts, the stored `badge_percentage_baseline_*`
447+
values in the database are stale — they were computed under the old
448+
set of active criteria. Projects will be corrected one-by-one as
449+
their owners save changes, but you must force an immediate bulk
450+
recalculation so that every project's badge reflects the new active
451+
criteria right away.
452+
453+
Create a migration:
454+
455+
```bash
456+
rails generate migration RecalcBaselineBadgePercentages
457+
```
458+
459+
Edit the generated file (in `db/migrate/`) to contain:
460+
461+
```ruby
462+
# frozen_string_literal: true
463+
464+
class RecalcBaselineBadgePercentages < ActiveRecord::Migration[8.0]
465+
def change
466+
# Baseline criteria set has changed (futures activated, obsoletes
467+
# removed), so stored badge_percentage_baseline_* values are stale.
468+
# Recalculate for all projects at all baseline levels.
469+
# update_all_badge_percentages also calls FastlyRails.purge_all,
470+
# so the CDN cache is cleared and badges update immediately.
471+
Project.update_all_badge_percentages(Sections::BASELINE_LEVEL_NAMES)
472+
end
473+
end
474+
```
475+
476+
Apply the migration:
477+
478+
```bash
479+
rails db:migrate
480+
```
481+
482+
`update_all_badge_percentages` calls `FastlyRails.purge_all` on
483+
completion, so the CDN badge cache is purged automatically — no
484+
manual cache invalidation is needed. The next request for any
485+
project's `/baseline` badge will fetch the freshly computed value.
486+
487+
7. **Verify** from the Rails console:
446488

447489
```ruby
448490
# Previously-future criteria should now be active:

test/controllers/projects_controller_test.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,11 +2064,20 @@ def repos(_user, **)
20642064
end
20652065

20662066
# Unit tests for private helper methods
2067-
test 'current_working_level returns criteria_level for baseline levels' do
2067+
test 'current_working_level returns baseline_badge_level for baseline levels' do
20682068
controller = ProjectsController.new
20692069
project = projects(:perfect_passing)
20702070
result = controller.send(:current_working_level, 'baseline-1', project)
2071-
assert_equal 'baseline-1', result
2071+
assert_equal project.baseline_badge_level, result
2072+
end
2073+
2074+
test 'current_working_level returns in_progress for baseline when no baseline earned' do
2075+
# Ensures in_progress is returned for baseline series (not just metal),
2076+
# so earning baseline-1 from scratch is detected as a level change.
2077+
controller = ProjectsController.new
2078+
project = projects(:perfect_passing) # no badge_percentage_baseline_* set
2079+
result = controller.send(:current_working_level, 'baseline-1', project)
2080+
assert_equal 'in_progress', result
20722081
end
20732082

20742083
test 'current_working_level returns project badge_level for non-baseline' do

test/mailers/report_mailer_test.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ class ReportMailerTest < ActionMailer::TestCase
2626
assert_predicate email.subject, :present?
2727
end
2828

29+
test 'email_owner gained metal badge uses /badge suffix' do
30+
email = ReportMailer.email_owner(
31+
@perfect_project, 'in_progress', 'passing', false, 'badge'
32+
).deliver_now
33+
assert_not ActionMailer::Base.deliveries.empty?
34+
assert_includes email.body.to_s, '/badge'
35+
assert_not_includes email.body.to_s, '/baseline'
36+
end
37+
38+
test 'email_owner gained baseline badge uses /baseline suffix' do
39+
email = ReportMailer.email_owner(
40+
@perfect_project, 'in_progress', 'baseline-1', false, 'baseline'
41+
).deliver_now
42+
assert_not ActionMailer::Base.deliveries.empty?
43+
assert_includes email.body.to_s, '/baseline'
44+
assert_not_includes email.body.to_s, '/badge'
45+
end
46+
2947
test 'Does the monthly announcement run?' do
3048
# This is a quick sanity test, not an in-depth test.
3149
# Use 'example.org' per RFC 2606

0 commit comments

Comments
 (0)