Developer and Writer

Github Actions LSP in Neovim

In this letter, I'll walk you through on properly setting up the github actions LSP on neovim.
💡
All credits goes to neovimdev for fixing the bugs we had with the LSP.

I've been working with Github Actions for quite some time now and I cannot for the life of me figure out how to get proper autocomplete and diagnostics like in VSCode.

How hard could it be? There's already an LSP that exists right? Well, the official LSP is found here.

Easy, just install it and configure it like any other LSP?

But we have a problem, according to this issue there's no binary executable.

Alright no problem, I scroll down a bit and I see a guy already made wrapper around it.

Great, I just need to follow the docs which quite handily provides step by step instructions on how to make it work with Neovim. It's also integrated with Mason and nvim-lspconfig.

Should be simple right? Well, it doesn't really work properly. You had to provide a GitHub token to get it to fetch metadata about a reusable workflow or component. Even in those cases, it was a bit buggy. It couldn't even fine local reusable workflows.

So what now?

Thanks god, a few days later, I saw a post on the r/neovim subreddit about someone having the exact same issues as me and fixing it.

Using this video, I was able to configure the github actions language server properly, but it was still far from perfect. This is why I decided to write this and share it with you people.

💡
Hopefully in the future, the plugin would be fixed but as of 11th of September that's not the case. Make sure to check out the Github repo for any updates.

Configuration

Step 1: Installing the language server

You can either install the language server directly by:

npm install --global gh-actions-language-server

or you can use Mason. The point is that you should be able to run the gh-actions-language-server from your shell.

It will probably fail and that's fine but make sure it runs.

Step 2: Add yaml.github filetype detection

We don't want the LSP to activate for every YAML file so let's add a new filetype called yaml.github. Create a file at ftdetect/github-actions.lua with:

vim.filetype.add({
  pattern = {
    ['.*/%.github[%w/]+workflows[%w/]+.*%.ya?ml'] = 'yaml.github',
  },
})

