Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ permissions:
env:
CI: '1'

# Audited dependency sets: the committed root Gemfile.lock (job "audit") plus
# the sets the matrix jobs resolve from the unlocked gemfiles/standalone.gemfile
# and gemfiles/rails.gemfile. ruby/setup-ruby resolves those gemfiles during the
# job and writes the lockfile next to the gemfile (gemfiles/*.lock, gitignored,
# never committed); the audit of that lockfile rides on rake compile (rake
# audit:advisories -> bin/audit-advisories), the task every matrix entry
# already runs, so each entry audits the set it actually resolved, in the job
# that installed it.
# bundle-audit exits 1 on an advisory, so a finding fails the matrix entry and
# with it the aggregate "CI OK" job.
#
# Branch protection only needs to require the final "CI OK" check.
# Gems are cached by ruby/setup-ruby (bundler-cache), keyed on the lockfile or
# gemfile, Ruby version and OS.
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Writing ruby2html templates/components (here or in a user app)? Load [`llms.txt`
| Demo Rails app | `app/`, `config/`, `db/` | Not packaged; loads the gem via `require_relative '../lib/gem/ruby2html'` |
| Specs | `spec/gem/` (renderer, attributes, escaping, `render_parity_spec.rb`; `render_spec_helper.rb` runs each group against Native **and** PureRuby in one process), `spec/gem/rails/` (handler, compiler, components, middleware), `spec/features/`, `spec/benchmarks/` | Rails-booting via `spec/rails_helper.rb`; `rake spec:standalone` runs `spec/gem` minus `rails/` without Rails |
| Benchmarks | `bench/*.rb` (standalone), `spec/benchmarks/requests_spec.rb` (request-level vs ERB/Slim/Phlex) | |
| Scripts | `bin/gem-smoke`, `bin/changelog-notes`, `bin/build-ext` | Packaging smoke test; print a CHANGELOG section; CI-style extension builds (`--werror`, `--cc=clang`, `--sanitize`) |
| Scripts | `bin/gem-smoke`, `bin/changelog-notes`, `bin/build-ext`, `bin/audit-advisories` | Packaging smoke test; print a CHANGELOG section; CI-style extension builds (`--werror`, `--cc=clang`, `--sanitize`); audit one resolved lockfile against the Ruby advisory database (`bundle-audit check --update --gemfile-lock <lockfile>`; exits non-zero on an advisory, except the reviewed advisories the script accepts, scoped to the exact reviewed version the lockfile resolves) |

- Supported: all maintained CRuby versions (>= 3.1, gemspec floor; CI tracks the official list — currently 3.3, 3.4, 4.0), JRuby (pure Ruby + the Java escaper), TruffleRuby (pure Ruby), Rails 7.2+.
- Dev Ruby: `.ruby-version` (latest stable). The CRuby CI matrix is generated automatically from the maintained-versions list, so a new release (e.g. a new 4.x) joins CI with no edit.
Expand All @@ -42,7 +42,8 @@ Writing ruby2html templates/components (here or in a user app)? Load [`llms.txt`
| Command | Notes |
|---|---|
| `bundle install` | — |
| `bundle exec rake compile` | C ext → `lib/ruby2html/ruby2html.so`; `RUBY2HTML_NO_SIMD=1` for the scalar escape path |
| `bundle exec rake compile` | C ext → `lib/ruby2html/ruby2html.so`; `RUBY2HTML_NO_SIMD=1` for the scalar escape path; carries `audit:advisories` (CI only; the `spec` tasks carry it too, so `SKIP_COMPILE=1` does not drop it) |
| `bundle exec rake audit:advisories`, `bin/audit-advisories [lockfile]` | audits one resolved lockfile — `<BUNDLE_GEMFILE>.lock` (the script reads `ENV['BUNDLE_GEMFILE']` itself; the rake task runs it with the variable still set, only its child processes get the Bundler keys stripped), else `Gemfile.lock`; an explicit path wins — against the Ruby advisory database via `bundle-audit check --update --gemfile-lock`; the rake task runs only in CI (`CI=1`) and only for the unlocked `gemfiles/*.gemfile` matrix sets; exits non-zero on an advisory, except the advisories `bin/audit-advisories` accepts for the floor lines the matrix resolves on purpose (Rails 7.2.x, Active Support 7.2.x on Ruby 3.1): those are listed by advisory id in the script, scoped to the exact reviewed version — a moved resolution drops every exception and fails the audit until the list is re-reviewed |
| `CI=1 bundle exec rspec` | full suite minus the ~6 min benchmark spec (skipped when CI is set) |
| `bundle exec rspec spec/gem` | renderer + Rails integration units |
| `bundle exec rake spec` | compiles first (`SKIP_COMPILE=1` to skip); `spec:gem` = `spec/gem`; `spec:standalone` = `spec/gem` without Rails |
Expand All @@ -54,7 +55,7 @@ Writing ruby2html templates/components (here or in a user app)? Load [`llms.txt`
| `bundle exec rake smoke` | build + `bin/gem-smoke`: file list check, install into temp GEM_HOME, render without Rails |
| `bin/rails server` | demo: `/`, `/rb_files`, `/form`, `/benchmark/{html,ruby,compruby,slim,phlex}` |

- Gem tasks (`compile clean clobber spec spec:gem spec:standalone bench smoke build install release default`) do not boot Rails; any other task loads the Rails tasks. `RUBY2HTML_RAILS_TASKS=1` forces that.
- Gem tasks (`compile clean clobber spec spec:gem spec:standalone bench smoke build install release default audit:advisories`) do not boot Rails; any other task loads the Rails tasks. `RUBY2HTML_RAILS_TASKS=1` forces that.
- After editing `ext/` always `rake compile`; otherwise specs run the stale `.so` (or PureRuby if none).
- Never run `rake release`, push `v*` tags or `gem push`: merging a version bump to `main` is the release (`docs/release.md`).

Expand Down Expand Up @@ -122,7 +123,7 @@ Goal: the repo needs no hand maintenance except major-version dependency upgrade
|---|---|
| Maintainer agent | Repo managed by [developerz.ai](https://developerz.ai) (policy: `.dz/maintainer/maintainer.yml`): triages issues, reviews and merges every PR (contributors, members, bots) once its author marks it ready (out of draft), it is reviewed and `CI OK` is green; daily `health` + weekday `release-due` checkups and a monthly `improve` pass (`.dz/briefs/`: canary fixes, dependency majors, speed with benchmark evidence, correctness, docs drift); opens release PRs; breaking changes escalate to the owner. Sibling projects such as [wurk](https://github.com/sebyx07/wurk) run the same model |
| Dependencies | Dependabot (`.github/dependabot.yml`) opens grouped weekly PRs: app dependencies wait out a 14-day cooldown (security updates skip it), while the dev-tools group is exempt from it (`cooldown.exclude`) and is proposed within days; the agent reviews and merges on green — the only merger |
| CI | `.github/workflows/ci.yml`; branch protection requires only the aggregate `CI OK` job: RuboCop, bundle-audit, full suite + Brakeman, gem specs across the Ruby matrix (generated from maintained CRuby versions), native builds on Linux/macOS arm64, `-Werror` with gcc and clang, ASAN/UBSAN (`use_sigaltstack=0`), pure-Ruby fallback, JRuby (Java escaper jar + `-java` gem) and TruffleRuby (pure Ruby) via `gemfiles/standalone.gemfile`, Rails integration per `gemfiles/rails.gemfile`, `bin/gem-smoke` packaging on plain RubyGems. Every job required; no `continue-on-error` |
| CI | `.github/workflows/ci.yml`; branch protection requires only the aggregate `CI OK` job: RuboCop, bundle-audit (the committed root `Gemfile.lock` in the `audit` job, plus the resolved sets of the unlocked matrix gemfiles `gemfiles/standalone.gemfile` and `gemfiles/rails.gemfile` — the matrix sets are audited by `bin/audit-advisories`, which the `audit:advisories` task rides on `rake compile` and the `spec` tasks (so `SKIP_COMPILE=1` cannot drop it), so every matrix entry audits the set it just resolved; the advisories of the floor lines the matrix resolves on purpose (Rails 7.2.x, Active Support 7.2.x on Ruby 3.1) are accepted by advisory id in `bin/audit-advisories`, scoped to the exact reviewed version, every other advisory fails the entry), full suite + Brakeman, gem specs across the Ruby matrix (generated from maintained CRuby versions), native builds on Linux/macOS arm64, `-Werror` with gcc and clang, ASAN/UBSAN (`use_sigaltstack=0`), pure-Ruby fallback, JRuby (Java escaper jar + `-java` gem) and TruffleRuby (pure Ruby) via `gemfiles/standalone.gemfile`, Rails integration per `gemfiles/rails.gemfile`, `bin/gem-smoke` packaging on plain RubyGems. Every job required; no `continue-on-error` |
| Canary | `.github/workflows/canary.yml`, monthly on the 1st, not gating: Ruby head and Rails edge surface upstream breaks before a release ships them; the monthly `improve` checkup fixes what it finds |
| Benchmarks | `.github/workflows/benchmark.yml`, non-gating: every push to `main` and PR renders a table into the job summary and uploads JSON; PRs get a sticky comment with the delta vs `main`. Set repo variable `BENCH_RUNNER` for a stable self-hosted runner; README holds only a dated release snapshot |
| Releases | `.github/workflows/release.yml`: merging a `version.rb` bump to `main` (no `v<VERSION>` tag yet) runs the tests and `rake smoke`, builds the `-java` gem on JRuby (jar inside), publishes **both** the C-extension gem and the `-java` gem via RubyGems trusted publishing (environment `release`), then creates the tag and the GitHub release from `bin/changelog-notes`. The release PR is opened by the agent or the `release` skill (`docs/release.md`) |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Entries before 1.6.6 were reconstructed from `git log` and RubyGems release date

## [Unreleased]

### Changed

- CI now audits every matrix entry's resolved dependency set against the Ruby advisory database — the `audit` job only checked the committed root `Gemfile.lock`, which keeps its own `bundle-audit` job. The matrix jobs install an unlocked gemfile, so Bundler resolves it during the job and writes the lockfile next to it (`gemfiles/standalone.gemfile.lock`, `gemfiles/rails.gemfile.lock`); the audit of that resolved lockfile rides on `rake compile` (`audit:advisories` → `bin/audit-advisories`), the task every matrix entry already runs, so each entry audits the set it actually installed, in the job that installed it. An advisory now fails the matrix entry and, with it, the aggregate `CI OK` job — except the advisories of the floor lines the matrix resolves on purpose (Rails 7.2.x, Active Support 7.2.x on Ruby 3.1, where Active Support 8 does not install): upstream patches those lines no more, so they are listed by advisory id in `bin/audit-advisories` and accepted only when the lockfile resolves the exact reviewed version (the five Rails-line components at 7.2.3, pinned by the rails 7.2 gem; Active Support, which the standalone gemfile on Ruby 3.1 resolves unlocked, additionally at 7.2.4 — that patch fixes three of its four advisories, while the `safe_constantize` DoS (`GHSA-8474-xrp6-wh69`) is fixed only in the 8.x lines) — a moved resolution drops every exception and fails the audit until the list is re-reviewed. The audit also rides on the `spec` tasks, so `SKIP_COMPILE=1` (strict extension builds, the pure-Ruby fallback) cannot drop it, and a signal-killed `bundle-audit` is reported as killed instead of looking like an advisory result. Every other line (Rails 8.0, 8.1, Active Support 8) is audited without any exception, and an advisory that is not listed still fails the entry.

### Fixed

- Appending a renderer's own output buffer to itself (`plain(current_output)`, `component(current_output)`) could read freed memory with the C extension: growing the buffer while its own bytes were still being read moved them out from under the read, which aborted under ASAN in the sanitizer CI job and could crash or corrupt output anywhere. Escaped and raw appends (`r2h_append_escaped`, `r2h_append_raw`) now duplicate the string first when it is the buffer; the pure-Ruby renderer was never affected, and output stays byte-identical (pinned by the new "can append the output buffer to itself" example, which runs against both implementations).
Expand Down
40 changes: 39 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

require 'rake/clean'

GEM_TASK = /\A(default|compile(:.*)?|clean|clobber|spec(:gem|:standalone)?|bench|smoke|build(:.*)?|install(:.*)?|release(:.*)?)\z/
GEM_TASK = /\A(default|compile(:.*)?|clean|clobber|spec(:gem|:standalone)?|bench|smoke|build(:.*)?|install(:.*)?|release(:.*)?|audit(:.*)?)\z/

requested = Rake.application.top_level_tasks.map { |t| t.split('[').first }
listing = Rake.application.options.show_tasks || Rake.application.options.show_prereqs # rake -T / -D / -W / -P
Expand Down Expand Up @@ -69,6 +69,35 @@ else
end
end

# --- Dependency audit ----------------------------------------------------------
# The matrix jobs install an unlocked gemfile (gemfiles/standalone.gemfile,
# gemfiles/rails.gemfile): ruby/setup-ruby resolves it during the job and writes
# the lockfile next to it (gemfiles/*.lock, gitignored, never committed). That
# resolved set is what the job installs, so it is what has to be audited against
# the Ruby advisory database; the committed root Gemfile.lock has its own CI job.
#
# The audit rides on compile, the one task every matrix entry runs (spec:gem and
# spec:standalone depend on it), so it needs no extra step in the workflow. The
# spec tasks carry it too, so SKIP_COMPILE=1 (strict extension builds, the
# pure-Ruby fallback) cannot drop the audit of the set the entry resolved; rake
# invokes a task once per run, so it still runs exactly once per entry.
#
# bin/audit-advisories accepts the reviewed advisories of the floor lines the
# matrix resolves on purpose (Rails 7.2.x, Active Support 7.2.x on Ruby 3.1),
# listed by advisory id in the script and scoped to the resolved version. Every
# other advisory fails the entry, and with it the aggregate CI OK job.
desc 'Audit the resolved lockfile against the Ruby advisory database (CI only)'
task 'audit:advisories' do
gemfile = ENV['BUNDLE_GEMFILE'].to_s
if ENV['CI'] && File.basename(gemfile).end_with?('.gemfile')
ruby File.expand_path('bin/audit-advisories', __dir__)
Comment thread
developerz-ai[bot] marked this conversation as resolved.
Resolved
else
puts 'audit:advisories skipped (CI only, and only for the unlocked matrix gemfiles)'
end
end

Rake::Task[:compile].enhance(['audit:advisories'])
Comment thread
developerz-ai[bot] marked this conversation as resolved.
Resolved

# --- Packaging (build / install / release into pkg/) ----------------------------
# Building a source gem does not need a compiled extension or a Rails boot.
require 'bundler/gem_helper'
Expand Down Expand Up @@ -111,6 +140,15 @@ if defined?(RSpec::Core::RakeTask)
%w[spec:gem spec:standalone].each { |t| Rake::Task[t].enhance([:compile]) } unless ENV['SKIP_COMPILE'] == '1'
end

# The audit does not depend on the extension: it must also run when compile is
# skipped (SKIP_COMPILE=1, and any entry that skips it in the future). rake
# invokes a task once per run, so entries that reach it through compile do not
# audit twice; outside CI, or outside the unlocked matrix gemfiles, the task
# body skips itself.
%w[spec spec:gem spec:standalone].each do |name|
Rake::Task[name].enhance(['audit:advisories']) if Rake::Task.task_defined?(name)
end

# --- Benchmarks ----------------------------------------------------------------
desc 'Run every bench/*.rb script (compiles the extension first)'
task bench: :compile do
Expand Down
Loading
Loading