Skip to content
Merged
Show file tree
Hide file tree
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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}

Expand Down
95 changes: 66 additions & 29 deletions src/detect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,68 @@ function labelColumn(start, raw) {
return start + lead + (/^['"]/.test(raw.trim()) ? 1 : 0) + 1;
}

/**
* 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 };
}

/**
* 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
* 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
Expand Down Expand Up @@ -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 };
}
Expand Down
2 changes: 1 addition & 1 deletion src/http.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
79 changes: 79 additions & 0 deletions test/detect.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:',
Expand Down Expand Up @@ -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`)],
Expand Down
Loading