So this will basically detect that any file within the .github/workflows/**.yaml is a yaml.github file type.

Step 3: Configure a Github PAT

According to the language server docs, you have to provide a session token with the repo and workflow scopes.

So to do that head over to your PAT settings and create that token.

Once done, if your on an enterprise plan, make sure to authorise that token with your org. It should be under the option, configure SSO:

Once you have the token, save it as an environment variable that is accessible from your shell.

In my case, I use mise so I can do the following in my dotfiles:

I prefer to call it GHCRIO but feel free to call it whatever you want as long as it can be accessed from shell.

💡
If your using zsh then check out this tutorial on handling environment variables. On the other hand, if you use fish then check out the docs.

Step 4: Configure Language Server

Next, we configure the language server itself, I usually copy whatever default configs are found in nvim-lspconfig or mason-lspconfig but for this one, we will have to add some custom stuff over the defaults.

Create a file called lsp/gh_actions.lua with:

local function fetch_github_repo(repo_name, token, org, workspace_path)
  local cmd = {
    "curl",
    "-H",
    "Authorization: Bearer " .. token,
    "-H",
    "User-Agent: Neovim",
    "https://api.github.com/repos/" .. org .. "/" .. repo_name,
  }

  local result = vim.system(cmd):wait()
  if not result or not result.stdout then
    print("No result from GitHub API")
    return {}
  end

  local raw_json = result.stdout
  if raw_json == "" then
    print("Empty JSON from GitHub API")
    return {}
  end

  local ok, data = pcall(vim.json.decode, raw_json)
  if not ok or type(data) ~= "table" then
    print("Failed to decode JSON from GitHub API")
    return {}
  end

  local repo_info = {
    id = data.id,
    owner = org,
    name = repo_name,
    workspaceUri = "file://" .. workspace_path,
    organizationOwned = true,
  }
  return repo_info
end

local get_gh_actions_init_options = function(org, workspace_path, session_token)
  org = org
  workspace_path = workspace_path or vim.fn.getcwd()
  session_token = session_token or os.getenv("GHCRIO")

  local function get_repo_name()
    local handle = io.popen("git remote get-url origin 2>/dev/null")
    if not handle then
      return nil
    end
    local result = handle:read("*a")
    handle:close()
    if not result or result == "" then
      return nil
    end
    -- Remove trailing newline
    result = result:gsub("%s+$", "")
    -- Extract repo name from URL
    local repo = result:match("([^/:]+)%.git$")
    return repo
  end
  local repo_name = get_repo_name()

  local repo_info = fetch_github_repo(repo_name, session_token, org, workspace_path)
  return {
    sessionToken = session_token,
    repos = {
      repo_info,
    },
  }
end


return {
  cmd = { 'gh-actions-language-server', '--stdio' },
  filetypes = { 'yaml.github' },
  init_options = get_gh_actions_init_options(),
  single_file_support = true,
  -- `root_dir` ensures that the LSP does not attach to all yaml files
  root_dir = function(bufnr, on_dir)
    local parent = vim.fs.dirname(vim.api.nvim_buf_get_name(bufnr))
    if
        vim.endswith(parent, '/.github/workflows')
    then
      on_dir(parent)
    end
  end,
  handlers = {
    ['actions/readFile'] = function(_, result)
      if type(result.path) ~= 'string' then
        return nil, { code = -32602, message = 'Invalid path parameter' }
      end
      local file_path = vim.uri_to_fname(result.path)
      if vim.fn.filereadable(file_path) == 1 then
        local f = assert(io.open(file_path, 'r'))
        local text = f:read('*a')
        f:close()
        return text, nil
      else
        return nil, { code = -32603, message = 'File not found: ' .. file_path }
      end
    end,
  },

  capabilities = {
    workspace = {
      didChangeWorkspaceFolders = {
        dynamicRegistration = true,
      },
    },
  },
}
💡
At this point, you can simply save and the language server should work. If you want a more in-depth explanation of what we just did, keep reading on.

Let's break the two functions we have:

  • fetch_github_repo – this function is used to fetch information about the GitHub repository using curl. It returns the data in the format that is expected by the language server.
  • get_gh_actions_init_options – this function is used to return the initial settings for the language server instance for the repo that your currently working on. For example, your working on repository "a", it will detect the name of the repository and then using the function fetch_github_repo it will get the info needed using the GitHub api.

I would like to add some more emphasis on the following lines:

  org = org
  workspace_path = workspace_path or vim.fn.getcwd()
  session_token = session_token or os.getenv("GHCRIO")

If your working in a repository owned by an org then fill the org parameter. We also automatically check the existence of the session_token by the environment variable GHCRIO. If that doesn't work for you, feel free to configure that however you want.

Finally for all you nerds out there, let me break down the actual language server configuration:

  cmd = { 'gh-actions-language-server', '--stdio' },
  filetypes = { 'yaml.github' },
  init_options = get_gh_actions_init_options(),
  single_file_support = true,
  -- `root_dir` ensures that the LSP does not attach to all yaml files
  root_dir = function(bufnr, on_dir)
    local parent = vim.fs.dirname(vim.api.nvim_buf_get_name(bufnr))
    if
        vim.endswith(parent, '/.github/workflows')
    then
      on_dir(parent)
    end
  end,
  • cmd – Starts the gh-actions-language-server in stdio mode.
  • filetypes – Only activates for yaml.github files
  • single_file_support – This allows the language server to work on individual GitHub Actions YAML files even when it can't find a proper root dir.
  • root_dir – This basically decides where the language server would base its root folder from. To learn more, read nvim lsp docs.
handlers = {
    ['actions/readFile'] = function(_, result)
      if type(result.path) ~= 'string' then
        return nil, { code = -32602, message = 'Invalid path parameter' }
      end
      local file_path = vim.uri_to_fname(result.path)
      if vim.fn.filereadable(file_path) == 1 then
        local f = assert(io.open(file_path, 'r'))
        local text = f:read('*a')
        f:close()
        return text, nil
      else
        return nil, { code = -32603, message = 'File not found: ' .. file_path }
      end
    end,
  },

Github actions language server requires us to implement a handler for reading files. That's what we essentially do here, we get the path an try to read it. If you don't add this then you will fail to find local reusable workflows.

To learn more, see this issue.

capabilities = {
    workspace = {
      didChangeWorkspaceFolders = {
        dynamicRegistration = true,
      },
    },
  }

Here we basically tell our language server what Neovim supports:

  • didChangeWorkspaceFolders – handling workspace changes dynamically.
  • dynamicRegistration – the server can register/unregister workspace folder change notifications at runtime.

This allows the LSP server to get notified when you add/remove workspace folders in your editor, which is useful for multi-root workspaces.

Closing thoughts

This was both fun to learn and write.

To be honest, I didn't really have much experience with the internals of complex language servers such as GH Actions but nevertheless it taught me a lot about how language servers are made and how neovim handles them.

Shout out to neovimdev once again for helping me on this and thank you for reading.

Subscribe to my occasionally chaotic newsletter.

No spam, no sharing to third party. Only you and me.

Member discussion