Add fork-invariants CI guard + remove re-introduced Pro gates

Upstream syncs repeatedly re-introduce DocuSeal's freemium gates, delete
fork code, overwrite brand assets, and drop AGPL attribution; recovery took
~7 repair commits after the 3.0.2 sync. rebrand-check only catches surviving
DocuSeal *text*, and the REBRANDING.md post-merge checklist was manual and
not run reliably (two regressions it claims were fixed were still live).

Add bin/fork-check, a stdlib-only runner driven by config/fork_invariants.yml,
asserting: must-exist fork files/brand assets, must-not-exist placeholders /
console_redirect / lib/docuseal.rb (Zeitwerk guard), must-contain attribution
+ renamed identifiers + SDK tokens, path-scoped must-not-contain gate markers,
forbidden global markers, forbidden i18n keys, no dangling partial renders,
and PRESERVE<->ALLOW_PATTERNS consistency between rebrand-sync/rebrand-check.
Wired into CI as the 'Fork invariants' job. Allowlist the new guard + manifest
in rebrand-check and deny them in rebrand-sync so the sweep can't corrupt them.

Baseline cleanup so the new gate-absence checks pass (both documented as
removed in REBRANDING.md but live on master): remove the ENTERPRISE_PATHS
'Pro Edition' export paywall from errors_controller.rb, and the multitenant?
reminder-duration gate from _reminder_form.html.erb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/687/head
Wabo 2 weeks ago
parent 16e830565f
commit 6ef4a55bcd

@ -162,6 +162,15 @@ jobs:
- name: Run rebrand-check - name: Run rebrand-check
run: bin/rebrand-check run: bin/rebrand-check
fork_check:
name: Fork invariants
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Run fork-check
run: bin/fork-check
rspec: rspec:
name: RSpec name: RSpec
runs-on: ubuntu-latest runs-on: ubuntu-latest

@ -1,34 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class ErrorsController < ActionController::Base class ErrorsController < ActionController::Base
ENTERPRISE_FEATURE_MESSAGE =
'This feature is available in Pro Edition: https://www.wabosign.com/pricing'
ENTERPRISE_PATHS = [
'/submissions/html',
'/api/submissions/html',
'/templates/html',
'/api/templates/html',
'/submissions/pdf',
'/api/submissions/pdf',
'/templates/pdf',
'/api/templates/pdf',
'/templates/doc',
'/api/templates/doc',
'/templates/docx',
'/api/templates/docx'
].freeze
SAFE_ERROR_MESSAGE_CLASSES = [ SAFE_ERROR_MESSAGE_CLASSES = [
ActionDispatch::Http::Parameters::ParseError, ActionDispatch::Http::Parameters::ParseError,
JSON::ParserError JSON::ParserError
].freeze ].freeze
def show def show
if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
end
respond_to do |f| respond_to do |f|
f.json do f.json do
set_cors_headers set_cors_headers

@ -2,7 +2,7 @@
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<div class="form-control"> <div class="form-control">
<% record = Struct.new(:first_duration, :second_duration, :third_duration).new(*(f.object.value || {}).values_at('first_duration', 'second_duration', 'third_duration')) %> <% record = Struct.new(:first_duration, :second_duration, :third_duration).new(*(f.object.value || {}).values_at('first_duration', 'second_duration', 'third_duration')) %>
<% durations = (Wabosign.multitenant? ? AccountConfigs::REMINDER_DURATIONS.except('one_hour', 'two_hours') : AccountConfigs::REMINDER_DURATIONS).keys.map { |v| [t(v.underscore), v] } %> <% durations = AccountConfigs::REMINDER_DURATIONS.keys.map { |v| [t(v.underscore), v] } %>
<div class="flex flex-col md:flex-row gap-2"> <div class="flex flex-col md:flex-row gap-2">
<div class="w-full"> <div class="w-full">
<%= f.fields_for :value, record do |ff| %> <%= f.fields_for :value, record do |ff| %>

