| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| /** |
| * One reader for what a workflow's triggers filter on. Two lanes |
| * derive a path filter from an import closure and compare it against this, and |
| * `ci-workflow-policy.test.mjs` runs before `npm ci` installs a YAML parser, so |
| * the install-free spelling is the only one that can be shared — which makes it |
| * the one both use, rather than a second parser that agrees until it does not. |
| * |
| * Node builtins only, for that same reason. |
| * |
| * Given up by hand-scanning rather than parsing: flow sequences |
| * (`paths: [a, b]`), block scalars, anchors and the rest of legal YAML. The |
| * workflows are written one entry per line, and several assertions over these |
| * lists already depend on that. |
| */ |
| |
| import { readFileSync } from 'node:fs'; |
| import { dirname, join } from 'node:path'; |
| import { fileURLToPath } from 'node:url'; |
| |
| const defaultRepoRoot = dirname(dirname(fileURLToPath(import.meta.url))); |
| |
| export function readWorkflow(workflowName, repoRoot = defaultRepoRoot) { |
| return readFileSync(join(repoRoot, '.github', 'workflows', workflowName), 'utf8'); |
| } |
| |
| /** |
| * The `on:` block only, with comment lines stripped, so a workflow cannot |
| * escape a trigger contract by writing `on: [pull_request]`, prose elsewhere in |
| * the file cannot fake one, and a comment between a trigger and its list cannot |
| * end a scan. Stripped comments survive as blank lines. |
| */ |
| export function workflowTriggerBlock(source) { |
| const withoutComments = source.replaceAll(/^[ \t]*#.*$/gmu, ''); |
| |
| return withoutComments.match(/^on:(.*(?:\n(?![^\s#]).*)*)/mu)?.[1] ?? ''; |
| } |
| |
| /** |
| * The `paths` list belonging to one of a workflow's triggers, or `null` when it |
| * carries no such trigger at all — which is what lets a caller tell "no such |
| * trigger" apart from "this trigger runs on everything". Anchoring to the |
| * trigger, instead of matching entry text anywhere in the file, is what makes |
| * the filter assertions fail when entries move under `paths-ignore`, under |
| * another trigger, or out of `on:` altogether. |
| */ |
| export function triggerPathFilter(source, trigger) { |
| const lines = workflowTriggerBlock(source).split('\n'); |
| const start = lines.findIndex((line) => new RegExp(`^ {2}${trigger}:\\s*$`, 'u').test(line)); |
| if (start < 0) return null; |
| |
| // Accepts the quoting and spacing YAML allows, so a legal rewrite reports the |
| // entries it really has instead of an empty list that reads as no filter. |
| const paths = []; |
| let inPaths = false; |
| for (const line of lines.slice(start + 1)) { |
| if (line.trim() === '') continue; |
| if (/^ {0,2}\S/u.test(line)) break; |
| if (/^ {4}\S/u.test(line)) { |
| inPaths = /^ {4}paths:\s*$/u.test(line); |
| continue; |
| } |
| const entry = inPaths ? /^\s+-\s+['"]?(.+?)['"]?\s*$/u.exec(line) : null; |
| if (entry) paths.push(entry[1]); |
| } |
| return paths; |
| } |
| |
| export function readTriggerPathFilter(workflowName, trigger, repoRoot = defaultRepoRoot) { |
| return triggerPathFilter(readWorkflow(workflowName, repoRoot), trigger); |
| } |
| |
| /** |
| * The `pull_request` filter, with "no trigger" and "no filter" both reported as |
| * the empty list: every caller of this one asks what a pull request is filtered |
| * on, and neither answer is a filter. |
| */ |
| export function readPullRequestPathFilter(workflowName, repoRoot = defaultRepoRoot) { |
| return readTriggerPathFilter(workflowName, 'pull_request', repoRoot) ?? []; |
| } |