Roll out Vue 3 on remaining pages with vue3_migrate_final_sweep

What does this MR do and why?

Not every team will migrate its pages to Vue 3 on its own. This MR moves every remaining page entry and global bundle to Vue 3 in one step, behind one feature flag: vue3_migrate_final_sweep. The flag is off by default.

The MR does these things:

  • It adds a vue3_migration.yml with status: rollout and feature_flag: vue3_migrate_final_sweep to each page entry that has no yml on master. An entry that exists in both CE and EE gets two identical files.
  • It does the same for each global bundle in config/helpers/entry_points.js that has no yml, for example sentry and tracker. A global bundle uses <name>.vue3_migration.yml beside its <name>.js. main is not a bundle of its own, so it is not in scope.
  • It skips the entries that an open MR already migrates. Those MRs keep their own flags.
  • It defines the flag in config/feature_flags/beta/vue3_migrate_final_sweep.yml, owned by group::pipeline authoring.
  • It fixes specs that would fail otherwise:
    • one example in spec/helpers/webpack_helper_spec.rb.
    • spec/helpers/listbox_helper_spec.rb and the fixture spec/frontend/fixtures/listbox.rb. With a yml on redirect_listbox, webpack_bundle_tag reads current_user, which raises Devise::MissingWarden in a helper spec. The specs now stub it.

When a team later migrates its own page or bundle, it changes the feature_flag line in that page's yml to its own flag.

!255334 (closed) runs the same setup as a stress test that never merges. Its pipelines show that CI passes with every page in the Vue 3 build.

Why the webpack helper spec fails without the fix

The example "walks up to the ancestor entry without tracking" stubs entrypoint_for only for pages.projects.jobs.show. For every other entry it calls the real method, so the result depends on which ymls are on disk. The stubbed manifest serves only pages.projects.

RSpec enables a feature flag that has no persisted value. With a yml on pages/projects, the ancestor resolves to pages.projects.vue3. The manifest does not have that entry, so the helper falls back to pages.projects and calls track_exception. The example expects no call.

The example now resolves every other entry to its own name, the same way the next context does.

How to regenerate this MR

The cutoff is 2026-10-26 00:00 UTC. An MR opened after the cutoff does not exclude entries from this MR.

Do not rebase this branch. Regenerate it on the cutoff date, just before merge, so that it matches master and the open MRs of that day.

  1. Reset the branch onto a fresh master:

    git fetch origin
    git checkout -B vue3-migrate-laggards origin/master
  2. Cherry-pick these commits, unless master already has them:

    • "Keep webpack helper fallback spec independent of yml files"
    • "Stub current_user in redirect listbox helper specs"
  3. Restore the flag definition:

    git checkout origin/vue3-migrate-laggards -- config/feature_flags/beta/vue3_migrate_final_sweep.yml
  4. Run the script below from the repository root. It needs glab and jq, and takes about 3 minutes.

  5. Read the list of excluded entries. Decide case by case what to do with each MR that is still open. To put the entries of an MR under the final sweep flag, run the script again with INCLUDE_MRS="<iid> <iid>".

  6. Run bin/rspec spec/lib/gitlab/vue3_migration_files_spec.rb.

  7. Commit the ymls together with the flag definition, then push with git push --force-with-lease.

  8. Update the result section below.

The script covers page entries and global bundles. It finds open MRs that have the vue3-migration label, or "vue3" or "Vue 3" in the title or description. It does not find an MR that has none of these. A team that migrates a page before the cutoff must add the label.

#!/usr/bin/env bash
# Adds a vue3_migration.yml for vue3_migrate_final_sweep to every page entry and
# global bundle that has none on master and that no open MR migrates. Run from
# the repo root.
set -euo pipefail

project=278964
roots=(app/assets/javascripts/pages ee/app/assets/javascripts/pages jh/app/assets/javascripts/pages)
work=$(mktemp -d)

# 1. Open MRs that may migrate an entry: the vue3-migration label, or "vue3"
#    in the title or description. INCLUDE_MRS="123 456" ignores those MRs,
#    so that their entries join the final sweep flag.
{
  glab api --paginate "projects/$project/merge_requests?state=opened&labels=vue3-migration&per_page=100"
  for q in vue3 'Vue%203'; do
    glab api --paginate "projects/$project/merge_requests?state=opened&in=title,description&search=$q&per_page=100"
  done
} | jq -r '.[].iid' | sort -u > "$work/found"
# This MR (!261154) and the stress test (!255334) are too large for the diffs
# API and only add final sweep or stress test files, so skip them. The Vue 2
# removal POC (!263207) and the Vue-3-only @gitlab/ui experiment (!264182)
# change ymls but never merge, so skip them too.
comm -23 "$work/found" <(printf '%s\n' 261154 255334 263207 264182 ${INCLUDE_MRS:-} | sort -u) > "$work/iids"

# 2. Migration files those MRs add, as `vue3_migration.yml` (pages) or
#    `<name>.vue3_migration.yml` (global bundles). Strip the ee/ or jh/ prefix,
#    so the CE and EE copies of an entry are excluded together. Files for the
#    final sweep flag (this MR) or the stress test (!255334) do not count.
while read -r iid; do
  if ! diffs=$(glab api --paginate "projects/$project/merge_requests/$iid/diffs?per_page=100"); then
    echo "Could not read the diffs of !$iid. Check it by hand." >&2
    continue
  fi
  printf '%s' "$diffs" |
    jq -r --arg iid "$iid" '.[] | select(.new_path | endswith("vue3_migration.yml")) |
      select(.diff | test("vue3_migrate_(final_sweep|stress_test)") | not) |
      "\(.new_path | sub("^(ee|jh)/"; ""))\t!\($iid)"'