@ -0,0 +1,229 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# bin/fork-check — assert the WaboSign fork invariants declared in
# config/fork_invariants.yml. Exits 1 (printing every violation) if any
# invariant is broken; prints "fork-check: ok" and exits 0 otherwise.
#
# This is the executable form of the REBRANDING.md post-merge checklist. It
# catches an upstream merge that re-introduces a Pro gate, deletes fork code,
# overwrites AGPL attribution, leaves a dangling partial render, or drifts the
# rename PRESERVE/ALLOW lists apart. To add or change an invariant, edit
# config/fork_invariants.yml (not this file). Stdlib-only, so the CI job needs
# no gem install. Wired into CI via .github/workflows/ci.yml.
require 'find'
require 'set'
require 'yaml'
ROOT = File.expand_path('..', __dir__)
MANIFEST = File.join(ROOT, 'config/fork_invariants.yml')
# Files whose job is to *name* the forbidden markers — never scan them in the
# tree-wide passes, or they would flag themselves.
SELF_REFERENTIAL = Set.new([
'config/fork_invariants.yml',
'bin/fork-check',
'bin/rebrand-sync',
'bin/rebrand-check',
'bin/sync-upstream',
'REBRANDING.md'
]).freeze
DENY_PREFIXES = [
'.git/', '.github/', 'node_modules/', 'vendor/bundle/', 'vendor/cache/',
'tmp/', 'log/', 'pg_data/', 'public/packs/', 'public/packs-test/',
'wabosign/', '.claude/', 'coverage/'
].freeze
BINARY_EXT = Set.new(%w[
.png .jpg .jpeg .gif .ico .svg .pdf .zip .tgz .gz .bz2 .xz
.woff .woff2 .ttf .eot .otf .pkcs12 .p12 .pem .crt .cer .key
.db .sqlite .sqlite3 .so .dylib .dll .exe .onnx
]).freeze
def abs(rel)
File.join(ROOT, rel)
end
def likely_binary?(path)
return true if BINARY_EXT.include?(File.extname(path).downcase)
File.binread(path, 1024).include?("\x00")
rescue StandardError
false
end
def marker_list(entry)
return [entry['marker']] if entry['marker']
return entry['marker_any'] if entry['marker_any']
return entry['marker_all'] if entry['marker_all']
[]
end
def hit?(content, marker)
if marker.start_with?('regex:')
content.match?(Regexp.new(marker.sub(/\Aregex:/, '')))
else
content.include?(marker)
end
end
def first_lineno(content, marker)
rx = marker.start_with?('regex:') ? Regexp.new(marker.sub(/\Aregex:/, '')) : nil
content.each_line.with_index(1) do |line, n|
return n if rx ? line.match?(rx) : line.include?(marker)
end
nil
end
def read_lines(path)
File.readlines(path, encoding: 'UTF-8')
rescue StandardError
[]
end
manifest = YAML.safe_load(File.read(MANIFEST)) || {}
violations = []
# 1. Files that must exist (fork code + brand assets upstream tends to delete).
Array(manifest['must_exist']).each do |path|
violations << "must_exist: missing required file: #{path}" unless File.exist?(abs(path))
end
# 2. Files that must not exist (placeholders / console controller / lib/docuseal.rb).
Array(manifest['must_not_exist']).each do |path|
violations << "must_not_exist: forbidden file present: #{path}" if File.exist?(abs(path))
end
# 3. Markers that must be present in a named file (attribution / identifiers / SDK).
Array(manifest['must_contain']).each do |entry|
path = entry['path']
unless File.exist?(abs(path))
violations << "must_contain: target file missing: #{path}"
next
end
content = File.read(abs(path))
markers = marker_list(entry)
if entry['marker_any']
unless markers.any? { |m| hit?(content, m) }
violations << "must_contain: #{path} contains none of #{markers.inspect} (#{entry['why']})"
end
else
markers.each do |m|
violations << "must_contain: #{path} is missing #{m.inspect} (#{entry['why']})" unless hit?(content, m)
end
end
end
# 4. Markers that must NOT appear in a named file (re-added Pro gates). Path-scoped.
Array(manifest['must_not_contain']).each do |entry|
path = entry['path']
next unless File.exist?(abs(path))
content = File.read(abs(path))
marker_list(entry).each do |m|
next unless hit?(content, m)
violations << "must_not_contain: #{path}:#{first_lineno(content, m)} has forbidden marker #{m.inspect} (#{entry['why']})"
end
end
# 5. Markers banned across the whole tree (kept tiny — prefer path-scoped above).
global = Array(manifest['forbidden_globally'])
unless global.empty?
Find.find(ROOT) do |p|
rel = p.sub(%r{\A#{Regexp.escape(ROOT)}/}, '')
if File.directory?(p)
Find.prune if rel != '' && DENY_PREFIXES.any? { |d| "#{rel}/".start_with?(d) }
next
end
next if SELF_REFERENTIAL.include?(rel)
next if DENY_PREFIXES.any? { |d| rel.start_with?(d) }
next if likely_binary?(p)
read_lines(p).each_with_index do |line, i|
global.each do |entry|
m = entry['marker']
violations << "forbidden_globally: #{rel}:#{i + 1} has #{m.inspect} (#{entry['why']})" if line.include?(m)
end
end
end
end
# 6. i18n keys that must never reappear (dead paywall/feature strings).
i18n_keys = Array(manifest['forbidden_i18n_keys'])
i18n_path = 'config/locales/i18n.yml'
if !i18n_keys.empty? && File.exist?(abs(i18n_path))
read_lines(abs(i18n_path)).each_with_index do |line, i|
i18n_keys.each do |k|
violations << "forbidden_i18n_keys: #{i18n_path}:#{i + 1} defines #{k}" if line.match?(/^\s*#{Regexp.escape(k)}:/)
end
end
end
# 7. Dangling partial renders: every render 'dir/name' must resolve to a file.
views_root = abs('app/views')
if File.directory?(views_root)
render_rx = %r{\brender\b(?:\s+partial:)?\s+['"]([a-z0-9_]+(?:/[a-z0-9_]+)+)['"]}
Find.find(views_root) do |p|
next if File.directory?(p)
next unless p.end_with?('.erb')
rel = p.sub(%r{\A#{Regexp.escape(ROOT)}/}, '')
read_lines(p).each_with_index do |line, i|
line.scan(render_rx).each do |(ref)|
dir, base = File.split(ref)
next unless Dir.glob(abs(File.join('app/views', dir, "_#{base}.*"))).empty?
violations << "dangling_partial: #{rel}:#{i + 1} renders '#{ref}' but app/views/#{dir}/_#{base}.* is missing"
end
end
end
end
# 8. PRESERVE (rebrand-sync) <-> ALLOW_PATTERNS (rebrand-check) consistency.
# Every preserved token containing "docuseal" must be tolerated by at least
# one allow-pattern, or the rename sweep and the survivor-check have drifted.
def extract_block(path, regex)
File.read(path).match(regex)&.captures&.first
end
preserve_src = extract_block(abs('bin/rebrand-sync'), /^PRESERVE = \{\n(.*?)^\}\.freeze/m)
allow_src = extract_block(abs('bin/rebrand-check'), /^ALLOW_PATTERNS = \[\n(.*?)^\]\.freeze/m)
if preserve_src.nil? || allow_src.nil?
violations << 'consistency: could not locate PRESERVE and/or ALLOW_PATTERNS blocks ' \
'(bin/fork-check parser needs updating)'
else
preserve_tokens = preserve_src.scan(/^\s*'([^']+)'\s*=>/).flatten
allow_patterns = []
allow_src.each_line do |l|
l.scan(/%r\{(.*?)\}/) { |c| allow_patterns << Regexp.new(c.first) }
end
if preserve_tokens.empty? || allow_patterns.empty?
violations << 'consistency: PRESERVE/ALLOW_PATTERNS extraction yielded zero entries (parser drift)'
else
preserve_tokens.select { |t| t.match?(/docuseal/i) }.each do |tok|
# SDK element tokens only ever appear wrapped (<tag>, "quoted", &lt;encoded),
# and the allow-patterns require that context — so test the token in the
# contexts the sweep actually protects, not just bare.
candidates = [tok, "<#{tok} ", "<#{tok}>", "</#{tok}>", "&lt;/#{tok}", "'#{tok}'", "\"#{tok}\""]
next if candidates.any? { |c| allow_patterns.any? { |re| c.match?(re) } }
violations << "consistency: PRESERVE token #{tok.inspect} (rebrand-sync) has no matching " \
'ALLOW_PATTERN (rebrand-check); the lists have drifted'
end
end
end
if violations.empty?
puts 'fork-check: ok'
exit 0
else
warn "fork-check: #{violations.size} invariant violation(s):"
violations.each { |v| warn " #{v}" }
exit 1
end

@ -53,6 +53,10 @@ ALLOW_FILES = Set.new([
'bin/rebrand-sync', 'bin/rebrand-sync',
'bin/rebrand-check', 'bin/rebrand-check',
'bin/sync-upstream', 'bin/sync-upstream',
# Fork-invariant guard + its manifest deliberately name "docuseal" tokens
# (e.g. lib/docuseal.rb, docuseal-form) as markers to assert on.
'bin/fork-check',
'config/fork_invariants.yml',
'lib/wabosign.rb', 'lib/wabosign.rb',
'lib/docuseal.rb', 'lib/docuseal.rb',
# Migration that finds rows by the legacy docuseal_aatl name. # Migration that finds rows by the legacy docuseal_aatl name.

@ -50,6 +50,11 @@ DENY_PATHS = Set.new([
'bin/rebrand-sync', 'bin/rebrand-sync',
'bin/rebrand-check', 'bin/rebrand-check',
'bin/sync-upstream', 'bin/sync-upstream',
# Fork-invariant guard + manifest: the sweep must not rewrite the "docuseal"
# tokens they intentionally assert on (e.g. the lib/docuseal.rb must_not_exist
# marker), or the guard would silently stop catching that regression.
'bin/fork-check',
'config/fork_invariants.yml',
# WaboSign brand logo files — must never be touched by the sweep; # WaboSign brand logo files — must never be touched by the sweep;
# restored from ORIG_HEAD by bin/sync-upstream after an upstream merge. # restored from ORIG_HEAD by bin/sync-upstream after an upstream merge.
'public/favicon.svg', 'public/favicon.svg',

@ -0,0 +1,126 @@
# WaboSign fork invariants — the executable form of the REBRANDING.md
# "Post-Merge Verification Checklist".
#
# bin/fork-check reads this file and fails (exit 1) if any invariant is
# violated. It runs in CI on every push, so an upstream merge that re-introduces
# a Pro gate, deletes fork code, overwrites attribution, or leaves a dangling
# partial fails the build instead of shipping silently.
#
# HOW TO EXTEND (for the next human or AI agent doing an upstream sync):
# - Upstream re-added a paywall/gate? Add a `must_not_contain` entry SCOPED to
# the exact file (see the multitenant? note below — never ban a token tree-
# wide unless it is genuinely unique to the gate).
# - Upstream deleted fork code / a brand asset? Add a `must_exist` entry.
# - Upstream re-added a placeholder / console controller? Add `must_not_exist`.
# - Always include a `why:` — it is the institutional memory the last sync
# wished it had.
#
# Marker semantics (must_contain / must_not_contain / forbidden_globally):
# marker: "<text>" single literal substring
# marker_any: ["a", "b"] at least one present (contain) / none present (not_contain)
# marker_all: ["a", "b"] all present (must_contain only)
# Prefix any marker value with "regex:" to match as a Ruby regular expression.
# ---------------------------------------------------------------------------
# Files that MUST exist. Upstream merges have deleted these fork-specific files
# (damage class #3) and overwritten brand assets (damage class #1).
# ---------------------------------------------------------------------------
must_exist:
# Independently-developed SMS stack (not in upstream OSS edition)
- lib/sms.rb
- lib/sms/providers/bulkvs.rb
- lib/sms/providers/twilio.rb
- lib/sms/providers/voipms.rb
- lib/sms/providers/signalwire.rb
- app/controllers/sms_settings_controller.rb
# Role-based authorization (fork feature; deleted by the 3.0.2 sync)
- lib/ability.rb
# Brand assets (binary; bypass the text sweep, so easy to lose silently)
- public/favicon.svg
- public/favicon.ico
- public/favicon-16x16.png
- public/favicon-32x32.png
- public/favicon-96x96.png
- public/logo.svg
# ---------------------------------------------------------------------------
# Files that MUST NOT exist. Upstream re-adds these on a sync.
# ---------------------------------------------------------------------------
must_not_exist:
# Re-adding this defines a `Docuseal` module that collides with lib/wabosign.rb
# under Zeitwerk → boot failure (damage class #4).
- lib/docuseal.rb
# Console/Upgrade redirect controller — there is no Console in WaboSign.
- app/controllers/console_redirect_controller.rb
# Pro-gated placeholder views that replace the real free forms.
- app/views/esign_settings/_default_signature_row.html.erb
- app/views/sso_settings/_placeholder.html.erb
# ---------------------------------------------------------------------------
# Markers that MUST be present in a specific file (damage classes #5 attribution
# and #3 renamed identifiers / SDK contract). Reading is scoped to the one file.
# ---------------------------------------------------------------------------
must_contain:
# --- AGPL §7(b) upstream attribution (must stay visible in interactive UIs) ---
- path: app/views/shared/_powered_by.html.erb
marker: "UPSTREAM_NAME"
why: "Footer credit links DocuSeal via Wabosign::UPSTREAM_NAME/URL — AGPL §7(b)."
- path: app/views/shared/_email_attribution.html.erb
marker: "product_name_is_a_fork_of_upstream_html"
why: "Email footer must state WaboSign is a fork of DocuSeal — AGPL §7(b)."
- path: app/javascript/submission_form/completed.vue
marker_any: ["fork_of", "DocuSeal"]
why: "Post-signing completion screen carries the DocuSeal fork credit."
- path: app/javascript/submission_form/calculator.js
marker: "DocuSeal"
why: "Upstream copyright header on this JS port must be retained."
# --- Renamed identifiers (sweep must have run; module must be Wabosign) ---
- path: lib/wabosign.rb
marker_all: ["module Wabosign", "PRODUCT_NAME", "AATL_CERT_NAME = 'wabosign_aatl'"]
why: "Core fork module + product/cert identity. Upstream resets these to DocuSeal."
- path: config/application.rb
marker: "module WaboSign"
why: "Rails app module must be the rebranded WaboSign, not DocuSeal."
# --- SDK embedding contract (intentionally keeps the docuseal-* names) ---
- path: app/views/templates/_embedding.html.erb
marker: "docuseal-form"
why: "Embedding docs must keep <docuseal-form> — SDK contract with downstream."
- path: app/controllers/embed_scripts_controller.rb
marker_any: ["docuseal-form", "docuseal-builder"]
why: "Custom-element registration keeps docuseal-* names for SDK compatibility."
# ---------------------------------------------------------------------------
# Markers that MUST NOT appear in a specific file (damage class #2 — re-added
# Pro/freemium gates). ALWAYS path-scoped: e.g. `Wabosign.multitenant?` is
# legitimate in ~19 other views, so banning it tree-wide would be wrong.
# ---------------------------------------------------------------------------
must_not_contain:
- path: app/controllers/errors_controller.rb
marker_any: ["ENTERPRISE_PATHS", "ENTERPRISE_FEATURE_MESSAGE", "Pro Edition"]
why: "3.0.2 re-added a 404-with-Pro-upsell paywall on HTML/PDF/DOCX export.
WaboSign ships every format free."
- path: app/views/notifications_settings/_reminder_form.html.erb
marker: "Wabosign.multitenant?"
why: "Re-added reminder-duration gate (hid one_hour/two_hours unless multitenant).
multitenant? is legitimate in ~19 OTHER views, so this ban is scoped here."
# ---------------------------------------------------------------------------
# Markers safe to ban across the whole tree (genuinely unique to a removed
# feature). Keep this list tiny — prefer path-scoped must_not_contain.
# ---------------------------------------------------------------------------
forbidden_globally:
- marker: "console_redirect_index_path"
why: "There is no Console in WaboSign; upstream re-adds these call sites."
# ---------------------------------------------------------------------------
# i18n keys that must never reappear in config/locales/i18n.yml. These are dead
# paywall/feature strings from upstream that get rebranded by the sweep (so they
# read 'WaboSign Pro') but reference features WaboSign does not ship (damage #6).
# ---------------------------------------------------------------------------
forbidden_i18n_keys:
- unlock_with_docuseal_pro
- discord_community
- ai_assistant
- wabosign_trusted_signature
Loading…
Cancel
Save