Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 99 additions & 16 deletions tests/unit/workflow_cache_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,24 @@ require("tests.busted_setup")
-- actionlint and zizmor check the syntax of .github/workflows/test.yml, not what its cache settings mean, so an edit
-- that loosens one of them keeps CI green. This spec reads the file as text, with no YAML parser, and checks each
-- setting where it sits in its step. It also fails when `actions/cache` or `jdx/mise-action` is named on a line that is
-- not the `uses:` of a block-style step. It does not decode YAML escapes.
-- not the `uses:` of a block-style step. It skips full-line comments, so a comment can name an action (the one above
-- the rock cache steps does). It does not decode YAML escapes.
describe(".github/workflows/test.yml cache settings", function()
local root = require("pl.path").abspath(debug.getinfo(1, "S").source:sub(2)):match("^(.*)/tests/unit/[^/]+$")
local push_only = "${{ github.event_name == 'push' }}"
local steps, lines

---Every step is a `- ` item at six spaces. A step ends at the next step or at a line with less indent, and a comment
---line is not part of it.
---@param next_line fun(): string|nil an iterator over the lines of a workflow
---@return string[][] steps the lines of each step
---@return string[] lines every line of the file that is not a comment line. A trailing comment stays: a ` #` can sit
---@return string[] lines every line of the text that is not a comment line. A trailing comment stays: a ` #` can sit
---inside a quoted value, so cutting there could hide a name in a flow-style step.
local function read_workflow()
local path = root .. "/.github/workflows/test.yml"
local file = assert(
io.open(path, "r"),
"cannot read " .. path .. " (a `git archive` tree has no .github folder: .gitattributes export-ignores it)"
)
local function parse_workflow(next_line)
local found = {}
local code = {}
local current
for line in file:lines() do
for line in next_line do
if not line:match("^%s*#") then
table.insert(code, line)
end
Expand All @@ -37,6 +34,18 @@ describe(".github/workflows/test.yml cache settings", function()
table.insert(current, line)
end
end
return found, code
end

---@return string[][] steps
---@return string[] lines
local function read_workflow()
local path = root .. "/.github/workflows/test.yml"
local file = assert(
io.open(path, "r"),
"cannot read " .. path .. " (a `git archive` tree has no .github folder: .gitattributes export-ignores it)"
)
local found, code = parse_workflow(file:lines())
file:close()
assert(#found > 0, "no steps found in " .. path)
return found, code
Expand All @@ -59,16 +68,28 @@ describe(".github/workflows/test.yml cache settings", function()
---Finds a setting by its place in the step: a key of the step itself sits at eight spaces (the `- name:` line
---counts), and an entry of its `with:` or `env:` block sits at ten.
---@param step string[]
---@param key string the key, which the file may write with a quote around it
---@param key string the key, which the file may write bare or in a matching pair of quotes
---@param block string|nil `"with"` or `"env"`, the step key whose block holds `key`. nil for a key of the step
---@return string|nil value the text after `key:`, without a trailing YAML comment and without quotes around it
local function field(step, key, block)
local current
for _, text in ipairs(step) do
local line = text:gsub("^ %- ", " ", 1)
local indent = #line:match("^ *")
local name, value = line:match("^ *[\"']?([%w_%-]+)[\"']?:%s*(.-)%s*$")
if name and indent == 8 then
local name, value
for _, pattern in ipairs({
'^ *"([%w_%-]+)":%s*(.-)%s*$',
"^ *'([%w_%-]+)':%s*(.-)%s*$",
"^ *([%w_%-]+):%s*(.-)%s*$",
}) do
name, value = line:match(pattern)
if name then
break
end
end
-- Any non-blank line at the step-key indent opens a new block, even when no pattern reads its key (`env :`). A
-- line of spaces only does not.
if indent == 8 and line:match("%S") then
current = name
end
local in_place = (indent == 8 and block == nil) or (indent == 10 and block ~= nil and current == block)
Expand Down Expand Up @@ -121,12 +142,74 @@ describe(".github/workflows/test.yml cache settings", function()
steps, lines = read_workflow()
end)

describe("the reader", function()
---@param text string
---@return fun(): string|nil next_line
local function split_lines(text)
local rows = {}
for row in (text .. "\n"):gmatch("(.-)\n") do
table.insert(rows, row)
end
local index = 0
return function()
index = index + 1
return rows[index]
end
end

---@param ... string the lines of the keys that follow `with:` in a `jdx/mise-action` step
---@return string[] step
local function mise_step(...)
local text = table.concat({
" - name: Set up more mise tools",
" uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151",
" with:",
" install_args: --locked",
table.concat({ ... }, "\n"),
}, "\n")
return (parse_workflow(split_lines(text)))[1]
end

it("ends the with: block at a step key that no key pattern reads", function()
for _, env in ipairs({ "env :", '"env" :' }) do
local step = mise_step(" " .. env, " cache: false")
assert.are.equal("--locked", field(step, "install_args", "with"))
assert.is_nil(field(step, "cache", "with"), "cache: false sits under " .. env .. ", not under with:")
end
end)

it("keeps the with: block open over a line of spaces", function()
local step = mise_step(" ", " cache: false")
assert.are.equal("false", field(step, "cache", "with"))
end)

it("does not read a key with a quote on one side only", function()
local step = mise_step(
" cache_save\": ${{ github.event_name == 'push' }}",
" cache': false",
' "cache_key: a',
" cache_key': b"
)
assert.is_nil(field(step, "cache_save", "with"))
assert.is_nil(field(step, "cache", "with"))
assert.is_nil(field(step, "cache_key", "with"))
end)

it("reads a bare key and a key in a matching pair of quotes", function()
local step = mise_step(" cache_key: bare", ' "cache_save": double', " 'cache': single")
assert.are.equal("bare", field(step, "cache_key", "with"))
assert.are.equal("double", field(step, "cache_save", "with"))
assert.are.equal("single", field(step, "cache", "with"))
assert.are.equal("jdx/mise-action@1648a7812b9aeae629881980618f079932869151", field(step, "uses"))
end)
end)

describe("the workflow text", function()
-- field() reads block style only. This test counts the lines that contain an action name, in any letter case, and
-- the steps whose `uses:` starts with the name in lower case. The two counts must be equal. So it fails for a
-- flow-style step (`- { uses: actions/cache@..., with: { ... } }`), for another letter case (`Actions/cache@`),
-- and for the name on any other line that is not a comment line (a step `name:`, a `run:` line, a trailing
-- comment). It does not decode YAML escapes, so `"actions\x2fcache@..."` is not seen.
-- the steps whose `uses:` is the name in lower case followed by `@` or `/`. The two counts must be equal. So it
-- fails for a flow-style step (`- { uses: actions/cache@..., with: { ... } }`), for another letter case
-- (`Actions/cache@`), and for the name on any other line that is not a comment line (a step `name:`, a `run:`
-- line, a trailing comment). It does not decode YAML escapes, so `"actions\x2fcache@..."` is not seen.
it("names actions/cache and jdx/mise-action only in steps it can read", function()
for _, action in ipairs({ "actions/cache", "jdx/mise-action" }) do
local named, read = 0, 0
Expand Down
Loading