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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); ci(e2e): gate staging e2e on critical staging-instance config drift by jacekradko · Pull Request #8757 · clerk/javascript · GitHub
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
2 changes: 2 additions & 0 deletions .changeset/staging-e2e-validate-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,13 +111,21 @@ jobs:
- name: Validate staging instance settings
run: node scripts/validate-staging-instances.mjs
env:
# Report-only unless the `STAGING_VALIDATE_STRICT` repo variable is set to "true"/"1".
# When strict, a mismatch on critical config (see CRITICAL_PATHS in the script) fails
# this job, which gates the integration-tests job below so the run stops fast with a
# clear diagnostic instead of letting a drifted staging mirror produce opaque failures.
STAGING_VALIDATE_STRICT: ${{ vars.STAGING_VALIDATE_STRICT }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}

integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
needs: [permissions-check]
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') }}
needs: [permissions-check, validate-instances]
# Run when permissions passed/skipped AND the staging-config validation did not block.
# validate-instances only fails when strict gating is enabled and critical config drifted,
# so by default (report-only) this is a no-op and tests run as before.
if: ${{ always() && (needs.permissions-check.result == 'success' || needs.permissions-check.result == 'skipped') && (needs.validate-instances.result == 'success' || needs.validate-instances.result == 'skipped') }}
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
Expand DownExpand Up@@ -321,7 +329,9 @@ jobs:

report:
name: Report Results
needs: [integration-tests]
# validate-instances is needed so a strict-mode gate failure (which SKIPS all
# test legs rather than failing them) still reaches the Slack notification.
needs: [integration-tests, validate-instances]
if: always()
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
Expand DownExpand Up@@ -355,7 +365,7 @@ jobs:
fi

