From b558d22d56fe229af4ee33666933bf197510be2c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Nov 2026 02:21:52 +0000 Subject: [PATCH 1/3] test(ci): read only a bare or a fully quoted key in the workflow spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit field() accepted a quote on one side of a key. YAML reads cache_save": as the key cache_save", so mise-action ignores it and keeps its default of true, but the spec read it as cache_save and passed. A key must now be bare or inside a matching pair of quotes ("key": or 'key':). The spec comments now say that full-line comments are skipped, and that the action name in uses: must be followed by @ or /. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: --- tests/unit/workflow_cache_spec.lua | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/unit/workflow_cache_spec.lua b/tests/unit/workflow_cache_spec.lua index dc661ec5..c1c5f62e 100644 --- a/tests/unit/workflow_cache_spec.lua +++ b/tests/unit/workflow_cache_spec.lua @@ -3,7 +3,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. 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' }}" @@ -59,7 +60,7 @@ 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) @@ -67,7 +68,17 @@ describe(".github/workflows/test.yml cache settings", function() 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 + 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 if name and indent == 8 then current = name end @@ -123,10 +134,10 @@ describe(".github/workflows/test.yml cache settings", function() 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 From 36feb3b779c5a4403627fa5efffdb6b64d148ce0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Nov 2026 03:33:52 +0000 Subject: [PATCH 2/3] test(ci): let any line at the step-key indent end the current block in the workflow spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit field() set the current block only on a line that one of its key patterns read. A second jdx/mise-action step with "with:" and then "env :" (or "\"env\" :") put "cache: false" under env in YAML, so mise-action kept its defaults and saved its cache on a pull request. No pattern read the env line, so the spec still thought it was inside with: and counted that cache as the input. Any non-blank line at the step-key indent now sets the current block, read or not. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: --- tests/unit/workflow_cache_spec.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/workflow_cache_spec.lua b/tests/unit/workflow_cache_spec.lua index c1c5f62e..b60460c7 100644 --- a/tests/unit/workflow_cache_spec.lua +++ b/tests/unit/workflow_cache_spec.lua @@ -79,7 +79,8 @@ describe(".github/workflows/test.yml cache settings", function() break end end - if name and indent == 8 then + -- Any line at the step-key indent opens a new block, even when no pattern reads its key (`env :`). + 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) From ae7a9a231de3d56f61cb8b42e00a9c7b230caa1b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Nov 2026 04:48:25 +0000 Subject: [PATCH 3/3] test(ci): test how the workflow spec reads keys and blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No test checked the reader of the workflow spec, so reverting the block-end line or dropping its non-blank check kept mise run test green. parse_workflow now takes an iterator, so the new cases feed it small workflow texts as inline strings: - an env : or "env" : line after with: ends the block - a line of spaces does not end with: - cache': false and cache_save": are not read as keys - "key": and 'key': are read, and so is a bare key The comment above the block-end line now says a non-blank line opens a new block. _Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: --- tests/unit/workflow_cache_spec.lua | 89 +++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/tests/unit/workflow_cache_spec.lua b/tests/unit/workflow_cache_spec.lua index b60460c7..06fe3565 100644 --- a/tests/unit/workflow_cache_spec.lua +++ b/tests/unit/workflow_cache_spec.lua @@ -12,19 +12,15 @@ 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. + ---@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 @@ -38,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 @@ -79,7 +87,8 @@ describe(".github/workflows/test.yml cache settings", function() break end end - -- Any line at the step-key indent opens a new block, even when no pattern reads its key (`env :`). + -- 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 @@ -133,6 +142,68 @@ 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:` is the name in lower case followed by `@` or `/`. The two counts must be equal. So it