Github Actions LSP in Neovim
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.
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.
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,
},
},
},
}
Let's break the two functions we have:
fetch_github_repo
– this function is used to fetch information about the GitHub repository usingcurl
. 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 functionfetch_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 thegh-actions-language-server
in stdio mode.filetypes
– Only activates foryaml.github
filessingle_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.
No spam, no sharing to third party. Only you and me.
Member discussion