mirror of https://github.com/docusealco/docuseal
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
parent
16e830565f
commit
6ef4a55bcd
@ -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", <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}>", "</#{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
|
||||
@ -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…
Reference in new issue