From 44fc4fb379393db6e120b1966f08cfed96b90b51 Mon Sep 17 00:00:00 2001 From: "Thing-han, Lim" <15379156+potsrevennil@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:25:40 +0800 Subject: [PATCH 1/2] fix(ci): pin plugins to lazy-lock.json on a fresh restore A bare `Lazy! restore` on a cache miss lets lazy.nvim's first-run install rewrite the lockfile before LazyVim's imported specs are known, leaving those plugins at their upstream target. Install first, copy the committed lock back, restore, and cmp the result -- the same sequence lazy-update.yml uses. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23f16e..4a7e67f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,8 +121,23 @@ jobs: with: path: ~/.local/share/nvim/lazy key: lazy-${{ matrix.runs-on }}-${{ hashFiles('lazy-lock.json') }} + # The wrapped nvim loads its config from the read-only store, so + # lazy.nvim reads and writes the lockfile through a stdpath("state") + # copy (see lua/config/lazy.lua), hence the copy in below. + # + # A bare `Lazy! restore` on a fresh runner (cache miss above) doesn't + # reproduce the lock: the first-run install handles LazyVim before the + # plugins its specs import are known, rewrites the lockfile without + # their entries, and then installs them at their upstream target. So + # install first, put the committed lock back, restore, and check it + # took. On a cache hit every plugin is already on disk, so the first + # `+qa` installs nothing. - name: Restore plugins - run: nvim --headless "+Lazy! restore" +qa + run: | + nvim --headless +qa + cp lazy-lock.json ~/.local/state/nvim/lazy-lock.json + nvim --headless "+Lazy! restore" +qa + cmp lazy-lock.json ~/.local/state/nvim/lazy-lock.json - name: Run smoke test run: nvim --headless -S .github/scripts/smoke-test.lua # Appended here rather than a separate workflow: this job already paid From 6ec91cf99bc50cbe997868bd2c33c0a9621626bd Mon Sep 17 00:00:00 2001 From: "Thing-han, Lim" <15379156+potsrevennil@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:59:53 +0800 Subject: [PATCH 2/2] refactor(ci): share build and plugin restore via a composite action ci.yml's test job and lazy-update.yml both built a kapi-vim package, put it on PATH and ran the checked lazy.nvim restore, with the restore sequence and its comment duplicated. .github/actions/setup-kapi-vim now does all three, plus the plugin cache. The cache is split into restore/save so lazy-update can read CI's cache without writing its updated plugins under the old lockfile's key; the save also moves to straight after the checked restore. The key changes from runs-on to OS/arch since a composite action can't see the matrix, so the first run after this misses once. Co-Authored-By: Claude Opus 5.5 --- .github/actions/setup-kapi-vim/action.yaml | 61 ++++++++++++++++++++++ .github/workflows/ci.yml | 43 ++------------- .github/workflows/lazy-update.yml | 29 +++------- 3 files changed, 73 insertions(+), 60 deletions(-) create mode 100644 .github/actions/setup-kapi-vim/action.yaml diff --git a/.github/actions/setup-kapi-vim/action.yaml b/.github/actions/setup-kapi-vim/action.yaml new file mode 100644 index 0000000..a816d16 --- /dev/null +++ b/.github/actions/setup-kapi-vim/action.yaml @@ -0,0 +1,61 @@ +name: 'Set up kapi-vim' +description: 'Build a kapi-vim package, put its nvim on PATH, and restore every lazy.nvim plugin to the committed lazy-lock.json' +inputs: + target: + description: 'Package to build: lite or bundle (bundle reads $KAPI_VIM_LANGS)' + required: true + save-cache: + description: "Save the plugin cache on a miss. Off for callers that change plugins afterwards, so they can't store them under this lockfile's key." + default: 'true' +runs: + using: 'composite' + steps: + - uses: ./.github/actions/install-nix + # --impure so bundle can read $KAPI_VIM_LANGS; a no-op for lite, which + # never calls builtins.getEnv. + - name: Build and put nvim on PATH + shell: bash + run: | + out=$(nix build --impure ".#${{ inputs.target }}" --print-build-logs --no-link --print-out-paths) + echo "$out/bin" >> "$GITHUB_PATH" + # Confirmed root cause of CI's inflated startup numbers (see PR #44): + # without this cache, plugin files land fresh from a network git clone + # on every run, and each cold file read shows up inside nvim's own + # require()/sourcing timings (--startuptime measures the whole call + # including the read, not just execution). That's what was producing + # 100-600ms readings vs. ~25ms locally for the identical build -- not a + # slow plugin, not CPU contention, not codesign/Gatekeeper overhead (all + # measured and ruled out, see ci.yml's benchmark step). Adding this cache + # alone took every matrix entry down to ~20-27ms, matching local numbers + # almost exactly. Keyed on OS and arch because telescope-fzf-native's + # native build output is platform-specific. + - id: cache + uses: actions/cache/restore@v4 + with: + path: ~/.local/share/nvim/lazy + key: lazy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lazy-lock.json') }} + # The wrapped nvim loads its config from the read-only store, so + # lazy.nvim reads and writes the lockfile through a stdpath("state") + # copy (see lua/config/lazy.lua), hence the copy in below. + # + # A bare `Lazy! restore` on a fresh runner (cache miss above) doesn't + # reproduce the lock: the first-run install handles LazyVim before the + # plugins its specs import are known, rewrites the lockfile without + # their entries, and then installs them at their upstream target. So + # install first, put the committed lock back, restore, and check it + # took. On a cache hit every plugin is already on disk, so the first + # `+qa` installs nothing. + - name: Restore plugins + shell: bash + run: | + nvim --headless +qa + cp lazy-lock.json ~/.local/state/nvim/lazy-lock.json + nvim --headless "+Lazy! restore" +qa + cmp lazy-lock.json ~/.local/state/nvim/lazy-lock.json + # Saved here, straight after the checked restore, rather than at job end: + # the cache then always holds exactly the locked plugins. + - if: inputs.save-cache == 'true' && steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.local/share/nvim/lazy + key: ${{ steps.cache.outputs.cache-primary-key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a7e67f..33714a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,8 @@ jobs: # new user gets, not a hermetic nix build -- but a reproducible one: # `:Lazy sync` fetches whatever's newest upstream and rewrites the # lockfile, making this job a moving target; `:Lazy restore` pins to the - # committed lockfile instead (see lua/config/lazy.lua for why that lockfile - # is actually reachable here). Then verifies startup is clean and the + # committed lockfile instead (see .github/actions/setup-kapi-vim for how, + # and the plugin cache that keeps the benchmark below honest). Then verifies startup is clean and the # expected LSP binaries for that entry's languages resolve on PATH (see # smoke-test.lua). # @@ -100,44 +100,9 @@ jobs: KAPI_VIM_LANGS: ${{ matrix.langs }} steps: - uses: actions/checkout@v7 - - uses: ./.github/actions/install-nix - - id: build - run: echo "out=$(nix build --impure .#${{ matrix.target }} --print-build-logs --no-link --print-out-paths)" >> "$GITHUB_OUTPUT" - - name: Put bin on PATH - run: echo "${{ steps.build.outputs.out }}/bin" >> "$GITHUB_PATH" - # Confirmed root cause of CI's inflated startup numbers below (see - # PR #44): without this cache, plugin files land fresh from a network - # git clone on every run, and each cold file read shows up inside - # nvim's own require()/sourcing timings (--startuptime measures the - # whole call including the read, not just execution). That's what was - # producing 100-600ms readings vs. ~25ms locally for the identical - # build -- not a slow plugin, not CPU contention, not codesign/ - # Gatekeeper overhead (all measured and ruled out, see below). Adding - # this cache alone took every matrix entry down to ~20-27ms, matching - # local numbers almost exactly. Keyed on runs-on because - # telescope-fzf-native's native build output is arch-specific. - - name: Cache lazy.nvim plugins - uses: actions/cache@v4 + - uses: ./.github/actions/setup-kapi-vim with: - path: ~/.local/share/nvim/lazy - key: lazy-${{ matrix.runs-on }}-${{ hashFiles('lazy-lock.json') }} - # The wrapped nvim loads its config from the read-only store, so - # lazy.nvim reads and writes the lockfile through a stdpath("state") - # copy (see lua/config/lazy.lua), hence the copy in below. - # - # A bare `Lazy! restore` on a fresh runner (cache miss above) doesn't - # reproduce the lock: the first-run install handles LazyVim before the - # plugins its specs import are known, rewrites the lockfile without - # their entries, and then installs them at their upstream target. So - # install first, put the committed lock back, restore, and check it - # took. On a cache hit every plugin is already on disk, so the first - # `+qa` installs nothing. - - name: Restore plugins - run: | - nvim --headless +qa - cp lazy-lock.json ~/.local/state/nvim/lazy-lock.json - nvim --headless "+Lazy! restore" +qa - cmp lazy-lock.json ~/.local/state/nvim/lazy-lock.json + target: ${{ matrix.target }} - name: Run smoke test run: nvim --headless -S .github/scripts/smoke-test.lua # Appended here rather than a separate workflow: this job already paid diff --git a/.github/workflows/lazy-update.yml b/.github/workflows/lazy-update.yml index e3a6eda..8d9710f 100644 --- a/.github/workflows/lazy-update.yml +++ b/.github/workflows/lazy-update.yml @@ -29,32 +29,19 @@ jobs: actions: write steps: - uses: actions/checkout@v7 - - uses: ./.github/actions/install-nix - - id: build - run: echo "out=$(nix build .#lite --print-build-logs --no-link --print-out-paths)" >> "$GITHUB_OUTPUT" - - name: Put bin on PATH - run: echo "${{ steps.build.outputs.out }}/bin" >> "$GITHUB_PATH" - # The wrapped nvim loads its config from the read-only store, so - # lazy.nvim reads and writes the lockfile through a stdpath("state") - # copy (see lua/config/lazy.lua), hence the copies in and out below. - # - # A bare `Lazy! restore` on a fresh runner doesn't reproduce the lock: - # the first-run install handles LazyVim before the plugins its specs - # import are known, rewrites the lockfile without their entries, and - # then installs them at their upstream target. So install first, put - # the committed lock back, restore, and check it took -- otherwise the - # update below would be measured from the wrong commits. - - name: Restore plugins - run: | - nvim --headless +qa - cp lazy-lock.json ~/.local/state/nvim/lazy-lock.json - nvim --headless "+Lazy! restore" +qa - cmp lazy-lock.json ~/.local/state/nvim/lazy-lock.json + # Reads CI's plugin cache for the same lockfile, but never writes it: + # the plugins on disk are about to move past that lockfile. + - uses: ./.github/actions/setup-kapi-vim + with: + target: lite + save-cache: 'false' - name: Update to new releases env: LAZY_UPDATE_REPORT: ${{ runner.temp }}/report.md run: | nvim --headless -c "luafile .github/scripts/lazy-update.lua" + # Bring lazy.nvim's stdpath("state") lockfile back into the + # checkout (see .github/actions/setup-kapi-vim). cp ~/.local/state/nvim/lazy-lock.json lazy-lock.json cat "$LAZY_UPDATE_REPORT" >> "$GITHUB_STEP_SUMMARY" - id: diff