From b74cf4fad9fc36346f8211a38934c493d4c9c408 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 22:44:22 +0000 Subject: [PATCH 1/3] test(ci): make the workflow spec read quoted settings and fail on a step it cannot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec read a key only when it was written bare and a value only when it was written without quotes. A step written with a quoted key (`"uses": actions/cache@...`) was not seen, and a quoted value (`cache_key: "..."`) failed the spec although YAML reads it the same. field() now accepts a quote around the key and strips one pair of quotes from the value. Three more checks: - Every jdx/mise-action step must turn its cache off or save only on a push. A second step with the default settings would save the tool cache on a pull request. - The rock restore key must be luarocks-${{ runner.os }}-${{ hashFiles('mise.toml') }} as a whole, with an optional version after the prefix, as the mise key is. - The spec fails when actions/cache or jdx/mise-action is named on a line that is not the uses: of a step it reads. That covers a flow-style step (`- { uses: actions/cache@..., with: { ... } }`) and an upper-case spelling. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: --- tests/unit/workflow_cache_spec.lua | 112 ++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/tests/unit/workflow_cache_spec.lua b/tests/unit/workflow_cache_spec.lua index e6a88130..d5a8dd28 100644 --- a/tests/unit/workflow_cache_spec.lua +++ b/tests/unit/workflow_cache_spec.lua @@ -2,23 +2,29 @@ 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. +-- setting where it sits in its step. A step that it cannot read, such as one in flow style, makes it fail. 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 steps + 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. ---@return string[][] steps the lines of each step - local function read_steps() + ---@return string[] lines every line of the file that is not a comment, without its trailing comment + 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 = {} + local code = {} local current for line in file:lines() do + if not line:match("^%s*#") then + table.insert(code, (line:gsub("%s+#.*$", ""))) + end if line:match("^ %- ") then current = {} table.insert(found, current) @@ -31,39 +37,59 @@ describe(".github/workflows/test.yml cache settings", function() end file:close() assert(#found > 0, "no steps found in " .. path) - return found + return found, code + end + + ---@param value string + ---@return string unquoted the value without one pair of YAML quotes around it + local function unquote(value) + local inner = value:match('^"(.*)"$') + if inner then + return inner + end + inner = value:match("^'(.*)'$") + if inner then + return (inner:gsub("''", "'")) + end + return value end ---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 + ---@param key string the key, which the file may write with a quote around it ---@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 + ---@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*$") + local name, value = line:match("^ *[\"']?([%w_%-]+)[\"']?:%s*(.-)%s*$") if name and indent == 8 then current = name end local in_place = (indent == 8 and block == nil) or (indent == 10 and block ~= nil and current == block) if name == key and in_place then - return (value:gsub("%s+#.*$", "")) + return unquote((value:gsub("%s+#.*$", ""))) end end return nil end ---@param step string[] - ---@return boolean caches_rocks whether the step uses actions/cache (plain, restore or save, with or without a quote - ---before the name) and one of its lines contains the text `.luarocks`: as `.luarocks`, `./.luarocks`, `~/.luarocks`, - ---an absolute path, or a line of a multi-line `path`. A path without that text (a glob such as `.luaroc*`, or a - ---parent folder) is not seen. + ---@param action string an action name without a version, such as `actions/cache` + ---@return boolean uses_it whether the step's `uses:` names the action + local function uses_action(step, action) + return (field(step, "uses") or ""):find(action, 1, true) == 1 + end + + ---@param step string[] + ---@return boolean caches_rocks whether the step uses actions/cache (plain, restore or save) and one of its lines + ---contains the text `.luarocks`: as `.luarocks`, `./.luarocks`, `~/.luarocks`, an absolute path, or a line of a + ---multi-line `path`. A path without that text (a glob such as `.luaroc*`, or a parent folder) is not seen. local function caches_rock_tree(step) - if not (field(step, "uses") or ""):gsub("^[\"']", ""):match("^actions/cache[/@]") then + if not uses_action(step, "actions/cache") then return false end for _, line in ipairs(step) do @@ -88,7 +114,51 @@ describe(".github/workflows/test.yml cache settings", function() end before_each(function() - steps = read_steps() + steps, lines = read_workflow() + end) + + describe("the workflow text", function() + -- field() reads block style only. A step or a list of steps in flow style (`- { uses: actions/cache@..., with: { + -- ... } }`) hides its settings, so the spec fails when an action name sits anywhere but in the `uses:` of a step it + -- reads. A name in another spelling of the case also fails: the match here ignores the case, the one in + -- uses_action() does not. + 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 + for _, line in ipairs(lines) do + if line:lower():find(action, 1, true) then + named = named + 1 + end + end + for _, step in ipairs(steps) do + if uses_action(step, action) then + read = read + 1 + end + end + assert.are.equal( + named, + read, + action .. " is named on " .. named .. " lines, but only " .. read .. " steps have it as their uses:" + ) + end + end) + + it("keeps the cache of every jdx/mise-action step off a pull request", function() + -- The default of cache_save is true, so a step that sets neither would save the tool cache on a pull request. + local checked = 0 + for _, step in ipairs(steps) do + if uses_action(step, "jdx/mise-action") then + checked = checked + 1 + local cache_save = field(step, "cache_save", "with") + local safe = field(step, "cache", "with") == "false" or cache_save == "false" or cache_save == push_only + assert.is_true( + safe, + "this jdx/mise-action step saves its cache on a pull request: " .. tostring(field(step, "name")) + ) + end + end + assert.is_true(checked >= 1, "no jdx/mise-action step found") + end) end) describe("the Set up mise step", function() @@ -118,15 +188,14 @@ describe(".github/workflows/test.yml cache settings", function() end) it("saves the cache only on a push", function() - assert.are.equal("${{ github.event_name == 'push' }}", field(step, "cache_save", "with")) + assert.are.equal(push_only, field(step, "cache_save", "with")) end) it("retries a failed download at least 6 times", function() local retries = field(step, "MISE_HTTP_RETRIES", "env") assert.is_string(retries, "the step sets no MISE_HTTP_RETRIES under env:") - local digits = retries:match("^[\"']?(%d+)[\"']?$") - assert.is_truthy(digits, "MISE_HTTP_RETRIES must be an integer: " .. retries) - assert.is_true(tonumber(digits) >= 6, "MISE_HTTP_RETRIES is below 6: " .. retries) + assert.is_truthy(retries:match("^%d+$"), "MISE_HTTP_RETRIES must be an integer: " .. retries) + assert.is_true(tonumber(retries) >= 6, "MISE_HTTP_RETRIES is below 6: " .. retries) end) end) @@ -168,7 +237,12 @@ describe(".github/workflows/test.yml cache settings", function() it("restores the key that the rock list in mise.toml decides", function() local key = field(restore, "key", "with") assert.is_string(key, "the restore step has no key under with:") - assert.is_truthy(key:find("hashFiles('mise.toml')", 1, true), key) + -- A version after the prefix (`luarocks-v2-`) is allowed, as `v1` is in the mise key: bump it to drop a tree. + local bare = key:gsub("^luarocks%-v%d+%-", "luarocks-") + assert.is_truthy( + bare:match("^luarocks%-%${{ runner%.os }}%-%${{ hashFiles%('mise%.toml'%) }}$"), + "the restore key must be luarocks-${{ runner.os }}-${{ hashFiles('mise.toml') }}: " .. key + ) end) it("restores from the exact key only", function() From 550a603d6e62bb19eeaa3167fef7c5923a42de9d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Nov 2026 00:21:15 +0000 Subject: [PATCH 2/3] test(ci): count the raw lines that name an action and say what the workflow spec checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line count cut each line at its first " #" as a trailing comment. A " #" can sit inside a quoted value of a flow-style step, such as { name: "Cache #2", uses: actions/cache@... }, so the cut hid the name and the step passed. The count now keeps every line that is not a comment line as it is. A trailing comment that names an action on another line now fails the count too. The spec comments now say what the spec does: it fails when actions/cache or jdx/mise-action is named on a line that is not the uses: of a block-style step, and it does not decode YAML escapes. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: --- tests/unit/workflow_cache_spec.lua | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/unit/workflow_cache_spec.lua b/tests/unit/workflow_cache_spec.lua index d5a8dd28..ff7c07db 100644 --- a/tests/unit/workflow_cache_spec.lua +++ b/tests/unit/workflow_cache_spec.lua @@ -2,7 +2,8 @@ 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. A step that it cannot read, such as one in flow style, makes it fail. +-- 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. 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' }}" @@ -11,7 +12,8 @@ describe(".github/workflows/test.yml cache settings", function() ---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. ---@return string[][] steps the lines of each step - ---@return string[] lines every line of the file that is not a comment, without its trailing comment + ---@return string[] lines every line of the file 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( @@ -23,7 +25,7 @@ describe(".github/workflows/test.yml cache settings", function() local current for line in file:lines() do if not line:match("^%s*#") then - table.insert(code, (line:gsub("%s+#.*$", ""))) + table.insert(code, line) end if line:match("^ %- ") then current = {} @@ -118,10 +120,11 @@ describe(".github/workflows/test.yml cache settings", function() end) describe("the workflow text", function() - -- field() reads block style only. A step or a list of steps in flow style (`- { uses: actions/cache@..., with: { - -- ... } }`) hides its settings, so the spec fails when an action name sits anywhere but in the `uses:` of a step it - -- reads. A name in another spelling of the case also fails: the match here ignores the case, the one in - -- uses_action() does not. + -- 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. 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 From 313cb6fb4e6ea9f170fe6edf87452a3723482282 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Nov 2026 01:17:34 +0000 Subject: [PATCH 3/3] test(ci): require @ or / after an action name in the workflow spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uses_action matched any value that began with the action name, so a similarly prefixed repository (jdx/mise-action-fork@...) counted as jdx/mise-action. The line-count guard and the "at least one mise-action step" check then passed with no real jdx/mise-action step left. The value must now continue with @ or /. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: --- tests/unit/workflow_cache_spec.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/workflow_cache_spec.lua b/tests/unit/workflow_cache_spec.lua index ff7c07db..dc661ec5 100644 --- a/tests/unit/workflow_cache_spec.lua +++ b/tests/unit/workflow_cache_spec.lua @@ -81,9 +81,11 @@ describe(".github/workflows/test.yml cache settings", function() ---@param step string[] ---@param action string an action name without a version, such as `actions/cache` - ---@return boolean uses_it whether the step's `uses:` names the action + ---@return boolean uses_it whether the step's `uses:` starts with the name and `@` or `/` follows it local function uses_action(step, action) - return (field(step, "uses") or ""):find(action, 1, true) == 1 + local uses = field(step, "uses") or "" + local after = uses:sub(#action + 1, #action + 1) + return uses:sub(1, #action) == action and (after == "@" or after == "/") end ---@param step string[]