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/scripts/lazy-update.lua b/.github/scripts/lazy-update.lua new file mode 100644 index 0000000..c9b18ee --- /dev/null +++ b/.github/scripts/lazy-update.lua @@ -0,0 +1,134 @@ +-- Release-only plugin updates, run by .github/workflows/lazy-update.yml: +-- +-- nvim --headless -c "luafile .github/scripts/lazy-update.lua" +-- +-- `:Lazy sync` moves every plugin to whatever it resolves to upstream, +-- which for most plugins is the branch head. This instead updates only the +-- plugins whose target is a tagged semver release (defaults.version = "*" +-- in lua/config/lazy.lua) and holds the rest -- plugins with an explicit +-- branch, `version = false`, or no release tags at all -- at their locked +-- commit, since there's no release to tell a fix from a work in progress. +-- +-- Forward only: a release that the locked commit already contains is +-- skipped. Plenty of plugins are locked past their last tag (plenary's is +-- years old), and "latest release" would otherwise mean a downgrade. +-- Releases cut from a separate branch (neo-tree's v3.x) aren't ancestors +-- either way, so those are compared by version instead. +-- +-- Writes a markdown summary to $LAZY_UPDATE_REPORT (for the PR body) and +-- exits non-zero if the check or update fails. + +local Config = require("lazy.core.config") +local Git = require("lazy.manage.git") +local Manage = require("lazy.manage") +local Semver = require("lazy.manage.semver") + +local function fail(msg) + io.stderr:write("lazy-update: " .. msg .. "\n") + vim.cmd("cquit 1") +end + +local function git(dir, args) + local res = vim.system(vim.list_extend({ "git", "-C", dir }, args), { text = true }):wait() + return res.code == 0 and vim.trim(res.stdout) or nil, res.code +end + +-- Does `commit` already contain `target`? +local function contains(dir, commit, target) + local _, code = git(dir, { "merge-base", "--is-ancestor", target, commit }) + return code == 0 +end + +-- Nearest release tag at or behind `commit`. Version-shaped tags only: +-- several plugins keep a floating `stable` tag that describe would pick. +local function base_version(dir, commit) + local tag = git(dir, { "describe", "--tags", "--abbrev=0", "--match=v[0-9]*", "--match=[0-9]*", commit }) + return tag and Semver.version(tag) +end + +local function level(from, to) + if not from then + return "release" + elseif from.major ~= to.major then + return "major" + elseif from.minor ~= to.minor then + return "minor" + end + return "patch" +end + +local ok, err = pcall(function() + Manage.check({ wait = true, show = false }) +end) +if not ok then + return fail("check failed: " .. tostring(err)) +end + +local bumps, held = {}, {} +for name, plugin in pairs(Config.plugins) do + local updates = plugin._.updates + if updates then + local from = Git.info(plugin.dir, true) + local to = updates.to + local base = from and (from.version or base_version(plugin.dir, from.commit)) + if + to.version + and from + and not contains(plugin.dir, from.commit, to.commit) + and (not base or base < to.version) + then + bumps[#bumps + 1] = { + name = name, + from = from.tag or from.commit:sub(1, 7), + to = to.tag, + level = level(base, to.version), + } + else + held[#held + 1] = name + end + end +end +table.sort(bumps, function(a, b) + return a.name < b.name +end) +table.sort(held) + +if #bumps > 0 then + ok, err = pcall(function() + Manage.update({ + wait = true, + show = false, + plugins = vim.tbl_map(function(b) + return b.name + end, bumps), + }) + end) + if not ok then + return fail("update failed: " .. tostring(err)) + end +end + +local lines = {} +if #bumps > 0 then + vim.list_extend(lines, { "| Plugin | From | To | Change |", "|---|---|---|---|" }) + for _, b in ipairs(bumps) do + local change = b.level == "major" and "**major**" or b.level + lines[#lines + 1] = ("| %s | %s | %s | %s |"):format(b.name, b.from, b.to, change) + end +else + lines[#lines + 1] = "No new plugin releases." +end +if #held > 0 then + lines[#lines + 1] = "" + lines[#lines + 1] = "Held at their locked commit (no newer release to move to): " + .. table.concat(held, ", ") + .. "." +end + +local report = os.getenv("LAZY_UPDATE_REPORT") +if report then + vim.fn.writefile(lines, report) +else + print(table.concat(lines, "\n")) +end +vim.cmd("qall!") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23f16e..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,29 +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') }} - - name: Restore plugins - run: nvim --headless "+Lazy! restore" +qa + 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 new file mode 100644 index 0000000..8d9710f --- /dev/null +++ b/.github/workflows/lazy-update.yml @@ -0,0 +1,73 @@ +name: lazy.nvim updates +on: + schedule: + # Mondays, an hour after Dependabot's flake.lock run. + - cron: "0 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + # Moves lazy-lock.json to new plugin *releases* only and opens (or + # refreshes) one PR with the result -- see .github/scripts/lazy-update.lua + # for which plugins move and which are held. Dependabot has no lazy.nvim + # ecosystem, hence a workflow. + update: + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + # gh workflow run below. Pushes and PRs made with GITHUB_TOKEN don't + # trigger other workflows; workflow_dispatch is the exception, so CI + # is started explicitly on the PR branch. + actions: write + steps: + - uses: actions/checkout@v7 + # 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 + run: | + if git diff --quiet lazy-lock.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + # Same check CI runs, against the updated plugins already on disk, so + # a release that breaks startup never gets as far as a PR. + - name: Run smoke test + if: steps.diff.outputs.changed == 'true' + run: nvim --headless -S .github/scripts/smoke-test.lua + - id: pr + if: steps.diff.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + branch: chore/lazy-update + delete-branch: true + add-paths: lazy-lock.json + commit-message: "chore(deps): bump lazy.nvim plugin releases" + title: "chore(deps): bump lazy.nvim plugin releases" + body-path: ${{ runner.temp }}/report.md + - name: Run CI on the PR branch + if: steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated' + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run ci.yml --ref chore/lazy-update diff --git a/lua/config/lazy.lua b/lua/config/lazy.lua index 13b0fbb..e3c77a9 100644 --- a/lua/config/lazy.lua +++ b/lua/config/lazy.lua @@ -79,6 +79,11 @@ lazy.setup({ -- import/override with your plugins { import = "plugins" }, }, + -- Plugins with semver tags follow releases, not their branch head, so + -- the lockfile only moves when upstream cuts a release. Specs that set + -- `branch` or `version = false`, and plugins with no tags, still track + -- commits; .github/scripts/lazy-update.lua leaves those alone. + defaults = { version = "*" }, checker = { enabled = true, -- check for plugin updates periodically notify = false, -- notify on update