From 6ef4a55bcd0e5505ab204d4e24038aba0ab1f282 Mon Sep 17 00:00:00 2001 From: Wabo Date: Thu, 4 Jun 2026 17:04:09 -0400 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 9 + app/controllers/errors_controller.rb | 22 -- .../_reminder_form.html.erb | 2 +- bin/fork-check | 229 ++++++++++++++++++ bin/rebrand-check | 4 + bin/rebrand-sync | 5 + config/fork_invariants.yml | 126 ++++++++++ 7 files changed, 374 insertions(+), 23 deletions(-) create mode 100755 bin/fork-check create mode 100644 config/fork_invariants.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e781f129..2bfbb6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,15 @@ jobs: - name: Run 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: name: RSpec runs-on: ubuntu-latest diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 5f29ed61..02dccc7a 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -1,34 +1,12 @@ # frozen_string_literal: true 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 = [ ActionDispatch::Http::Parameters::ParseError, JSON::ParserError ].freeze 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| f.json do set_cors_headers diff --git a/app/views/notifications_settings/_reminder_form.html.erb b/app/views/notifications_settings/_reminder_form.html.erb index d38ccc22..58b4bec5 100644 --- a/app/views/notifications_settings/_reminder_form.html.erb +++ b/app/views/notifications_settings/_reminder_form.html.erb @@ -2,7 +2,7 @@ <%= f.hidden_field :key %>
<% 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] } %>
<%= f.fields_for :value, record do |ff| %> diff --git a/bin/fork-check b/bin/fork-check new file mode 100755 index 00000000..8eeb478d --- /dev/null +++ b/bin/fork-check @@ -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 (, "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}\""] + 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 diff --git a/bin/rebrand-check b/bin/rebrand-check index 27c52bdb..2376d97a 100755 --- a/bin/rebrand-check +++ b/bin/rebrand-check @@ -53,6 +53,10 @@ ALLOW_FILES = Set.new([ 'bin/rebrand-sync', 'bin/rebrand-check', '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/docuseal.rb', # Migration that finds rows by the legacy docuseal_aatl name. diff --git a/bin/rebrand-sync b/bin/rebrand-sync index 07058192..5aca8331 100755 --- a/bin/rebrand-sync +++ b/bin/rebrand-sync @@ -50,6 +50,11 @@ DENY_PATHS = Set.new([ 'bin/rebrand-sync', 'bin/rebrand-check', '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; # restored from ORIG_HEAD by bin/sync-upstream after an upstream merge. 'public/favicon.svg', diff --git a/config/fork_invariants.yml b/config/fork_invariants.yml new file mode 100644 index 00000000..2fa7f6cc --- /dev/null +++ b/config/fork_invariants.yml @@ -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: "" 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 — 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