- name: Notify Slack on failure
if: ${{ needs.integration-tests.result == 'failure' && steps.inputs.outputs.notify-slack == 'true' }}
if: ${{ (needs.integration-tests.result == 'failure' || needs.validate-instances.result == 'failure') && steps.inputs.outputs.notify-slack == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
Expand Down
111 changes: 100 additions & 11 deletions scripts/validate-staging-instances.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,20 +21,77 @@ const STAGING_KEY_PREFIX = 'clerkstage-';
* Paths to ignore during comparison — these are expected to differ between
* production and staging environments.
*/
const IGNORED_PATHS = [
/\.id$/,
/^auth_config\.id$/,
/\.logo_url$/,
/\.captcha_enabled$/,
/\.captcha_widget_type$/,
/\.enforce_hibp_on_sign_in$/,
/\.disable_hibp$/,
];
const IGNORED_PATHS = [/\.id$/, /^auth_config\.id$/, /\.logo_url$/, /\.enforce_hibp_on_sign_in$/, /\.disable_hibp$/];

function isIgnored(path) {
return IGNORED_PATHS.some(pattern => pattern.test(path));
}

// ── Gating policy ────────────────────────────────────────────────────────────

/**
* Functional configuration that must match between a production instance and its
* staging mirror for the e2e suite to be meaningful. A mismatch on any of these
* paths fails the gate in strict mode; every other difference is reported as
* informational drift and never blocks. Keep this list tight: only config that
* actually changes which auth flows are possible belongs here.
*/
const CRITICAL_PATHS = [
// An auth attribute (email_address, phone_number, username, ...) toggled on/off.
/^user_settings\.attributes\.[^.]+\.enabled$/,
// The phone-code channel set (sms / whatsapp), which drives alternate-channel UIs.
/^user_settings\.attributes\.phone_number\.channels$/,
// Enabled auth strategies / factors for an attribute.
/^user_settings\.attributes\.[^.]+\.(first_factors|second_factors|verifications)$/,
// A social provider enabled/disabled, or wholly added/removed.
/^user_settings\.social\.[^.]+(\.enabled)?$/,
// Password policy, which affects password sign-in / sign-up flows.
/^user_settings\.password_settings\..+/,
// Bot protection: an enabled captcha blocks every in-browser sign-up in
// headless CI unless the test carries a bypass token (widget type alone is
// inert while captcha is disabled, so it stays informational).
/^user_settings\.sign_up\.captcha_enabled$/,
];

/**
* Known, intentionally-tolerated critical drift that should NOT fail the gate, so
* that NEW drift still does. Each entry needs a `path` (string or RegExp), an
* optional `instance` name to scope it, and a `reason` (ideally a tracking link).
* Prefer fixing the staging instance over adding entries here.
*/
const ACCEPTED_DRIFT = [
// e.g. { instance: 'with-whatsapp-phone-code', path: 'user_settings.attributes.phone_number.channels',
// reason: 'WhatsApp channel not yet provisioned on staging (CLERK-XXXX)' },
];

function isCriticalPath(path) {
return CRITICAL_PATHS.some(pattern => pattern.test(path));
}

function isAcceptedDrift(instanceName, path, acceptedDrift = ACCEPTED_DRIFT) {
return acceptedDrift.some(entry => {
if (entry.instance !== undefined && entry.instance !== instanceName) return false;
return typeof entry.path === 'string' ? entry.path === path : entry.path.test(path);
});
}

/**
* Split a pair's mismatches into blocking (critical and not accepted) and
* informational. Pure and side-effect free for testability.
*/
function classifyMismatches(instanceName, mismatches, acceptedDrift = ACCEPTED_DRIFT) {
const blocking = [];
const informational = [];
for (const m of mismatches) {
if (isCriticalPath(m.path) && !isAcceptedDrift(instanceName, m.path, acceptedDrift)) {
blocking.push(m);
} else {
informational.push(m);
}
}
return { blocking, informational };
}

// ── Key loading ──────────────────────────────────────────────────────────────

function loadKeys(envVar, filePath) {
Expand DownExpand Up@@ -311,7 +368,7 @@ function printReport(name, mismatches) {

// ── Main ─────────────────────────────────────────────────────────────────────

async function main() {
async function main({ strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) } = {}) {
const { keys: prodKeys, errors: prodErrors } = loadKeys('INTEGRATION_INSTANCE_KEYS', 'integration/.keys.json');
for (const err of prodErrors) console.error(`⚠️ Production keys: ${err}`);
if (!prodKeys) {
Expand DownExpand Up@@ -367,6 +424,8 @@ async function main() {

let mismatchCount = 0;
let fetchFailCount = 0;
let blockingTotal = 0;
const blockingByInstance = [];

for (const pair of validPairs) {
const prodDomain = parseFapiDomain(pair.prod.pk);
Expand All@@ -386,6 +445,12 @@ async function main() {
mismatches = collapseAttributeMismatches(mismatches);
mismatches = collapseSocialMismatches(mismatches);

const { blocking } = classifyMismatches(pair.name, mismatches);
if (blocking.length > 0) {
blockingTotal += blocking.length;
blockingByInstance.push({ name: pair.name, paths: blocking.map(m => m.path) });
}

if (mismatches.length > 0) mismatchCount++;
printReport(pair.name, mismatches);
}
Expand All@@ -397,12 +462,32 @@ async function main() {
const matchedCount = validPairs.length - mismatchCount - fetchFailCount;
if (matchedCount > 0) parts.push(`${matchedCount} matched`);
console.log(`Summary: ${parts.join(', ')} (${validPairs.length} total)`);

// Gating: only mismatches on critical config block, and only in strict mode.
// Fetch failures and cosmetic drift never fail the build, to avoid false reds.
if (blockingTotal > 0) {
console.log('');
console.log(
`❌ ${blockingTotal} blocking mismatch(es) on critical config across ${blockingByInstance.length} instance(s):`,
);
for (const { name, paths } of blockingByInstance) {
for (const p of paths) console.log(` - ${name}: ${p}`);
}
if (strict) {
console.error(
'\nStaging instance config has drifted on critical paths. Fix the staging instance(s) or add an accepted-drift entry.',
);
process.exit(1);
}
console.log('\n(Report-only: set STAGING_VALIDATE_STRICT=1 or pass --strict to fail the build on the above.)');
}
}

// Allow importing functions for testing while still being executable
const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch(err => {
const strict = ['1', 'true'].includes(process.env.STAGING_VALIDATE_STRICT) || process.argv.includes('--strict');
main({ strict }).catch(err => {
console.error('Unexpected error:', err);
process.exit(0);
});
Expand All@@ -416,5 +501,9 @@ export {
collapseAttributeMismatches,
collapseSocialMismatches,
compareEnvironments,
isIgnored,
isCriticalPath,
isAcceptedDrift,
classifyMismatches,
main,
};
Loading
Loading