From 8b1e0549e02c7f4718df1752e198d59b241ca0be Mon Sep 17 00:00:00 2001 From: Booyaka101 Date: Sun, 20 Sep 2026 21:34:57 +0800 Subject: [PATCH 1/2] 1.4.1: read runs-on: in its mapping form A job on a runner group writes its labels under a mapping, and the scanner took whatever followed runs-on: as the label text, so the whole job fell out without a word. A repo pinning a retiring image behind a group was told it had nothing to migrate. Gap since 1.0.0, not a 1.4.0 regression. labels: takes the same three shapes runs-on: does, so the read loop is now one function that recurses for the mapping instead of a fourth copy of the scalar, flow-sequence and block-list branches. group: names a pool and is never read as a label. Annotations still point at the label where it sits, and the labels under one mapping stay one target, which is what --label and the migration lane need: GitHub only schedules onto a runner carrying every label in the set. Closes #12 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 28 +++++++++++++ README.md | 13 ++++--- action.yml | 2 +- package.json | 2 +- src/cli.mjs | 2 +- src/detect.mjs | 93 +++++++++++++++++++++++++++++++------------- src/http.mjs | 2 +- test/detect.test.mjs | 79 +++++++++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a363e..c531792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] — 2026-09-20 + +### Fixed + +- **`runs-on:` in its mapping form is now scanned** ([#12](https://github.com/Booyaka101/runner-drift/issues/12)). + A job that targets a runner group writes its labels under a mapping: + + ```yaml + runs-on: + group: default + labels: [ubuntu-22.04] + ``` + + The scanner took whatever followed `runs-on:` as the label text, so this form + yielded nothing and the job was skipped in silence. A repo on a runner group + pinning a retiring image was told it had nothing to migrate, and the same job + never showed up in the `ubuntu-latest` migration lane either. It had been that + way since 1.0.0. + + `labels:` takes the same three shapes `runs-on:` does (scalar, flow sequence, + block list), so the read loop was extracted into one function that calls back + into itself for the mapping rather than growing a fourth copy of those branches. + `group:` names a pool, not a label, and is never read as one. The annotation + points at the label where it sits, the same as every other form, and the labels + under one mapping stay one target, so `--label` matching and the migration lane + treat them as the set a runner has to carry. + ## [1.4.0] — 2026-09-20 ### Added @@ -377,6 +404,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm the lock, since that is the list it is about to compare. Both lanes now read `--tools`, then the lock, then the scan. +[1.4.1]: https://github.com/Booyaka101/runner-drift/releases/tag/v1.4.1 [1.4.0]: https://github.com/Booyaka101/runner-drift/releases/tag/v1.4.0 ## [1.3.0] — 2026-09-13 diff --git a/README.md b/README.md index 76665fd..5d999aa 100644 --- a/README.md +++ b/README.md @@ -880,12 +880,13 @@ date for one. Meanwhile a version below the `2.329.0` registration floor is called out on its own line. - **Detection is a targeted line scan**, not a full YAML parse (the package has zero - dependencies). It handles inline, flow-sequence and block-sequence `runs-on:`, and - resolves `runs-on: ${{ matrix.os }}` by harvesting label-shaped values from the same - file, skipping comments, block scalars and the keys that hold prose (`run`, - `name`, `if`). If it misses something, `--tools` and `--label` override it - completely. Which tools get diffed is `--tools` first, then the ones the lock - file already records, then the scan. + dependencies). It handles inline, flow-sequence and block-sequence `runs-on:`, plus + the `group:`/`labels:` mapping form (a group names a pool, so only the labels under + it are read), and resolves `runs-on: ${{ matrix.os }}` by harvesting label-shaped + values from the same file, skipping comments, block scalars and the keys that hold + prose (`run`, `name`, `if`). If it misses something, `--tools` and `--label` + override it completely. Which tools get diffed is `--tools` first, then the ones + the lock file already records, then the scan. - **Resolving `uses:` needs the network, and says so when it cannot.** Each unique remote reference is one `raw.githubusercontent.com` read of that exact ref's `action.yml`, plus, for the failing ones only, one `api.github.com` release diff --git a/action.yml b/action.yml index e13f621..b08b612 100644 --- a/action.yml +++ b/action.yml @@ -43,7 +43,7 @@ inputs: version: description: 'npm version of runner-drift to run.' required: false - default: '1.4.0' + default: '1.4.1' package: description: 'Override the npm spec, e.g. a local .tgz built in the same job. Mainly for testing this action before the version it requests exists on npm.' required: false diff --git a/package.json b/package.json index 1cfdd84..c28b07f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "runner-drift", - "version": "1.4.0", + "version": "1.4.1", "description": "Detect and attribute GitHub Actions runner-image tool drift: lock the tool versions your CI actually uses, diff them on every image bump, and plan a runner label migration before the deprecation deadline.", "type": "module", "bin": { diff --git a/src/cli.mjs b/src/cli.mjs index f883220..e668a90 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -76,7 +76,7 @@ async function version() { const pkg = JSON.parse(await readFile(path.join(HERE, '..', 'package.json'), 'utf8')); return pkg.version; } catch { - return '1.4.0'; + return '1.4.1'; } } diff --git a/src/detect.mjs b/src/detect.mjs index 2fa5b53..59d421d 100644 --- a/src/detect.mjs +++ b/src/detect.mjs @@ -153,6 +153,62 @@ function labelColumn(start, raw) { * fail, so nothing to backtrack. For a line with no terminator in it — which is * every line, since the caller split on newlines — both captures are unchanged. */ +/** + * One `runs-on:`-shaped value, from the line its key is on: a scalar, a flow + * sequence, or a block list below. Returns the last line index it consumed. + * + * `runs-on:` also takes a mapping of `group:` and `labels:`, and the labels + * under it are written in those same three shapes, so that branch calls back in + * here rather than repeating them. A group names a pool, not a label, and is + * the one key whose value must never be read as one. + */ +function readTarget(lines, i, baseIndent, rawValue, push) { + const raw = stripComment(rawValue); + const value = raw.trim(); + const valueStart = lines[i].length - rawValue.length; + let expression = false; + + if (value.includes('${{')) return { end: i, expression: true }; + + if (value.startsWith('[')) { + let offset = valueStart + lines[i].slice(valueStart).indexOf('[') + 1; + for (const part of value.replace(/^\[|\]$/g, '').split(',')) { + if (part.includes('${{')) expression = true; + else push(part, i + 1, labelColumn(offset, part)); + offset += part.length + 1; + } + return { end: i, expression }; + } + + if (value) { + push(value, i + 1, labelColumn(valueStart, raw)); + return { end: i, expression }; + } + + let end = i; + for (let j = i + 1; j < lines.length; j++) { + const l = lines[j]; + if (l.trim() === '') continue; + if (indentOf(l) <= baseIndent) break; + const mapping = l.match(/^([ \t]*)(group|labels):[ \t]*([^\r\n]*)/); + if (mapping) { + if (mapping[2] === 'labels') { + const inner = readTarget(lines, j, mapping[1].length, mapping[3], push); + expression = expression || inner.expression; + j = inner.end; + } + end = j; + continue; + } + const dash = l.match(/^([ \t]*-[ \t]*)([^\r\n]*)/); + const item = stripComment(dash ? dash[2] : l.trim()).trim(); + if (item.includes('${{')) expression = true; + else push(item, j + 1, labelColumn(dash ? dash[1].length : indentOf(l), item)); + end = j; + } + return { end, expression }; +} + function scanRunsOn(lines) { const found = []; // `found` is flat, one entry per label, because the retirement lane annotates @@ -172,39 +228,20 @@ function scanRunsOn(lines) { for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^([ \t]*)runs-on:[ \t]*([^\r\n]*)/); if (!m) continue; - const baseIndent = m[1].length; - const raw = stripComment(m[2]); - const value = raw.trim(); - const valueStart = lines[i].length - m[2].length; // Anchored on the `runs-on:` line itself: the set is the target, so pointing // at one item of a block list would be arbitrary. - target = { labels: [], expression: false, line: i + 1, col: valueStart + 1 }; + target = { + labels: [], + expression: false, + line: i + 1, + col: lines[i].length - m[2].length + 1, + }; targets.push(target); - if (!value) { - for (let j = i + 1; j < lines.length; j++) { - const l = lines[j]; - if (l.trim() === '') continue; - if (indentOf(l) <= baseIndent) break; - const dash = l.match(/^([ \t]*-[ \t]*)([^\r\n]*)/); - const item = stripComment(dash ? dash[2] : l.trim()).trim(); - if (item.includes('${{')) expression = target.expression = true; - else push(item, j + 1, labelColumn(dash ? dash[1].length : indentOf(l), item)); - i = j; - } - } else if (value.startsWith('[')) { - let offset = valueStart + lines[i].slice(valueStart).indexOf('[') + 1; - for (const part of value.replace(/^\[|\]$/g, '').split(',')) { - if (part.includes('${{')) expression = target.expression = true; - else push(part, i + 1, labelColumn(offset, part)); - offset += part.length + 1; - } - } else if (value.includes('${{')) { - expression = target.expression = true; - } else { - push(value, i + 1, labelColumn(valueStart, raw)); - } + const read = readTarget(lines, i, m[1].length, m[2], push); + if (read.expression) expression = target.expression = true; + i = read.end; } return { found, targets, expression }; } diff --git a/src/http.mjs b/src/http.mjs index 816d7df..ac2fb6d 100644 --- a/src/http.mjs +++ b/src/http.mjs @@ -49,7 +49,7 @@ function authHeaders() { const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.INPUT_GITHUB_TOKEN || ''; const headers = { - 'user-agent': 'runner-drift/1.4.0 (+https://github.com/Booyaka101/runner-drift)', + 'user-agent': 'runner-drift/1.4.1 (+https://github.com/Booyaka101/runner-drift)', accept: 'application/vnd.github+json', }; if (token) headers.authorization = `Bearer ${token}`; diff --git a/test/detect.test.mjs b/test/detect.test.mjs index bf38b03..b2447e5 100644 --- a/test/detect.test.mjs +++ b/test/detect.test.mjs @@ -173,6 +173,84 @@ test('labelSites: block-sequence items carry their own line and column', () => { assert.deepEqual(extractLabelSites(y), [{ label: 'macos-14', file: null, line: 5, col: 9, job: 'a' }]); }); +test('labelSites: a runs-on mapping reads its labels and not its group', () => { + const y = 'jobs:\n a:\n runs-on:\n group: default\n labels: [ubuntu-22.04]\n'; + assert.deepEqual(extractLabelSites(y), [ + { label: 'ubuntu-22.04', file: null, line: 5, col: 16, job: 'a' }, + ]); +}); + +test('labelSites: a mapping whose labels are a block list', () => { + const y = [ + 'jobs:', + ' a:', + ' runs-on:', + ' group: big', + ' labels:', + ' - self-hosted', + ' - macos-14', + ' steps: []', + ].join('\n'); + assert.deepEqual(extractLabelSites(y), [ + { label: 'macos-14', file: null, line: 7, col: 11, job: 'a' }, + ]); +}); + +test('labelSites: a mapping whose labels are a single scalar', () => { + const y = 'jobs:\n a:\n runs-on:\n labels: ubuntu-22.04\n'; + assert.deepEqual(extractLabelSites(y), [ + { label: 'ubuntu-22.04', file: null, line: 4, col: 15, job: 'a' }, + ]); +}); + +test('a group on its own is a pool, not a label', () => { + const y = 'jobs:\n a:\n runs-on:\n group: default\n steps: []\n'; + assert.deepEqual(extractLabels(y), []); + assert.deepEqual(extractLabelSites(y), []); + assert.deepEqual(extractRunsOnTargets(y)[0].labels, []); +}); + +test('a mapping is one target, so its labels are a set the runner must carry', () => { + const y = [ + 'jobs:', + ' a:', + ' runs-on:', + ' group: big', + ' labels: [self-hosted, macos-14]', + ' b:', + ' runs-on: ubuntu-22.04', + ].join('\n'); + const targets = extractRunsOnTargets(y); + assert.equal(targets.length, 2); + assert.deepEqual(targets[0].labels, [SELF_HOSTED, 'macos-14']); + assert.deepEqual(targets[1].labels, ['ubuntu-22.04']); +}); + +test('a floating label under a mapping is still floating', () => { + const y = 'jobs:\n a:\n runs-on:\n group: default\n labels: [ubuntu-latest]\n'; + assert.deepEqual(extractFloatingSites(y), [ + { label: 'ubuntu-latest', file: null, line: 5, col: 16, job: 'a' }, + ]); +}); + +test('an expression inside a mapping marks the target, same as a bare one', () => { + const y = [ + 'jobs:', + ' a:', + ' runs-on:', + ' group: default', + ' labels: [${{ matrix.os }}]', + ' strategy:', + ' matrix:', + ' os: [ubuntu-22.04]', + ].join('\n'); + assert.ok(extractRunsOnTargets(y)[0].expression, 'the mapping resolves through the matrix'); + assert.deepEqual( + extractLabelSites(y).map((s) => s.label), + ['ubuntu-22.04'], + ); +}); + test('labelSites: ${{ matrix.os }} resolves to the matrix value positions', () => { const y = [ 'jobs:', @@ -602,6 +680,7 @@ test('the line scanners stay linear on pathological input', () => { ['run: with a trailing CR', () => extractRunScripts(` run:${pad}${CR}x`)], ['runs-on: with a trailing CR', () => extractRunsOnTargets(`runs-on:${pad}${CR}x`)], ['a block-list dash with a CR', () => extractRunsOnTargets(`runs-on:\n${pad}-${pad}${CR}`)], + ['a labels: key with a CR', () => extractRunsOnTargets(`runs-on:\n labels:${pad}${CR}`)], ['extractLabels', () => extractLabels(`runs-on:${pad}${CR}x`)], ['extractLabelSites', () => extractLabelSites(`runs-on:${pad}${CR}x`)], ['commandsInScript after sudo', () => commandsInScript(`sudo${pad}x`)], From 29dd9df4c3b0b5b90329e0ccfdf2b74f0de62ed7 Mon Sep 17 00:00:00 2001 From: Booyaka101 Date: Sun, 20 Sep 2026 21:35:42 +0800 Subject: [PATCH 2/2] Put the scanRunsOn docblock back on scanRunsOn The extraction left it stacked above readTarget's own. Co-Authored-By: Claude Opus 5 --- src/detect.mjs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/detect.mjs b/src/detect.mjs index 59d421d..2af787f 100644 --- a/src/detect.mjs +++ b/src/detect.mjs @@ -139,20 +139,6 @@ function labelColumn(start, raw) { return start + lead + (/^['"]/.test(raw.trim()) ? 1 : 0) + 1; } -/** - * Every `runs-on:` value in a document, positioned. `expression` reports - * whether any value was a `${{ … }}` reference, which is what makes the - * matrix fallback below kick in. - * - * Two shapes here are load-bearing for linear time, and both were quadratic - * before 1.2.0 (CodeQL js/polynomial-redos). Indentation is `[ \t]`, not `\s`, - * and the value is `([^\r\n]*)` with no `$`. The pair matters: `\s*(.*)$` lets - * both quantifiers match a space, and `$` can fail because `.` excludes line - * terminators, so one stray carriage return on a long line makes the engine try - * every split of the whitespace between them. Without a `$` there is nothing to - * fail, so nothing to backtrack. For a line with no terminator in it — which is - * every line, since the caller split on newlines — both captures are unchanged. - */ /** * One `runs-on:`-shaped value, from the line its key is on: a scalar, a flow * sequence, or a block list below. Returns the last line index it consumed. @@ -209,6 +195,20 @@ function readTarget(lines, i, baseIndent, rawValue, push) { return { end, expression }; } +/** + * Every `runs-on:` value in a document, positioned. `expression` reports + * whether any value was a `${{ … }}` reference, which is what makes the + * matrix fallback below kick in. + * + * Two shapes are load-bearing for linear time, and both were quadratic + * before 1.2.0 (CodeQL js/polynomial-redos). Indentation is `[ \t]`, not `\s`, + * and the value is `([^\r\n]*)` with no `$`. The pair matters: `\s*(.*)$` lets + * both quantifiers match a space, and `$` can fail because `.` excludes line + * terminators, so one stray carriage return on a long line makes the engine try + * every split of the whitespace between them. Without a `$` there is nothing to + * fail, so nothing to backtrack. For a line with no terminator in it — which is + * every line, since the caller split on newlines — both captures are unchanged. + */ function scanRunsOn(lines) { const found = []; // `found` is flat, one entry per label, because the retirement lane annotates