Files
team/server/scripts/shard-split.js
9ed85b6850 Cherry-pick Go 1.25.8 upgrade and FIPS password fixes to release-11.4 (#36123)
* ci: shard server Postgres tests into 4 parallel runners (#35739)

Cherry-pick of 00296f9b49 from master.

* updated go to version 1.25.8 (#35817)

Cherry-pick of 32a10bac62 from master.

* Move password hashers from server/v8 to server/public (#35805)

Cherry-pick of cca3f08574 from master.

* Generate instead of hard-coding test passwords, enforce new minimum for FIPS, shard CI, fix FIPS builds (#35905)

Cherry-pick of 1bdd985b41 from master.

* upgrade golangci-lint (#35845)

Cherry-pick of 157e0400b2 from master.

On release-11.4 (unlike 11.5), UpdatePostsChannelTypeByChannelId and
BackfillPostsChannelType exist in the elasticsearch/opensearch files,
so the imports they require (fmt, updatebyquery, scriptlanguage) must
be added — they were not in the cherry-pick diff since they already
existed on master.

Also updates html2text/tablewriter/olekukonko deps to fix a workspace
conflict where enterprise's Go 1.25.8 bump pulled in a newer html2text
that requires tablewriter ≥ 1.1 (was pinned to 0.0.5 via a replace
directive). Matches the subset of 866f759e4b (Mm 66662 bump deps)
relevant to this incompatibility.

* Fix FIPS-incompatible passwords and config in e2e test suites (#36001)

Cherry-pick of b51b4a8937 from master.

release-11.4 differences vs release-11.5:
- demo_plugin_installation.spec.ts also deleted (modify/delete conflict, git rm)
- authentication_2_spec.ts conflict: HEAD missing check() call; took
  incoming (check + FIPS-length passwords)
- member_invitation_ui_spec.ts: HEAD has 'Create Account' (capital A)
  matching release-11.4 button text; kept HEAD's button text with
  incoming's newTestPassword()

* fix(cypress): demo plugin (#36056)

Cherry-pick of 39f1979aa5 from master.

demo_plugin_installation.spec.ts was deleted in step 10 (modify/delete);
re-created here with both the FIPS-password change and the URL fix.

* test: clean up channel store data after TestChannelStore (#36066)

TestChannelStore sub-tests create channels, members, and team members
using fake TeamIds and UserIds (model.NewId() for non-existent rows).
These records are left in the database and cause integrity tests
(TestCheck*) running in the same binary to fail their full-table scans.

Register a t.Cleanup on TestChannelStore that purges the affected
tables entirely. A blanket purge is safe: the schema enforces no FK
constraints, and every test suite creates its own data independently.

* Fix command injection in server-test-template workflow (#36080)

Cherry-pick of 9223935f16 from master.

* fix(ci): restore testname format in sharded gotestsum runs (#36078)

run-shard-tests.sh called gotestsum directly without --format, so it
fell back to gotestsum's default (pkgname) instead of the testname
format set by the Makefile. Pass --format "${GOTESTSUM_FORMAT:-testname}"
to match the Makefile default.

Co-authored-by: Mattermost Build <[email protected]>

* fixup: drop demo_plugin playwright specs — master-only APIs

demo_plugin/helpers.ts and demo_plugin_installation.spec.ts use
installAndEnablePlugin and isPluginActive which don't exist on
release-11.4's PlaywrightExtended type. These files were re-created
by the step-11 cherry-pick of 39f1979aa5, but they don't compile on
this branch (same class of problem as actions.spec.ts in step 10).

* fixup: add missing await in PluginLinkTooltip test

The test added in 39f1979aa5 is missing an await on renderWithContext,
causing the pointer-events assertion to race. Fix from 0c7d3f27c6
(on a feature branch, not yet on master).

* fixup: drop pointer-events test incompatible with jsdom@20

The 'should not block interaction with elements outside the tooltip' test
checks getComputedStyle for pointer-events: none, which is set only in SCSS.
release-11.4 uses jest-environment-jsdom@29 (jsdom@20), which does not apply
stylesheets, so getComputedStyle always returns "". The test passes on
release-11.5+ only because they use jest-environment-jsdom@30 (jsdom@25+).

Drop the test rather than backport the jsdom upgrade.

* fixup: add missing i18n keys for elasticsearch/opensearch functions

BackfillPostsChannelType and UpdatePostsChannelTypeByChannelId exist on
release-11.4 (unlike release-11.5+). The golangci-lint cherry-pick added
new error strings to those functions referencing i18n keys that weren't
in en.json.

* fixup: run make i18n-extract to reorder keys correctly

* fixup: drop terms-and-privacy checkbox from MM-T1774 on release-11.4

The #signup-body-card-form-check-terms-and-privacy element did not exist
on release-11.4's signup page. It was added to master after this branch
was cut, so the check() call from the incoming cherry-pick causes the test
to fail with "element never found".

* fixup: remove UpdatePostsChannelTypeByChannelId/BackfillPostsChannelType

These functions were introduced by MM-67540 (allow searching public channel
messages without channel membership), which never landed on release-11.4.
The golangci-lint cherry-pick only modernizes existing code; the rerere
resolution incorrectly added the full function bodies from master.

Remove both functions and their now-unused imports (fmt, updatebyquery,
scriptlanguage) from elasticsearch.go and opensearch.go. Also remove the
two i18n keys added to support them.

Same treatment as release-11.5, where these functions were also absent.

---------

Co-authored-by: Mattermost Build <[email protected]>
2026-04-16 12:10:03 -03:00

243 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
/**
* shard-split.js — Test shard assignment solver
*
* Splits Go test packages across N parallel CI runners using timing data
* from previous runs. Uses a two-tier strategy:
*
* 1. "Light" packages (< HEAVY_MS total runtime): assigned whole to a shard
* 2. "Heavy" packages (>= HEAVY_MS): individual tests distributed across
* shards using -run regex filters
*
* Timing data sources (in priority order):
* - gotestsum.json (JSONL): per-test elapsed times from previous run
* - prev-report.xml (JUnit XML): package-level timing (fallback)
* - Round-robin: when no timing data exists at all
*
* Assignment algorithm: greedy bin-packing (sort by duration desc, assign
* each item to the shard with lowest current load). Simple and effective
* for our distribution where 2 packages dominate 84% of runtime.
*
* Environment variables:
* SHARD_INDEX — this runner's index (0-based)
* SHARD_TOTAL — total number of shards
*
* Input files (in working directory):
* all-packages.txt — newline-separated list of all test packages
* prev-gotestsum.json — (optional) JSONL timing data from previous run
* prev-report.xml — (optional) JUnit XML from previous run
*
* Output files (in working directory):
* shard-te-packages.txt — space-separated TE packages for this shard
* shard-ee-packages.txt — space-separated EE packages for this shard
* shard-heavy-runs.txt — heavy package runs, one per line: "pkg REGEX"
*/
const fs = require("node:fs");
const { execSync } = require("node:child_process");
const SHARD_INDEX = parseInt(process.env.SHARD_INDEX);
const SHARD_TOTAL = parseInt(process.env.SHARD_TOTAL);
const HEAVY_MS = 300000; // 5 min: packages above this get test-level splitting
// Only api4 (~38 min) and app (~15 min) exceed this threshold.
// Packages like sqlstore (~3 min) stay whole to preserve test isolation —
// their integrity tests scan the entire database and break if split across
// shards where other tests leave data behind.
if (isNaN(SHARD_INDEX) || isNaN(SHARD_TOTAL) || SHARD_TOTAL < 1) {
console.error("ERROR: SHARD_INDEX and SHARD_TOTAL must be set");
process.exit(1);
}
const allPkgs = fs.readFileSync("all-packages.txt", "utf8").trim().split("\n").filter(Boolean);
if (allPkgs.length === 0) {
console.error("WARNING: No test packages found in all-packages.txt");
process.exit(0);
}
const pkgTimes = {};
const testTimes = {}; // "pkg::TestName" -> ms
// ── Parse gotestsum.json (JSONL) for per-test timing ──
// Each line is a JSON event; we want "pass" events with Elapsed times.
if (fs.existsSync("prev-gotestsum.json")) {
console.log("::group::Parsing gotestsum.json timing data");
const lines = fs.readFileSync("prev-gotestsum.json", "utf8").split("\n");
for (const line of lines) {
if (!line.includes('"pass"')) continue;
try {
const d = JSON.parse(line);
if (!d.Test || !d.Package) continue;
const elapsed = Math.round((d.Elapsed || 0) * 1000);
// Aggregate package time from test pass events
pkgTimes[d.Package] = (pkgTimes[d.Package] || 0) + elapsed;
// Top-level test name (use max elapsed for parent vs subtests)
const top = d.Test.split("/")[0];
const key = d.Package + "::" + top;
testTimes[key] = Math.max(testTimes[key] || 0, elapsed);
} catch (e) {
// Skip malformed lines
}
}
console.log(
`gotestsum.json: ${Object.keys(pkgTimes).length} packages, ${Object.keys(testTimes).length} tests`
);
console.log("::endgroup::");
}
// ── Fallback: parse JUnit XML for package-level timing ──
if (Object.keys(pkgTimes).length === 0 && fs.existsSync("prev-report.xml")) {
console.log("::group::Parsing JUnit XML timing data (fallback)");
const xml = fs.readFileSync("prev-report.xml", "utf8");
for (const m of xml.matchAll(/<testsuite[^>]*>/g)) {
const name = m[0].match(/name="([^"]+)"/)?.[1];
const time = m[0].match(/\btime="([^"]+)"/)?.[1];
if (name && time) {
pkgTimes[name] = (pkgTimes[name] || 0) + Math.round(parseFloat(time) * 1000);
}
}
console.log(`JUnit XML: ${Object.keys(pkgTimes).length} packages (no per-test data)`);
console.log("::endgroup::");
}
const hasTimingData = Object.keys(pkgTimes).length > 0;
const hasTestTiming = Object.keys(testTimes).length > 0;
// ── Identify heavy packages ──
// Only split at test level if we have per-test timing data
const heavyPkgs = new Set();
if (hasTestTiming) {
for (const [pkg, ms] of Object.entries(pkgTimes)) {
if (ms > HEAVY_MS) heavyPkgs.add(pkg);
}
}
if (heavyPkgs.size > 0) {
console.log("Heavy packages (test-level splitting):");
for (const p of heavyPkgs) {
console.log(` ${(pkgTimes[p] / 1000).toFixed(0)}s ${p.split("/").pop()}`);
}
}
// ── Build work items ──
// Each item is either a whole package ("P") or a single test from a heavy package ("T")
const items = [];
for (const pkg of allPkgs) {
if (heavyPkgs.has(pkg)) {
// Split into individual test items
const tests = Object.entries(testTimes)
.filter(([k]) => k.startsWith(pkg + "::"))
.map(([k, ms]) => ({ ms, type: "T", pkg, test: k.split("::")[1] }));
if (tests.length > 0) {
items.push(...tests);
} else {
// Shouldn't happen, but fall back to whole package
items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
}
} else {
items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
}
}
// ── Discover new/renamed tests in heavy packages ──
// Tests not in the timing cache won't appear in any shard's -run regex,
// silently skipping them. Discover current test names at runtime and
// assign any cache-missing tests to the least-loaded shard.
if (heavyPkgs.size > 0) {
console.log("::group::Discovering new tests in heavy packages");
for (const pkg of heavyPkgs) {
const cachedTests = new Set(
Object.keys(testTimes)
.filter((k) => k.startsWith(pkg + "::"))
.map((k) => k.split("::")[1])
);
try {
const out = execSync(`go test -list '.*' ${pkg} 2>/dev/null`, {
encoding: "utf8",
timeout: 60000,
});
const currentTests = out
.split("\n")
.map((l) => l.trim())
.filter((l) => /^Test[A-Z]/.test(l));
let newCount = 0;
for (const t of currentTests) {
if (!cachedTests.has(t)) {
// Assign a small default duration so it gets picked up
items.push({ ms: 1000, type: "T", pkg, test: t });
newCount++;
}
}
if (newCount > 0) {
console.log(` ${pkg.split("/").pop()}: ${newCount} new test(s) not in cache`);
}
} catch (e) {
console.log(` ${pkg.split("/").pop()}: go test -list failed, skipping discovery`);
}
}
console.log("::endgroup::");
}
// Sort descending by duration for greedy bin-packing
items.sort((a, b) => b.ms - a.ms);
// ── Greedy bin-packing assignment ──
const shards = Array.from({ length: SHARD_TOTAL }, () => ({
load: 0,
whole: [],
heavy: {},
}));
if (!hasTimingData) {
// Round-robin fallback when no timing data exists
console.log("No timing data — using round-robin");
allPkgs.forEach((pkg, i) => {
shards[i % SHARD_TOTAL].whole.push(pkg);
});
} else {
for (const item of items) {
// Find shard with minimum current load
const min = shards.reduce((m, s, i) => (s.load < shards[m].load ? i : m), 0);
shards[min].load += item.ms;
if (item.type === "P") {
shards[min].whole.push(item.pkg);
} else {
if (!shards[min].heavy[item.pkg]) shards[min].heavy[item.pkg] = [];
shards[min].heavy[item.pkg].push(item.test);
}
}
}
// ── Report shard assignments ──
console.log("::group::Shard assignment");
for (let i = 0; i < SHARD_TOTAL; i++) {
const s = shards[i];
const hRuns = Object.keys(s.heavy).length;
const hTests = Object.values(s.heavy).reduce((n, a) => n + a.length, 0);
const marker = i === SHARD_INDEX ? " ← THIS SHARD" : "";
console.log(
`Shard ${i}: ${(s.load / 1000).toFixed(1)}s | ${s.whole.length} pkgs` +
(hRuns > 0 ? `, ${hRuns} heavy splits (${hTests} tests)` : "") +
marker
);
}
console.log("::endgroup::");
// ── Write output for this shard ──
const myShard = shards[SHARD_INDEX];
const te = myShard.whole.filter((p) => !p.includes("/enterprise/")).join(" ");
const ee = myShard.whole.filter((p) => p.includes("/enterprise/")).join(" ");
fs.writeFileSync("shard-te-packages.txt", te);
fs.writeFileSync("shard-ee-packages.txt", ee);
// Heavy package runs: one line per run as "pkg REGEX"
const heavyRuns = Object.entries(myShard.heavy).map(([pkg, tests]) => {
const regex = tests.map((t) => "^" + t + "$").join("|");
return pkg + " " + regex;
});
fs.writeFileSync("shard-heavy-runs.txt", heavyRuns.join("\n"));
console.log(
`Light packages: ${myShard.whole.length} (${te.split(" ").filter(Boolean).length} TE, ${ee.split(" ").filter(Boolean).length} EE)`
);
console.log(`Heavy package runs: ${heavyRuns.length}`);