done < "$work/iids" | sort -u > "$work/in_flight"

echo "Entries excluded because an open MR migrates them:"
cat "$work/in_flight"
echo "Excluded entries per MR:"
cut -f2 "$work/in_flight" | sort | uniq -c
cut -f1 "$work/in_flight" > "$work/in_flight_keys"

# 3. The migration file of every entry: one per pages/**/index.js, and one per
#    global bundle in config/helpers/entry_points.js. `main` has no bundle of
#    its own, so it cannot be migrated this way.
{
  { find "${roots[@]}" -name index.js 2>/dev/null || true; } | sed 's#index\.js$#vue3_migration.yml#'
  node -e '
    const path = require("path");
    const { baseEntryPoints } = require("./config/helpers/entry_points");
    for (const [name, spec] of Object.entries(baseEntryPoints)) {
      if (name === "main" || typeof spec !== "string") continue;
      const file = path.posix.join("app/assets/javascripts", spec);
      const base = path.posix.basename(file, ".js");
      const yml = base === "index" ? "vue3_migration.yml" : `${base}.vue3_migration.yml`;
      console.log(path.posix.join(path.posix.dirname(file), yml));
    }'
} | sort -u > "$work/targets"

# 4. Write the file for every entry that has none and is not in flight.
{ find app ee/app jh/app -path '*/assets/javascripts/*' -name '*vue3_migration.yml' 2>/dev/null || true; } |
  sed -E 's#^(ee|jh)/##' | sort -u > "$work/existing"
added=0
while read -r file; do
  key=${file#ee/}; key=${key#jh/}
  [ -f "$file" ] && continue
  # Not `cut | grep -q`: grep exits early, cut gets SIGPIPE, and pipefail
  # makes the check fail, so an in-flight entry would get a yml.
  grep -qxF "$key" "$work/in_flight_keys" && continue
  # CE and EE copies must be byte-identical, so never pair ours with theirs.
  if grep -qxF "$key" "$work/existing"; then
    echo "Skipped $file: another edition already has a yml" >&2
    continue
  fi
  printf 'status: rollout\nfeature_flag: vue3_migrate_final_sweep\n' > "$file"
  added=$((added + 1))
done < "$work/targets"

echo "Added $added vue3_migration.yml files."

Last result

On 2026-10-26, on master 9da11c3c0962, the script added 209 files. This was the same file list as on 2026-10-22. This is the final run: later changes are separate commits, and the branch is not regenerated again.

After review, the MR adds 208 files:

  • 206 files for page entries.
  • 2 files for global bundles: redirect_listbox and graphql_explorer.

Changes after the run:

  • jira_connect_app is removed, so that vue3_migrate_jira_connect_app keeps control of its rollout and rollback. A global enable of the final sweep flag applies also when there is no current user. See !261154 (comment 4090890518).
  • The flag milestone is 19.6, so that the flag-state logging used by the rollout issue works. See !261154 (comment 4090890479).

This result matches the cutoff:

  • Master changed no vue3_migration.yml file between the cutoff and 9da11c3c0962.
  • All the MRs in the table below were opened before the cutoff.

These global bundles already have a yml on master, so the script keeps them:

  • duo_panel, performance_bar and super_sidebar, with their own flags.
  • coverage_persistence and tracker, with status: migrated.

These open MRs exclude entries from this MR:

MR Excluded entries What the MR does
!263131 24 entries (26 files) group::seat management pages under their own flag vue3_migrate_seat_management. The list matches the one in !261154 (comment 4039584852).
!259934 9 entries Group and project wikis. It changes ymls that master already has, so it does not change the output.
!263563 7 entries Merge request pages under their own flag vue3_migrate_merge_request_pages.
!262578 (merged) 1 entry Organization Access Artifact Registry members page.
!264748 1 entry New Policy Store page with its own flag vue3_migrate_policy_store. The entry is not on master.
!261499 (merged) 1 entry entrypoints/main.vue3_migration.yml. main is out of scope here, so this does not change the output.

Since the previous run, master got its own yml for the admin metrics and profiling settings page, from !263978 (merged). This MR did not add that file before either.

The script skips these MRs, because they match the search but never merge:

  • The Vue 2 removal POC !263207.
  • The Vue-3-only @gitlab/ui experiment !264182. It changes 352 entries.

projects.settings.merge_requests is a settings page, not a merge request page, so it stays in this MR.

Script fix in this run

Step 4 used cut -f1 in_flight | grep -qxF "$key". grep -q exits on the first match. When the list is long, cut then gets SIGPIPE. With pipefail, the check fails and the script writes a yml for an entry that an open MR migrates. The 352 entries of !264182 made this happen: 21 seat management files got a yml. The same bug probably caused the 18 wrong seat management files in the push of 2026-10-20. The script now reads the keys from a file.

The script also prints the number of excluded entries per MR.

Screenshots or screen recordings

No UI change while the flag is off.

How to set up and validate locally

  1. Enable the flag in the Rails console: Feature.enable(:vue3_migrate_final_sweep).
  2. Open a page in scope, for example the admin dashboard at /admin.
  3. In the browser console, run document.querySelectorAll('[data-gitlab-vue3-app]').length. The result is greater than 0 when the page mounts a Vue app.

Checks done so far:

  • node -e 'require("./config/vue3migration/migration.js").loadVue3Migrations()' loads all 559 entries.
  • spec/lib/gitlab/vue3_migration_files_spec.rb passed on the regenerated version, before the Jira Connect removal: 3082 examples, 0 failures.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

References

Edited by Miguel Rincon

Merge request reports

Loading
Loading