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
70 changes: 70 additions & 0 deletions build/tasks/verify-workflows.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @file Tests for which `uses:` the workflow pin check reads.
* @author The OpenINF Authors & Friends
* @license MIT OR Apache-2.0 OR BlueOak-1.0.0
* @module {type ES6Module} build/tasks/verify-workflows.test
*
* Beside `verify/` rather than in it: `verify.all` runs every file in that
* directory as a task.
*/

import { deepStrictEqual } from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join as pathJoin } from 'node:path';
import { describe, test } from 'node:test';

const TASK = pathJoin(import.meta.dirname, 'verify', 'verify-workflows.mts');

/**
* Runs the check over one workflow, in a directory holding nothing else.
* @param {string} workflow The workflow as written.
* @returns {Promise<number | null>} The exit code.
*/
async function check(workflow: string) {
const root = await mkdtemp(pathJoin(tmpdir(), 'openinf-workflows-'));

await mkdir(pathJoin(root, '.github', 'workflows'), { recursive: true });
await writeFile(pathJoin(root, '.github', 'workflows', 'ci.yml'), workflow);

return spawnSync(process.execPath, [TASK], { cwd: root }).status;
}

describe('verify-workflows', () => {
test('reads the steps of a job named `with`', async () => {
deepStrictEqual(
await check(
'on: push\njobs:\n with:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n'
),
1
);
});

test('reads the steps of a job named `env`', async () => {
deepStrictEqual(
await check(
'on: push\njobs:\n env:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n'
),
1
);
});

test('passes a step input that happens to be called `uses`', async () => {
deepStrictEqual(
await check(
'on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: ./local\n with:\n uses: anything@v1\n env:\n uses: anything@v1\n'
),
0
);
});

test('passes an input called `uses` to a reusable workflow', async () => {
deepStrictEqual(
await check(
'on: push\njobs:\n call:\n uses: ./.github/workflows/other.yml\n with:\n uses: anything@v1\n'
),
0
);
});
});
19 changes: 15 additions & 4 deletions build/tasks/verify/verify-workflows.mts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ type Job = {
/** One `uses:` value, and where the file says it. */
type Use = { action: string; line: number; trailing: string };

/**
* Whether a node is a pair under the given key.
* @param {unknown} node Anything the walk passed through.
* @param {string} name The key to look for.
* @returns {boolean} Whether `node` is `name: ...`.
*/
const isKeyed = (node: unknown, name: string) =>
isPair(node) && isScalar(node.key) && node.key.value === name;

/**
* Collects everything a workflow runs from somebody else's repository, and
* where each is written.
Expand Down Expand Up @@ -96,12 +105,14 @@ function usesIn(text: string) {
// is a scalar only where the anchor is defined, which is nowhere near
// `jobs.<id>.steps`; narrowing this to the two paths GitHub executes from
// would stop reading the one place such a reference is written down.
//
// A key directly under `jobs:` is a job's id, which its author chose, so
// a job called `with` or `env` is still a job and its steps still run.
if (
path.some(
(ancestor) =>
isPair(ancestor) &&
isScalar(ancestor.key) &&
(ancestor.key.value === 'with' || ancestor.key.value === 'env')
(ancestor, index) =>
(isKeyed(ancestor, 'with') || isKeyed(ancestor, 'env')) &&
!isKeyed(path[index - 2], 'jobs')
)
) {
return;
Expand Down
Loading