diff --git a/config/config-matterwick.default.json b/config/config-matterwick.default.json index 29b565a..4f99e87 100644 --- a/config/config-matterwick.default.json +++ b/config/config-matterwick.default.json @@ -84,6 +84,5 @@ "E2EInstanceMaxAge": 6, "E2EPRInstanceMaxAge": 24, "CMTTriggerWorkflowName": "CMT Provisioner", - "CMTTestWorkflowName": "Compatibility Matrix Testing", - "CMTServerVersions": ["10.11.18", "11.7.1"] + "CMTTestWorkflowName": "Compatibility Matrix Testing" } diff --git a/server/config.go b/server/config.go index a01ddb3..7154fd4 100644 --- a/server/config.go +++ b/server/config.go @@ -105,13 +105,13 @@ type MatterwickConfig struct { // Value: plugin ID to use for mmctl enable command PluginRepoToIDMapping map[string]string - E2ELabel string - E2EMobileIOSLabel string - E2EMobileAndroidLabel string - E2EResetServersLabel string - E2EUsername string - E2EPassword string - E2EServerVersion string + E2ELabel string + E2EMobileIOSLabel string + E2EMobileAndroidLabel string + E2EResetServersLabel string + E2EUsername string + E2EPassword string + E2EServerVersion string E2EAutoTriggerOnMaster bool E2EReleasePatternPrefix string E2ETestWorkflowNames []string // workflow names of the actual test workflows (for completion-based cleanup) @@ -144,15 +144,14 @@ type MatterwickConfig struct { // CMTServerVersions is an OPTIONAL manual override for the CMT version set. When non-empty // it is used verbatim (values must be valid Mattermost image tags: full semver, no "v" // prefix, e.g. "10.11.0"). When empty (the normal case) matterwick auto-derives the set - // from Mattermost's GitHub releases via Server.resolveCMTServerVersions. + // from Mattermost's GitHub releases via Server.resolveCMTServerVersions (newest ESR + // lines + latest stable minors + current RC). Leave empty in production. CMTServerVersions []string - } // defaultCMTServerVersions is the fallback CMT version set used only when auto-resolution -// fails (GitHub API error) and no manual override is configured. Kept reasonably current: -// the active v10.11 ESR plus a recent v11 release. -var defaultCMTServerVersions = []string{"10.11.18", "11.7.1"} +// fails (GitHub API error) and no manual override is configured. +var defaultCMTServerVersions = []string{"10.11.22", "11.7.7"} func findConfigFile(fileName string) string { if _, err := os.Stat("/tmp/" + fileName); err == nil { diff --git a/server/e2e_dryrun_test.go b/server/e2e_dryrun_test.go index 47e6187..aeea3ab 100644 --- a/server/e2e_dryrun_test.go +++ b/server/e2e_dryrun_test.go @@ -146,15 +146,31 @@ func makeDesktopInstances() []*E2EInstance { } } -// makeMobileInstances fabricates the 3 mobile instances (site-1/2/3). +// makeMobileInstances fabricates the 5 platform-isolated mobile instances. func makeMobileInstances() []*E2EInstance { return []*E2EInstance{ - {Name: "inst-site1", Platform: "site-1", URL: "https://site1.test.example.com", InstallationID: "id-1", ServerVersion: "master"}, - {Name: "inst-site2", Platform: "site-2", URL: "https://site2.test.example.com", InstallationID: "id-2", ServerVersion: "master"}, - {Name: "inst-site3", Platform: "site-3", URL: "https://site3.test.example.com", InstallationID: "id-3", ServerVersion: "master"}, + {Name: "inst-android-site1", Platform: "android-site-1", URL: "https://android-site1.test.example.com", InstallationID: "id-1", ServerVersion: "master"}, + {Name: "inst-android-site2", Platform: "android-site-2", URL: "https://android-site2.test.example.com", InstallationID: "id-2", ServerVersion: "master"}, + {Name: "inst-ios-site1", Platform: "ios-site-1", URL: "https://ios-site1.test.example.com", InstallationID: "id-3", ServerVersion: "master"}, + {Name: "inst-ios-site2", Platform: "ios-site-2", URL: "https://ios-site2.test.example.com", InstallationID: "id-4", ServerVersion: "master"}, + {Name: "inst-site3", Platform: "site-3", URL: "https://site3.test.example.com", InstallationID: "id-5", ServerVersion: "master"}, } } +func makeMobileCMTInstances(versions []string) []*E2EInstance { + instances := make([]*E2EInstance, 0, len(versions)*len(mobileE2EPlatforms)) + for versionIndex, version := range versions { + for platformIndex, platform := range mobileE2EPlatforms { + instances = append(instances, &E2EInstance{ + Platform: platform, + URL: fmt.Sprintf("https://v%d-site%d.example.com", versionIndex, platformIndex+1), + ServerVersion: version, + }) + } + } + return instances +} + // ------------------------------------------------------------ // 1. Desktop PR label: E2E/Run → e2e-functional.yml // ------------------------------------------------------------ @@ -238,40 +254,67 @@ func TestDryRun_MobileDispatch(t *testing.T) { platform := s.extractPlatformFromLabel(tt.label) assert.Equal(t, tt.platform, platform) - // Build the inputs as triggerMobileE2EWorkflow does - inputs := map[string]interface{}{ - "MOBILE_VERSION": prSha, - "PLATFORM": platform, - } - for i, inst := range instances { - inputs[fmt.Sprintf("SITE_%d_URL", i+1)] = inst.URL + ghSrv, captures := mockGitHubServer(t, http.StatusNoContent) + client := newTestGitHubClient(t, ghSrv) + pr := &model.PullRequest{ + RepoOwner: "mattermost", + RepoName: "mattermost-mobile", + Number: 42, + Ref: prRef, + Sha: prSha, } - - body := map[string]interface{}{"ref": prRef, "inputs": inputs} - jsonBytes, err := json.Marshal(body) - require.NoError(t, err) - - var parsed struct { - Ref string `json:"ref"` - Inputs map[string]interface{} `json:"inputs"` - } - require.NoError(t, json.Unmarshal(jsonBytes, &parsed)) - - assert.Equal(t, prRef, parsed.Ref) - assert.Equal(t, tt.platform, parsed.Inputs["PLATFORM"]) - assert.Equal(t, prSha, parsed.Inputs["MOBILE_VERSION"]) - assert.Equal(t, "https://site1.test.example.com", parsed.Inputs["SITE_1_URL"]) - assert.Equal(t, "https://site2.test.example.com", parsed.Inputs["SITE_2_URL"]) - assert.Equal(t, "https://site3.test.example.com", parsed.Inputs["SITE_3_URL"]) + require.NoError(t, s.triggerMobileE2EWorkflow(context.Background(), client, pr, instances, platform)) + require.Len(t, *captures, 1) + + capture := (*captures)[0] + assert.Equal(t, prRef, capture.Ref) + assert.Equal(t, tt.platform, capture.Inputs["PLATFORM"]) + assert.Equal(t, prSha, capture.Inputs["MOBILE_VERSION"]) + assert.Equal(t, "https://android-site1.test.example.com", capture.Inputs["ANDROID_SITE_1_URL"]) + assert.Equal(t, "https://android-site2.test.example.com", capture.Inputs["ANDROID_SITE_2_URL"]) + assert.Equal(t, "https://ios-site1.test.example.com", capture.Inputs["IOS_SITE_1_URL"]) + assert.Equal(t, "https://ios-site2.test.example.com", capture.Inputs["IOS_SITE_2_URL"]) + assert.Equal(t, "https://site3.test.example.com", capture.Inputs["SITE_3_URL"]) // Mobile must NOT use instance_details (desktop-only field) - assert.NotContains(t, parsed.Inputs, "instance_details", + assert.NotContains(t, capture.Inputs, "instance_details", "mobile workflow must not send instance_details") + assert.NotContains(t, capture.Inputs, "SITE_1_URL", + "new Matterwick dispatches must use explicit platform URL inputs") + assert.NotContains(t, capture.Inputs, "SITE_2_URL", + "new Matterwick dispatches must use explicit platform URL inputs") }) } } +func TestDryRun_MobileTopology(t *testing.T) { + assert.Equal(t, []string{ + "android-site-1", + "android-site-2", + "ios-site-1", + "ios-site-2", + "site-3", + }, mobileE2EPlatforms) + assert.Equal(t, []string{ + "ANDROID_SITE_1_URL", + "ANDROID_SITE_2_URL", + "IOS_SITE_1_URL", + "IOS_SITE_2_URL", + "SITE_3_URL", + }, mobileE2EWorkflowInputKeys) + + s := newDryRunServer(t, "", "mattermost") + err := s.triggerMobileE2EWorkflowForPushEvent( + "mattermost", + "mattermost-mobile", + "main", + "abc123", + makeMobileInstances()[:3], + ) + require.EqualError(t, err, "mobile E2E requires 5 instances") +} + // ------------------------------------------------------------ // 3. Label detection for all configured E2E labels // ------------------------------------------------------------ @@ -440,10 +483,7 @@ func TestDryRun_DesktopCMT(t *testing.T) { func TestDryRun_MobileCMT(t *testing.T) { t.Run("buildMobileCMTMatrixJSON produces correct schema", func(t *testing.T) { versions := []string{"v11.1.0", "v11.2.0"} - instances := []*E2EInstance{ - {URL: "https://v1.example.com", ServerVersion: "v11.1.0"}, - {URL: "https://v2.example.com", ServerVersion: "v11.2.0"}, - } + instances := makeMobileCMTInstances(versions) jsonStr, err := buildMobileCMTMatrixJSON(versions, instances) require.NoError(t, err) @@ -455,33 +495,43 @@ func TestDryRun_MobileCMT(t *testing.T) { require.Len(t, servers, 2) s0 := servers[0].(map[string]interface{}) assert.Equal(t, "v11.1.0", s0["version"]) - assert.Equal(t, "https://v1.example.com", s0["url"]) + assert.Equal(t, "https://v0-site1.example.com", s0["android_site_1_url"]) + assert.Equal(t, "https://v0-site2.example.com", s0["android_site_2_url"]) + assert.Equal(t, "https://v0-site3.example.com", s0["ios_site_1_url"]) + assert.Equal(t, "https://v0-site4.example.com", s0["ios_site_2_url"]) + assert.Equal(t, "https://v0-site5.example.com", s0["site_3_url"]) + assert.NotContains(t, s0, "url") // Older version: `latest` is omitted entirely (cmtServer.Latest is false, omitempty). _, has0 := s0["latest"] assert.False(t, has0, "older mobile entries must not carry the `latest` field") s1 := servers[1].(map[string]interface{}) assert.Equal(t, "v11.2.0", s1["version"]) - assert.Equal(t, "https://v2.example.com", s1["url"]) + assert.Equal(t, "https://v1-site1.example.com", s1["android_site_1_url"]) + assert.Equal(t, "https://v1-site5.example.com", s1["site_3_url"]) // Highest semver gets `latest: true`. The mobile workflow uses this to decide whether // to run the whole suite (latest) or just smoke (older) — that policy lives there, // not in matterwick. assert.Equal(t, true, s1["latest"]) }) - t.Run("CMT_MATRIX uses server array not SITE_URL inputs", func(t *testing.T) { + t.Run("CMT_MATRIX carries explicit five-server topology per version", func(t *testing.T) { versions := []string{"v11.1.0", "v11.2.0"} - instances := []*E2EInstance{ - {URL: "https://v1.example.com", ServerVersion: "v11.1.0"}, - {URL: "https://v2.example.com", ServerVersion: "v11.2.0"}, - } + instances := makeMobileCMTInstances(versions) jsonStr, err := buildMobileCMTMatrixJSON(versions, instances) require.NoError(t, err) - // CMT_MATRIX must use "server" array, not the SITE_1/2/3_URL inputs used for PR runs - assert.NotContains(t, jsonStr, "SITE_1_URL") - assert.NotContains(t, jsonStr, "SITE_2_URL") assert.Contains(t, jsonStr, "\"server\"") - assert.Contains(t, jsonStr, "\"url\"") + assert.Contains(t, jsonStr, "\"android_site_1_url\"") + assert.Contains(t, jsonStr, "\"android_site_2_url\"") + assert.Contains(t, jsonStr, "\"ios_site_1_url\"") + assert.Contains(t, jsonStr, "\"ios_site_2_url\"") + assert.Contains(t, jsonStr, "\"site_3_url\"") + }) + + t.Run("mobile CMT rejects partial topologies", func(t *testing.T) { + versions := []string{"v11.1.0", "v11.2.0"} + _, err := buildMobileCMTMatrixJSON(versions, makeMobileCMTInstances(versions)[:9]) + require.EqualError(t, err, "mobile CMT requires 10 instances for 2 versions, got 9") }) t.Run("mobile CMT marks the highest-semver entry as latest", func(t *testing.T) { @@ -491,8 +541,8 @@ func TestDryRun_MobileCMT(t *testing.T) { // numbers (rc.10 > rc.2). Locking these in so the workflow's latest gate doesn't // silently shift if someone tweaks the comparator. cases := []struct { - name string - versions []string + name string + versions []string wantLatestIdx int }{ { @@ -523,10 +573,7 @@ func TestDryRun_MobileCMT(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - instances := make([]*E2EInstance, len(tc.versions)) - for i := range tc.versions { - instances[i] = &E2EInstance{URL: fmt.Sprintf("https://v%d.example.com", i)} - } + instances := makeMobileCMTInstances(tc.versions) jsonStr, err := buildMobileCMTMatrixJSON(tc.versions, instances) require.NoError(t, err) var matrix map[string]interface{} @@ -548,10 +595,7 @@ func TestDryRun_MobileCMT(t *testing.T) { t.Run("mobile CMT: all unparseable versions => last entry marked latest", func(t *testing.T) { versions := []string{"junk", "also-junk"} - instances := []*E2EInstance{ - {URL: "https://a.example.com"}, - {URL: "https://b.example.com"}, - } + instances := makeMobileCMTInstances(versions) jsonStr, err := buildMobileCMTMatrixJSON(versions, instances) require.NoError(t, err) var matrix map[string]interface{} @@ -602,7 +646,7 @@ func TestDryRun_InstanceTracking(t *testing.T) { delete(s.e2eInstances, key) s.e2eInstancesLock.Unlock() - assert.Len(t, retrieved, 3) + assert.Len(t, retrieved, 5) s.e2eInstancesLock.Lock() _, exists := s.e2eInstances[key] @@ -666,7 +710,7 @@ func TestDryRun_InstanceTracking(t *testing.T) { } s.e2eInstancesLock.Unlock() - assert.Len(t, collected, 6, "should collect 3 instances from each of 2 push keys") + assert.Len(t, collected, 10, "should collect 5 instances from each of 2 push keys") }) t.Run("CMT cleanup by run id removes only the matching tracking key", func(t *testing.T) { @@ -1213,16 +1257,9 @@ func TestDryRun_MMServerVersionFromInstance(t *testing.T) { t.Run("mobile dispatch does not include MM_SERVER_VERSION", func(t *testing.T) { // Drive triggerMobileE2EWorkflow so we assert the real payload, not a hand-built map. s := newDryRunServer(t, "", "mattermost") - instances := []*E2EInstance{ - {Name: "inst-site1", Platform: "site-1", - URL: "https://site1.test.example.com", InstallationID: "id-1", - ServerVersion: "11.6.0"}, - {Name: "inst-site2", Platform: "site-2", - URL: "https://site2.test.example.com", InstallationID: "id-2", - ServerVersion: "11.6.0"}, - {Name: "inst-site3", Platform: "site-3", - URL: "https://site3.test.example.com", InstallationID: "id-3", - ServerVersion: "11.6.0"}, + instances := makeMobileInstances() + for _, instance := range instances { + instance.ServerVersion = "11.6.0" } ghSrv, captures := mockGitHubServer(t, http.StatusNoContent) @@ -1244,8 +1281,10 @@ func TestDryRun_MMServerVersionFromInstance(t *testing.T) { "mobile dispatch must never include MM_SERVER_VERSION") assert.NotContains(t, c.Inputs, "instance_details", "mobile dispatch must never include instance_details") - assert.Equal(t, "https://site1.test.example.com", c.Inputs["SITE_1_URL"]) - assert.Equal(t, "https://site2.test.example.com", c.Inputs["SITE_2_URL"]) + assert.Equal(t, "https://android-site1.test.example.com", c.Inputs["ANDROID_SITE_1_URL"]) + assert.Equal(t, "https://android-site2.test.example.com", c.Inputs["ANDROID_SITE_2_URL"]) + assert.Equal(t, "https://ios-site1.test.example.com", c.Inputs["IOS_SITE_1_URL"]) + assert.Equal(t, "https://ios-site2.test.example.com", c.Inputs["IOS_SITE_2_URL"]) assert.Equal(t, "https://site3.test.example.com", c.Inputs["SITE_3_URL"]) }) @@ -1320,43 +1359,54 @@ func TestDryRun_CMTVersionNormalization(t *testing.T) { func TestDryRun_ResolveCMTServerVersions(t *testing.T) { // A realistic releases payload (newest first): an upcoming RC, recent stable minors, - // and ESR lines flagged in the body. Includes multiple patches per line and a draft. + // and ESR lines flagged in the body. Includes multiple patches per line, a draft, and + // an EOL ESR (9.11) that must not enter the matrix. releasesBody := `[ {"tag_name":"v11.8.0-rc3","draft":false,"prerelease":true,"body":"Mattermost Platform Release 11.8.0-rc3"}, {"tag_name":"v11.8.0-rc2","draft":false,"prerelease":true,"body":"rc"}, {"tag_name":"v11.7.2","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 11.7.2 contains fixes."}, - {"tag_name":"v11.7.1","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 11.7.1"}, + {"tag_name":"v11.7.0","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 11.7.0"}, {"tag_name":"v11.6.4","draft":false,"prerelease":false,"body":"Mattermost Platform Release 11.6.4"}, {"tag_name":"v11.6.3","draft":false,"prerelease":false,"body":"Mattermost Platform Release 11.6.3"}, {"tag_name":"v11.5.7","draft":false,"prerelease":false,"body":"Mattermost Platform Release 11.5.7"}, {"tag_name":"v11.99.0","draft":true,"prerelease":false,"body":"draft should be ignored"}, {"tag_name":"v10.11.19","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 10.11.19 contains security fixes."}, - {"tag_name":"v10.11.18","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 10.11.18"} + {"tag_name":"v10.11.17","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 10.11.17"}, + {"tag_name":"v9.11.18","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 9.11.18"} ]` - t.Run("auto-derives ESR + latest 3 minors + current RC, latest patch each", func(t *testing.T) { + t.Run("auto-derives newest 2 ESRs + latest 3 minors + current RC, latest patch each", func(t *testing.T) { srv := mockReleasesServer(t, releasesBody, http.StatusOK) s := newDryRunServer(t, "", "mattermost") s.githubAPIBase = srv.URL + "/" got := s.resolveCMTServerVersions() - // 10.11.19 (ESR) + 11.5.7/11.6.4/11.7.2 (latest 3 minors; 11.7 also ESR) + 11.8.0-rc3 (RC), - // latest patch per line, v-stripped, ascending. + // 10.11.19 + 11.7.2 (newest 2 ESRs) + 11.5.7/11.6.4 (fill latest-3) + 11.8.0-rc3, + // 9.11.18 EOL ESR dropped, latest patch per line, v-stripped, ascending. assert.Equal(t, []string{"10.11.19", "11.5.7", "11.6.4", "11.7.2", "11.8.0-rc3"}, got) }) - t.Run("explicit config override is returned verbatim, no API call", func(t *testing.T) { + t.Run("cmtServerVersions uses resolve when CMTServerVersions is empty", func(t *testing.T) { + srv := mockReleasesServer(t, releasesBody, http.StatusOK) + s := newDryRunServer(t, "", "mattermost") + s.githubAPIBase = srv.URL + "/" + s.Config.CMTServerVersions = nil + + assert.Equal(t, []string{"10.11.19", "11.5.7", "11.6.4", "11.7.2", "11.8.0-rc3"}, s.cmtServerVersions()) + }) + + t.Run("explicit CMTServerVersions override skips resolve", func(t *testing.T) { called := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true - w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(releasesBody)) })) t.Cleanup(srv.Close) s := newDryRunServer(t, "", "mattermost") s.githubAPIBase = srv.URL + "/" - s.Config.CMTServerVersions = []string{"9.11.0", "10.5.0"} + s.Config.CMTServerVersions = []string{"10.11.22", "11.10.0-rc1"} - assert.Equal(t, []string{"9.11.0", "10.5.0"}, s.cmtServerVersions()) + assert.Equal(t, []string{"10.11.22", "11.10.0-rc1"}, s.cmtServerVersions()) assert.False(t, called, "manual override must not hit the GitHub API") }) @@ -1364,8 +1414,31 @@ func TestDryRun_ResolveCMTServerVersions(t *testing.T) { srv := mockReleasesServer(t, "boom", http.StatusInternalServerError) s := newDryRunServer(t, "", "mattermost") s.githubAPIBase = srv.URL + "/" + s.Config.CMTServerVersions = nil assert.Equal(t, defaultCMTServerVersions, s.resolveCMTServerVersions()) + assert.Equal(t, []string{"10.11.22", "11.7.7"}, defaultCMTServerVersions) + assert.Equal(t, defaultCMTServerVersions, s.cmtServerVersions()) + }) + + t.Run("cap prefers trailing ESR over oldest feature minor", func(t *testing.T) { + // 2 ESRs + 3 distinct latest minors + RC = 6 before cap; drop 11.8 (oldest non-ESR). + body := `[ + {"tag_name":"v11.11.0-rc1","draft":false,"prerelease":true,"body":"rc"}, + {"tag_name":"v11.10.0","draft":false,"prerelease":false,"body":"Mattermost Platform Release 11.10.0"}, + {"tag_name":"v11.9.0","draft":false,"prerelease":false,"body":"Mattermost Platform Release 11.9.0"}, + {"tag_name":"v11.8.0","draft":false,"prerelease":false,"body":"Mattermost Platform Release 11.8.0"}, + {"tag_name":"v11.7.7","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 11.7.7"}, + {"tag_name":"v10.11.22","draft":false,"prerelease":false,"body":"Mattermost Platform Extended Support Release 10.11.22"} + ]` + srv := mockReleasesServer(t, body, http.StatusOK) + s := newDryRunServer(t, "", "mattermost") + s.githubAPIBase = srv.URL + "/" + + got := s.resolveCMTServerVersions() + assert.Equal(t, []string{"10.11.22", "11.7.7", "11.9.0", "11.10.0", "11.11.0-rc1"}, got) + assert.NotContains(t, got, "11.8.0") + assert.Len(t, got, maxCMTServerVersions) }) t.Run("RC omitted when not newer than latest stable", func(t *testing.T) { @@ -1522,20 +1595,20 @@ func TestShouldTriggerCMT(t *testing.T) { assert.True(t, s.shouldTriggerCMT("workflow_dispatch", "release-6.2")) // RC tag cut (primary trigger). For tag pushes head_branch is the tag name. - assert.True(t, s.shouldTriggerCMT("push", "v6.2.0-rc.1")) // desktop convention - assert.True(t, s.shouldTriggerCMT("push", "v2.41.0-rc.1")) // future mobile - assert.True(t, s.shouldTriggerCMT("push", "v6.2.0-rc.10")) // multi-digit rc - assert.True(t, s.shouldTriggerCMT("push", "6.2.0-rc.1")) // missing 'v' prefix is permitted - assert.True(t, s.shouldTriggerCMT("push", "v6.2.0-rc1")) // no separator before number + assert.True(t, s.shouldTriggerCMT("push", "v6.2.0-rc.1")) // desktop convention + assert.True(t, s.shouldTriggerCMT("push", "v2.41.0-rc.1")) // future mobile + assert.True(t, s.shouldTriggerCMT("push", "v6.2.0-rc.10")) // multi-digit rc + assert.True(t, s.shouldTriggerCMT("push", "6.2.0-rc.1")) // missing 'v' prefix is permitted + assert.True(t, s.shouldTriggerCMT("push", "v6.2.0-rc1")) // no separator before number // Release branch (defense-in-depth — kept for backwards compat / manual triggers). assert.True(t, s.shouldTriggerCMT("push", "release-6.2")) // Must NOT trigger: GA tags, betas, nightly tags, feature branches, default branch. - assert.False(t, s.shouldTriggerCMT("push", "v6.2.0")) // GA tag — no -rc - assert.False(t, s.shouldTriggerCMT("push", "v1.0.22-beta")) // pre-release but not RC - assert.False(t, s.shouldTriggerCMT("push", "6.3.0-nightly.20260601")) // nightly tag - assert.False(t, s.shouldTriggerCMT("push", "v6.2.0-rcabc")) // -rc but no number + assert.False(t, s.shouldTriggerCMT("push", "v6.2.0")) // GA tag — no -rc + assert.False(t, s.shouldTriggerCMT("push", "v1.0.22-beta")) // pre-release but not RC + assert.False(t, s.shouldTriggerCMT("push", "6.3.0-nightly.20260601")) // nightly tag + assert.False(t, s.shouldTriggerCMT("push", "v6.2.0-rcabc")) // -rc but no number assert.False(t, s.shouldTriggerCMT("create", "feature/cool-thing")) assert.False(t, s.shouldTriggerCMT("push", "main")) assert.False(t, s.shouldTriggerCMT("schedule", "main")) @@ -1554,12 +1627,12 @@ func TestIsRCTag(t *testing.T) { assert.True(t, isRCTag(ref), "expected RC tag: %q", ref) } for _, ref := range []string{ - "v6.2.0", // GA - "v6.2.0-rc", // missing number - "v6.2.0-rcabc", // letters after -rc - "v1.0.22-beta", // not RC - "6.3.0-nightly.20260601", // nightly - "release-6.2", // branch + "v6.2.0", // GA + "v6.2.0-rc", // missing number + "v6.2.0-rcabc", // letters after -rc + "v1.0.22-beta", // not RC + "6.3.0-nightly.20260601", // nightly + "release-6.2", // branch "main", "", } { diff --git a/server/e2e_tests.go b/server/e2e_tests.go index b780ac0..d2e54d0 100644 --- a/server/e2e_tests.go +++ b/server/e2e_tests.go @@ -26,16 +26,54 @@ import ( // E2EInstance represents a single E2E test server instance // Note: Platform field has different meanings for desktop vs mobile: // - Desktop: Platform = OS runner (linux/macos/windows) where tests execute -// - Mobile: Platform = instance identifier (site-1/site-2/site-3) for the test server +// - Mobile: Platform = platform/site identifier for the test server type E2EInstance struct { Name string `json:"name"` - Platform string `json:"platform"` // Desktop: linux/macos/windows (OS runner), Mobile: site-1/site-2/site-3 (instance ID) + Platform string `json:"platform"` // Desktop: linux/macos/windows (OS runner), Mobile: platform/site instance ID Runner string `json:"runner"` // For desktop only: GitHub Actions runner label URL string `json:"url"` InstallationID string `json:"installation_id"` ServerVersion string `json:"server_version"` } +var mobileE2EPlatforms = []string{ + "android-site-1", + "android-site-2", + "ios-site-1", + "ios-site-2", + "site-3", +} + +var mobileE2EWorkflowInputKeys = []string{ + "ANDROID_SITE_1_URL", + "ANDROID_SITE_2_URL", + "IOS_SITE_1_URL", + "IOS_SITE_2_URL", + "SITE_3_URL", +} + +// buildMobileURLInputs maps each mobile instance's Platform to its workflow input URL. +// It validates that exactly the canonical platforms are present and pairs them with +// mobileE2EWorkflowInputKeys so callers cannot drift from the canonical order. +func buildMobileURLInputs(instances []*E2EInstance) (map[string]string, error) { + if len(instances) != len(mobileE2EPlatforms) { + return nil, fmt.Errorf("mobile E2E requires exactly %d instances, got %d", len(mobileE2EPlatforms), len(instances)) + } + platformToURL := make(map[string]string, len(instances)) + for _, inst := range instances { + platformToURL[inst.Platform] = inst.URL + } + inputs := make(map[string]string, len(mobileE2EWorkflowInputKeys)) + for i, platform := range mobileE2EPlatforms { + url, ok := platformToURL[platform] + if !ok { + return nil, fmt.Errorf("mobile E2E missing instance for platform %s", platform) + } + inputs[mobileE2EWorkflowInputKeys[i]] = url + } + return inputs, nil +} + // e2eUniqueSuffix returns an 8-char random hex suffix for unique instance names. func e2eUniqueSuffix() string { return cloudModel.NewID()[:8] @@ -85,8 +123,7 @@ func (s *Server) handleE2ETestRequest(pr *model.PullRequest, label string) { testPlatform = "all" } else if strings.Contains(pr.RepoName, "mobile") { instanceType = "mobile" - // Always create all 3 mobile instances (workflow expects SITE_1/2/3_URL). - platforms = []string{"site-1", "site-2", "site-3"} + platforms = mobileE2EPlatforms testPlatform = s.extractPlatformFromLabel(label) logger.WithField("testPlatform", testPlatform).Info("Detected mobile test platform from label (ios/android/both)") } else { @@ -310,6 +347,29 @@ func (s *Server) createCloudInstallation(ctx context.Context, name, version, use "MM_RATELIMITSETTINGS_VARYBYUSER": cloudModel.EnvVar{Value: "false"}, "MM_TEAMSETTINGS_EXPERIMENTALENABLEAUTOMATICREPLIES": cloudModel.EnvVar{Value: "true"}, } + if instanceType == "mobile" { + envVars["MM_FEATUREFLAGS_CHANNELBOOKMARKS"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_FEATUREFLAGS_INTERACTIVEDIALOGAPPSFORM"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_FEATUREFLAGS_MMBLOCKSENABLED"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_FILESETTINGS_ENABLEPUBLICLINK"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_PASSWORDSETTINGS_MINIMUMLENGTH"] = cloudModel.EnvVar{Value: "8"} + envVars["MM_PLUGINSETTINGS_ENABLEMARKETPLACE"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_PLUGINSETTINGS_ENABLEREMOTEMARKETPLACE"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_PLUGINSETTINGS_ENABLEUPLOADS"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_RATELIMITSETTINGS_MAXBURST"] = cloudModel.EnvVar{Value: "50000"} + envVars["MM_RATELIMITSETTINGS_PERSEC"] = cloudModel.EnvVar{Value: "10000"} + envVars["MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_SERVICESETTINGS_ENABLECHANNELBOOKMARKS"] = cloudModel.EnvVar{Value: "true"} + envVars["MM_SERVICESETTINGS_MAXIMUMACTIVEUSERS"] = cloudModel.EnvVar{Value: "999999"} + envVars["MM_SERVICESETTINGS_MAXIMUMLOGINATTEMPTS"] = cloudModel.EnvVar{Value: "999999"} + envVars["MM_SERVICESETTINGS_SESSIONLENGTHWEBINHOURS"] = cloudModel.EnvVar{Value: "4320"} + envVars["MM_SUPPORTSETTINGS_HELPLINK"] = cloudModel.EnvVar{Value: "https://docs.mattermost.com/"} + envVars["MM_SUPPORTSETTINGS_REPORTAPROBLEMTYPE"] = cloudModel.EnvVar{Value: "default"} + envVars["MM_TEAMSETTINGS_MAXUSERSPERTEAM"] = cloudModel.EnvVar{Value: "999999"} + envVars["MM_EXPERIMENTALSETTINGS_ENABLEWATERMARK"] = cloudModel.EnvVar{Value: "false"} + envVars["MM_EXPERIMENTALSETTINGS_RESTRICTSYSTEMADMIN"] = cloudModel.EnvVar{Value: "false"} + } installationRequest := &cloudModel.CreateInstallationRequest{ OwnerID: name, @@ -558,20 +618,23 @@ func (s *Server) triggerMobileE2EWorkflow(ctx context.Context, client *github.Cl "testPlatform": testPlatform, // ios/android/both }) - if len(instances) != 3 { - return fmt.Errorf("mobile E2E requires exactly 3 instances, got %d", len(instances)) + if len(instances) != len(mobileE2EPlatforms) { + return fmt.Errorf("mobile E2E requires exactly %d instances, got %d", len(mobileE2EPlatforms), len(instances)) + } + + // Build workflow inputs keyed by Platform so dispatch order is independent of slice order. + mobileInputs, err := buildMobileURLInputs(instances) + if err != nil { + return err } - // Build workflow inputs dynamically based on the provided instances inputs := map[string]interface{}{ "MOBILE_VERSION": pr.Sha, "PLATFORM": testPlatform, // Workflow input: which mobile OS to test (ios/android/both) "pr_number": fmt.Sprintf("%d", pr.Number), } - for i, inst := range instances { - // SITE_1_URL, SITE_2_URL, SITE_3_URL - siteKey := fmt.Sprintf("SITE_%d_URL", i+1) - inputs[siteKey] = inst.URL + for inputKey, url := range mobileInputs { + inputs[inputKey] = url } // Use the github REST API to trigger the workflow_dispatch event @@ -844,13 +907,15 @@ func (s *Server) resolveBranchHeadSHA(owner, repoName, branch string) (string, e // cmtVersion is a parsed Mattermost release version: major.minor.patch with an optional // release-candidate number. raw is the bare-semver string passed to the cloud provisioner -// (e.g. "11.7.1" or "11.8.0-rc3"). +// (e.g. "10.11.22" or "11.10.0-rc1"). type cmtVersion struct { major, minor, patch int rc int // 0 = stable, >0 = -rcN raw string } +type cmtMinorKey struct{ major, minor int } + // parseCMTVersion parses "vX.Y.Z" or "vX.Y.Z-rcN" (the leading "v" is optional). It returns // ok=false for anything else (other prerelease suffixes like -beta/-alpha are ignored for CMT). func parseCMTVersion(tag string) (cmtVersion, bool) { @@ -902,9 +967,12 @@ func (a cmtVersion) less(b cmtVersion) bool { return ar < br } +const maxCMTServerVersions = 5 +const maxCMTESRLines = 2 // current + trailing ESR; older body-flagged ESRs are treated as EOL + // cmtServerVersions returns the version set CMT runs against. An explicit, non-empty -// Config.CMTServerVersions is used verbatim (manual override / pin); otherwise the set is -// auto-derived from the Mattermost GitHub releases. +// Config.CMTServerVersions is used verbatim (manual override / pin). Otherwise the set is +// auto-derived from Mattermost GitHub releases. Shared by mobile and desktop CMT triggers. func (s *Server) cmtServerVersions() []string { if len(s.Config.CMTServerVersions) > 0 { return s.Config.CMTServerVersions @@ -912,7 +980,10 @@ func (s *Server) cmtServerVersions() []string { return s.resolveCMTServerVersions() } -// resolveCMTServerVersions fetches Mattermost releases and picks: all active ESR lines + latest 3 stable minors + current RC, one patch per line. Falls back to defaultCMTServerVersions on error. +// resolveCMTServerVersions fetches Mattermost releases and picks: the newest +// maxCMTESRLines ESR lines (body-string "extended support release") + latest 3 stable +// minors + current RC, one patch per line. Only used when Config.CMTServerVersions is +// empty. Falls back to defaultCMTServerVersions on error. func (s *Server) resolveCMTServerVersions() []string { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -954,9 +1025,8 @@ func (s *Server) resolveCMTServerVersions() []string { } } - type minorKey struct{ major, minor int } - latestStable := map[minorKey]cmtVersion{} - esrMinors := map[minorKey]bool{} + latestStable := map[cmtMinorKey]cmtVersion{} + esrMinors := map[cmtMinorKey]bool{} var bestRC cmtVersion haveRC := false @@ -968,7 +1038,7 @@ func (s *Server) resolveCMTServerVersions() []string { if !ok { continue } - key := minorKey{v.major, v.minor} + key := cmtMinorKey{v.major, v.minor} if v.rc > 0 { if !haveRC || bestRC.less(v) { bestRC = v @@ -996,15 +1066,26 @@ func (s *Server) resolveCMTServerVersions() []string { } sort.Slice(minors, func(i, j int) bool { return minors[j].less(minors[i]) }) - selected := map[minorKey]cmtVersion{} + selected := map[cmtMinorKey]cmtVersion{} for i := 0; i < len(minors) && i < 3; i++ { // latest 3 stable minor lines - selected[minorKey{minors[i].major, minors[i].minor}] = minors[i] + selected[cmtMinorKey{minors[i].major, minors[i].minor}] = minors[i] } - for k := range esrMinors { // active ESR line(s) + // Keep only the newest maxCMTESRLines ESR minors (current + trailing). Older lines + // still carry "extended support release" in historical GitHub release bodies and + // would otherwise flood the matrix (9.11 / 10.5 false positives after EOS). + esrChosen := make([]cmtVersion, 0, len(esrMinors)) + for k := range esrMinors { if v, ok := latestStable[k]; ok { - selected[k] = v + esrChosen = append(esrChosen, v) } } + sort.Slice(esrChosen, func(i, j int) bool { return esrChosen[j].less(esrChosen[i]) }) // newest first + keptESR := map[cmtMinorKey]bool{} + for i := 0; i < len(esrChosen) && i < maxCMTESRLines; i++ { + k := cmtMinorKey{esrChosen[i].major, esrChosen[i].minor} + selected[k] = esrChosen[i] + keptESR[k] = true + } chosen := make([]cmtVersion, 0, len(selected)+1) for _, v := range selected { @@ -1016,14 +1097,10 @@ func (s *Server) resolveCMTServerVersions() []string { } sort.Slice(chosen, func(i, j int) bool { return chosen[i].less(chosen[j]) }) // ascending - // Cap at 5 versions to bound provisioning cost and matrix wall-clock: latest RC - // (when present) + up to 4 previous lines. ESR-aware selection above may pick - // more if a release window has multiple active ESRs; in that case we keep the - // newest 5 and drop the oldest entries (typically the older ESR line) — surfaces - // in the [resolveCMTServerVersions] log line for the operator. - const maxVersions = 5 - if len(chosen) > maxVersions { - chosen = chosen[len(chosen)-maxVersions:] // keep the newest if over the cap + // Cap at maxCMTServerVersions. Prefer kept ESR lines over older feature minors so the + // trailing ESR is not dropped when latest-3 + 2 ESRs + RC exceeds the cap. + if len(chosen) > maxCMTServerVersions { + chosen = capCMTVersionsPreferringESR(chosen, keptESR, maxCMTServerVersions) } versions := make([]string, 0, len(chosen)) @@ -1034,6 +1111,35 @@ func (s *Server) resolveCMTServerVersions() []string { return versions } +// capCMTVersionsPreferringESR keeps at most maxN versions from an ascending list, dropping +// oldest non-ESR entries first. If only ESR lines remain and we're still over, drop the +// oldest ESR. RC counts as non-ESR but is usually newest so survives. +func capCMTVersionsPreferringESR(chosen []cmtVersion, esrMinors map[cmtMinorKey]bool, maxN int) []cmtVersion { + if len(chosen) <= maxN { + return chosen + } + + isESR := func(v cmtVersion) bool { + return v.rc == 0 && esrMinors[cmtMinorKey{v.major, v.minor}] + } + + kept := append([]cmtVersion(nil), chosen...) + for len(kept) > maxN { + dropIdx := -1 + for i, v := range kept { + if !isESR(v) { + dropIdx = i + break + } + } + if dropIdx < 0 { + dropIdx = 0 + } + kept = append(kept[:dropIdx], kept[dropIdx+1:]...) + } + return kept +} + // destroyE2EInstances destroys all given E2E instances func (s *Server) destroyE2EInstances(instances []*E2EInstance, logger logrus.FieldLogger) { for _, instance := range instances { @@ -1297,10 +1403,19 @@ func (s *Server) dispatchDesktopE2EWorkflow(repoOwner, repoName, ref, sha, insta } // dispatchMobileE2EWorkflow triggers e2e-detox-pr.yml. No tracking key in inputs — GitHub rejects undeclared workflow_dispatch inputs with 422. -func (s *Server) dispatchMobileE2EWorkflow(repoOwner, repoName, ref, sha, site1URL, site2URL, site3URL, platform, runType string) error { +func (s *Server) dispatchMobileE2EWorkflow( + repoOwner, repoName, ref, sha string, + instances []*E2EInstance, + platform, runType string, +) error { ctx := context.Background() client := newGithubClient(s.Config.GithubAccessToken) + mobileInputs, err := buildMobileURLInputs(instances) + if err != nil { + return err + } + logger := s.Logger.WithFields(logrus.Fields{ "repo": repoName, "ref": ref, @@ -1308,13 +1423,13 @@ func (s *Server) dispatchMobileE2EWorkflow(repoOwner, repoName, ref, sha, site1U // Build the workflow dispatch request workflowInputs := map[string]interface{}{ - "SITE_1_URL": site1URL, - "SITE_2_URL": site2URL, - "SITE_3_URL": site3URL, "MOBILE_VERSION": sha, "PLATFORM": platform, "run_type": runType, } + for inputKey, url := range mobileInputs { + workflowInputs[inputKey] = url + } // Use REST API to trigger workflow dispatch (v32 go-github compatibility) req, err := client.NewRequest("POST", diff --git a/server/e2e_tests_test.go b/server/e2e_tests_test.go index cc20944..20fe660 100644 --- a/server/e2e_tests_test.go +++ b/server/e2e_tests_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" mattermostModel "github.com/mattermost/mattermost-server/v6/model" @@ -208,7 +209,7 @@ func TestE2EInstanceValidation(t *testing.T) { { name: "Valid mobile instance", instance: &E2EInstance{ - Platform: "site-1", + Platform: "android-site-1", URL: "https://example.com", InstallationID: "id-123", ServerVersion: "v11.1.0", @@ -273,28 +274,40 @@ func TestMobileInstanceDetailsFormat(t *testing.T) { description string }{ { - name: "Three mobile instances (site-1, site-2, site-3)", + name: "Five mobile instances split by platform with shared site-3", instances: []*E2EInstance{ { - Platform: "site-1", - URL: "https://site1.example.com", + Platform: "android-site-1", + URL: "https://android-site1.example.com", InstallationID: "id-1", ServerVersion: "master", }, { - Platform: "site-2", - URL: "https://site2.example.com", + Platform: "android-site-2", + URL: "https://android-site2.example.com", InstallationID: "id-2", ServerVersion: "master", }, + { + Platform: "ios-site-1", + URL: "https://ios-site1.example.com", + InstallationID: "id-3", + ServerVersion: "master", + }, + { + Platform: "ios-site-2", + URL: "https://ios-site2.example.com", + InstallationID: "id-4", + ServerVersion: "master", + }, { Platform: "site-3", URL: "https://site3.example.com", - InstallationID: "id-3", + InstallationID: "id-5", ServerVersion: "master", }, }, - description: "Mobile instances should use site-1/2/3 platform naming", + description: "Mobile instances should use platform-specific site naming", }, } @@ -311,10 +324,10 @@ func TestMobileInstanceDetailsFormat(t *testing.T) { err = json.Unmarshal([]byte(result), &details) require.NoError(t, err, "Result should be valid JSON") - assert.Equal(t, 3, len(details), "Should have 3 instances") + assert.Equal(t, 5, len(details), "Should have 5 instances") // Verify platform names - expectedPlatforms := []string{"site-1", "site-2", "site-3"} + expectedPlatforms := []string{"android-site-1", "android-site-2", "ios-site-1", "ios-site-2", "site-3"} for i, detail := range details { assert.Equal(t, expectedPlatforms[i], detail["platform"], "Platform should match mobile naming scheme") } @@ -491,7 +504,7 @@ func TestE2EInstanceCreation(t *testing.T) { }, { name: "Mobile instance creation", - platform: "site-1", + platform: "ios-site-1", serverVersion: "v11.1.0", description: "Should create mobile instance with site platform and version", }, @@ -595,18 +608,32 @@ func TestInstancePlatformMapping(t *testing.T) { description: "Windows is desktop platform", }, { - name: "Site-1 platform", - platform: "site-1", + name: "Android site-1 platform", + platform: "android-site-1", + isDesktop: false, + isMobile: true, + description: "android-site-1 is mobile platform", + }, + { + name: "Android site-2 platform", + platform: "android-site-2", + isDesktop: false, + isMobile: true, + description: "android-site-2 is mobile platform", + }, + { + name: "iOS site-1 platform", + platform: "ios-site-1", isDesktop: false, isMobile: true, - description: "site-1 is mobile platform", + description: "ios-site-1 is mobile platform", }, { - name: "Site-2 platform", - platform: "site-2", + name: "iOS site-2 platform", + platform: "ios-site-2", isDesktop: false, isMobile: true, - description: "site-2 is mobile platform", + description: "ios-site-2 is mobile platform", }, { name: "Site-3 platform", @@ -620,7 +647,7 @@ func TestInstancePlatformMapping(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isDesktop := tt.platform == "linux" || tt.platform == "macos" || tt.platform == "windows" - isMobile := tt.platform == "site-1" || tt.platform == "site-2" || tt.platform == "site-3" + isMobile := slices.Contains(mobileE2EPlatforms, tt.platform) assert.Equal(t, tt.isDesktop, isDesktop, tt.description+" (desktop check)") assert.Equal(t, tt.isMobile, isMobile, tt.description+" (mobile check)") diff --git a/server/push_events.go b/server/push_events.go index f3e1410..f4b7ac7 100644 --- a/server/push_events.go +++ b/server/push_events.go @@ -161,7 +161,7 @@ func (s *Server) createMultipleE2EInstancesForPushEvent(repoName, instanceType, if instanceType == "desktop" { platforms = []string{"linux", "macos", "windows"} } else { - platforms = []string{"site-1", "site-2", "site-3"} + platforms = mobileE2EPlatforms } logger := s.Logger.WithFields(logrus.Fields{ @@ -295,22 +295,29 @@ func (s *Server) triggerMobileE2EWorkflowForPushEvent(repoOwner, repoName, branc "branch": branch, }) - if len(instances) < 3 { - logger.Errorf("Mobile E2E requires 3 instances, got %d", len(instances)) - return fmt.Errorf("mobile E2E requires 3 instances") + if len(instances) != len(mobileE2EPlatforms) { + logger.Errorf("Mobile E2E requires %d instances, got %d", len(mobileE2EPlatforms), len(instances)) + return fmt.Errorf("mobile E2E requires %d instances", len(mobileE2EPlatforms)) + } + + mobileInputs, err := buildMobileURLInputs(instances) + if err != nil { + return err } logger.WithFields(logrus.Fields{ - "site_1_url": instances[0].URL, - "site_2_url": instances[1].URL, - "site_3_url": instances[2].URL, + "android_site_1_url": mobileInputs["ANDROID_SITE_1_URL"], + "android_site_2_url": mobileInputs["ANDROID_SITE_2_URL"], + "ios_site_1_url": mobileInputs["IOS_SITE_1_URL"], + "ios_site_2_url": mobileInputs["IOS_SITE_2_URL"], + "site_3_url": mobileInputs["SITE_3_URL"], }).Debug("Triggering mobile E2E workflow for push event") // handlePushEvent only routes master/main pushes here (release-branch push trigger was // removed), so runType is always MASTER for mobile push events. return s.dispatchMobileE2EWorkflow( repoOwner, repoName, branch, sha, - instances[0].URL, instances[1].URL, instances[2].URL, + instances, "both", // push events always test both iOS and Android "MASTER", ) diff --git a/server/workflow_run.go b/server/workflow_run.go index ddf3e30..b23929e 100644 --- a/server/workflow_run.go +++ b/server/workflow_run.go @@ -10,6 +10,7 @@ import ( "io" "net/url" "regexp" + "sort" "strings" "sync" "time" @@ -300,14 +301,34 @@ func (s *Server) handleCMTTrigger(owner, repoName, branch, sha string, runID int s.handleCMTWithServerVersions(owner, repoName, instanceType, branch, sha, versions, runID, logger) } -// handleCMTWithServerVersions orchestrates CMT testing: creates one instance per server -// version, builds the CMT_MATRIX JSON, and dispatches compatibility-matrix-testing.yml once. +// capCMTServerVersions keeps at most maxCMTServerVersions entries, preferring the +// newest parseable semvers. Copies the input so Config.CMTServerVersions is not mutated. +func capCMTServerVersions(serverVersions []string) []string { + if len(serverVersions) <= maxCMTServerVersions { + return serverVersions + } + sorted := append([]string(nil), serverVersions...) + sort.Slice(sorted, func(i, j int) bool { + vi, oki := parseCMTVersion(sorted[i]) + vj, okj := parseCMTVersion(sorted[j]) + if oki != okj { + return !oki // unparseable first so the newest tail keeps valid versions + } + if !oki { + return sorted[i] < sorted[j] + } + return vi.less(vj) + }) + return sorted[len(sorted)-maxCMTServerVersions:] +} + +// handleCMTWithServerVersions orchestrates CMT testing and dispatches compatibility-matrix-testing.yml once. func (s *Server) handleCMTWithServerVersions(repoOwner, repoName, instanceType, branch, sha string, serverVersions []string, runID int64, logger logrus.FieldLogger) { - // Cap at 5 — also enforced by resolveCMTServerVersions, but Config.CMTServerVersions can bypass that. - const maxVersions = 5 - if len(serverVersions) > maxVersions { - logger.Warnf("Capping server versions from %d to %d", len(serverVersions), maxVersions) - serverVersions = serverVersions[:maxVersions] + // Cap at maxCMTServerVersions. Auto-resolve already enforces this with ESR + // preference; this is a backstop for a mis-set Config.CMTServerVersions override. + if len(serverVersions) > maxCMTServerVersions { + logger.Warnf("Capping server versions from %d to %d (keeping newest)", len(serverVersions), maxCMTServerVersions) + serverVersions = capCMTServerVersions(serverVersions) } logger = logger.WithFields(logrus.Fields{ @@ -330,16 +351,37 @@ func (s *Server) handleCMTWithServerVersions(repoOwner, repoName, instanceType, // Docker Hub tags use bare semver (e.g. "11.6.0"), not "v11.6.0". version = strings.TrimPrefix(version, "v") - logger.WithField("version", version).Info("Creating CMT instance for server version") + logger.WithField("version", version).Info("Creating CMT instances for server version") - instance, err := s.createSingleCMTInstance(repoName, instanceType, version, logger) - if err != nil { - logger.WithError(err).Errorf("Failed to create instance for version %s; rolling back partial CMT matrix", version) - s.destroyE2EInstances(allInstances, logger) + var versionInstances []*E2EInstance + if instanceType == "mobile" { + var err error + versionInstances, err = s.createMobileCMTInstances(context.Background(), repoName, version, logger) + if err != nil { + logger.WithError(err).Errorf("Failed to create topology for version %s; rolling back partial CMT matrix", version) + s.destroyE2EInstances(allInstances, logger) + return + } + } else { + instance, err := s.createSingleCMTInstance(context.Background(), repoName, instanceType, version, "", logger) + if err != nil { + logger.WithError(err).Errorf("Failed to create instance for version %s; rolling back partial CMT matrix", version) + s.destroyE2EInstances(allInstances, logger) + return + } + versionInstances = []*E2EInstance{instance} + } + expectedInstances := 1 + if instanceType == "mobile" { + expectedInstances = len(mobileE2EPlatforms) + } + if len(versionInstances) != expectedInstances { + logger.Errorf("Failed to create complete CMT topology for version %s; rolling back partial CMT matrix", version) + s.destroyE2EInstances(append(allInstances, versionInstances...), logger) return } - allInstances = append(allInstances, instance) + allInstances = append(allInstances, versionInstances...) validVersions = append(validVersions, version) } @@ -395,24 +437,81 @@ func (s *Server) handleCMTWithServerVersions(repoOwner, repoName, instanceType, } // createSingleCMTInstance creates one Mattermost cloud instance for a CMT server version. -// CMT only needs one server per version — the test matrix handles platform parallelism. -func (s *Server) createSingleCMTInstance(repoName, instanceType, version string, logger logrus.FieldLogger) (*E2EInstance, error) { - // Name format: {type}-{version}-{hex6} +func (s *Server) createSingleCMTInstance(ctx context.Context, repoName, instanceType, version, platform string, logger logrus.FieldLogger) (*E2EInstance, error) { sanitizedVersion := sanitizeForDNS(version) uid := e2eUniqueSuffix() - name := e2eInstanceName(s.Config.DNSNameTestServer, instanceType, sanitizedVersion, uid) + nameParts := []string{instanceType, sanitizedVersion} + if platform != "" { + nameParts = append(nameParts, platform) + } + nameParts = append(nameParts, uid) + name := e2eInstanceName(s.Config.DNSNameTestServer, nameParts...) username := s.Config.E2EUsername password := s.getE2EPassword(instanceType) - return s.createCloudInstallation(context.Background(), name, version, username, password, instanceType, logger) + instance, err := s.createCloudInstallation(ctx, name, version, username, password, instanceType, logger) + if instance != nil { + instance.Platform = platform + } + return instance, err } -// cmtServer is one entry in CMT_MATRIX. Latest is set on the highest-semver entry (mobile only); omitempty keeps it absent from desktop JSON. +// createMobileCMTInstances creates one five-server topology for a server version. +func (s *Server) createMobileCMTInstances(ctx context.Context, repoName, version string, logger logrus.FieldLogger) ([]*E2EInstance, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + type result struct { + instance *E2EInstance + err error + } + results := make([]result, len(mobileE2EPlatforms)) + var wg sync.WaitGroup + var firstErr error + var errMu sync.Mutex + for i, platform := range mobileE2EPlatforms { + wg.Add(1) + go func(idx int, platform string) { + defer wg.Done() + instance, err := s.createSingleCMTInstance(ctx, repoName, "mobile", version, platform, logger) + results[idx] = result{instance: instance, err: err} + if err != nil { + errMu.Lock() + if firstErr == nil { + firstErr = err + cancel() + } + errMu.Unlock() + } + }(i, platform) + } + wg.Wait() + + instances := make([]*E2EInstance, 0, len(results)) + for _, result := range results { + if result.err != nil { + continue + } + instances = append(instances, result.instance) + } + if firstErr != nil { + s.destroyE2EInstances(instances, logger) + return nil, firstErr + } + return instances, nil +} + +// cmtServer is one entry in CMT_MATRIX. Mobile entries carry a five-server topology. type cmtServer struct { - Version string `json:"version"` - URL string `json:"url"` - Latest bool `json:"latest,omitempty"` + Version string `json:"version"` + URL string `json:"url,omitempty"` + AndroidSite1URL string `json:"android_site_1_url,omitempty"` + AndroidSite2URL string `json:"android_site_2_url,omitempty"` + IOSSite1URL string `json:"ios_site_1_url,omitempty"` + IOSSite2URL string `json:"ios_site_2_url,omitempty"` + Site3URL string `json:"site_3_url,omitempty"` + Latest bool `json:"latest,omitempty"` } // buildDesktopCMTMatrixJSON builds CMT_MATRIX for compatibility-matrix-testing.yml: 3 fixed environment runners × N server versions. @@ -447,7 +546,7 @@ func buildDesktopCMTMatrixJSON(versions []string, instances []*E2EInstance) (str return string(b), nil } -// buildMobileCMTMatrixJSON builds CMT_MATRIX for compatibility-matrix-testing.yml: N server entries, highest-semver marked latest:true. +// buildMobileCMTMatrixJSON builds one five-server topology per version, with the highest semver marked latest:true. func buildMobileCMTMatrixJSON(versions []string, instances []*E2EInstance) (string, error) { type mobileCMTMatrix struct { Server []cmtServer `json:"server"` @@ -456,10 +555,12 @@ func buildMobileCMTMatrixJSON(versions []string, instances []*E2EInstance) (stri // Mark the highest-parseable version as latest; fall back to last entry if none parse. latestIdx := -1 var latestVer cmtVersion + requiredInstances := len(versions) * len(mobileE2EPlatforms) + if len(instances) != requiredInstances { + return "", fmt.Errorf("mobile CMT requires %d instances for %d versions, got %d", requiredInstances, len(versions), len(instances)) + } + for i, version := range versions { - if i >= len(instances) { - break - } v, ok := parseCMTVersion(version) if !ok { continue @@ -475,10 +576,30 @@ func buildMobileCMTMatrixJSON(versions []string, instances []*E2EInstance) (stri var matrix mobileCMTMatrix for i, version := range versions { - if i >= len(instances) { - break + entry := cmtServer{Version: version} + block := instances[i*len(mobileE2EPlatforms) : (i+1)*len(mobileE2EPlatforms)] + platformToURL := make(map[string]string, len(block)) + for _, inst := range block { + platformToURL[inst.Platform] = inst.URL + } + for _, platform := range mobileE2EPlatforms { + url, ok := platformToURL[platform] + if !ok { + return "", fmt.Errorf("mobile CMT missing instance for platform %s in version %s", platform, version) + } + switch platform { + case "android-site-1": + entry.AndroidSite1URL = url + case "android-site-2": + entry.AndroidSite2URL = url + case "ios-site-1": + entry.IOSSite1URL = url + case "ios-site-2": + entry.IOSSite2URL = url + case "site-3": + entry.Site3URL = url + } } - entry := cmtServer{Version: version, URL: instances[i].URL} if i == latestIdx { entry.Latest = true } @@ -638,4 +759,3 @@ func (s *Server) pollDispatchedWorkflowRun(repoOwner, repoName, workflowFile, br return 0, fmt.Errorf("timed out polling for dispatched workflow run on branch %s", branch) } - diff --git a/server/workflow_run_test.go b/server/workflow_run_test.go index dbcb5a9..f9b4730 100644 --- a/server/workflow_run_test.go +++ b/server/workflow_run_test.go @@ -583,3 +583,57 @@ func TestVersionParsingWithVariations(t *testing.T) { }) } } + +func TestCapCMTServerVersions(t *testing.T) { + t.Run("returns input unchanged when at or under cap", func(t *testing.T) { + in := []string{"10.11.22", "11.7.7", "11.9.0"} + got := capCMTServerVersions(in) + if len(got) != len(in) { + t.Fatalf("expected %d, got %d", len(in), len(got)) + } + for i := range in { + if got[i] != in[i] { + t.Errorf("index %d: expected %s, got %s", i, in[i], got[i]) + } + } + }) + + t.Run("keeps newest semvers regardless of input order", func(t *testing.T) { + in := []string{"11.8.0", "10.11.22", "11.10.0", "11.7.7", "11.9.0", "11.11.0-rc1"} + got := capCMTServerVersions(in) + want := []string{"11.7.7", "11.8.0", "11.9.0", "11.10.0", "11.11.0-rc1"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: expected %s, got %s", i, want[i], got[i]) + } + } + }) + + t.Run("does not mutate input slice", func(t *testing.T) { + in := []string{"11.8.0", "10.11.22", "11.10.0", "11.7.7", "11.9.0", "11.11.0-rc1"} + orig := append([]string(nil), in...) + _ = capCMTServerVersions(in) + for i := range orig { + if in[i] != orig[i] { + t.Fatalf("input mutated at %d: want %s, got %s", i, orig[i], in[i]) + } + } + }) + + t.Run("drops unparseable values when mixed with valid versions", func(t *testing.T) { + in := []string{"not-a-version", "11.10.0", "also-bad", "11.9.0", "11.8.0", "11.7.7", "10.11.22"} + got := capCMTServerVersions(in) + want := []string{"10.11.22", "11.7.7", "11.8.0", "11.9.0", "11.10.0"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: expected %s, got %s", i, want[i], got[i]) + } + } + }) +}