Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 24 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,30 @@ export class Utils {
return binary;
};

// A `$VAR` that sits *inside* a regex literal on the RHS of `=~`/`!~`

@firecow firecow Jul 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repo doesn't carry source comments

// must be substituted with its raw value, not a quoted JS string
// literal. The general expansion below wraps every value in quotes
// (needed for `==` operands), which would otherwise leak `"`
// characters into the regex pattern, e.g. turning
// $PHP_VERSION =~ /^\$_TARGET_PHP$/ (with _TARGET_PHP=8.3)
// into the malformed pattern `^\"8.3"$` instead of `^8.3$`.
// In a regex context GitLab still expands an escaped `\$VAR`, dropping
// the escaping backslash. We do this as a pre-pass, before the general
// expansion, so that no `$VAR` remains inside literal regexes; this
// leaves the "RHS is a variable that holds a regex" case (e.g.
// `$TAG =~ $TAG_REGEX`) to the general quoted expansion as before.
const regexLiteralRhs = /(?<op>=~|!~)(?<pre>\s*["']?)\/(?<pattern>(?:\\.|[^/\\])*)\//g;
evalStr = evalStr.replaceAll(regexLiteralRhs, (_match, op, pre, pattern) => {
const expandedPattern = pattern.replaceAll(

@firecow firecow Jul 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaping breaks regex fragments: /^($ALLOWED)$/ with a|b is false here, true on GitLab and inconsistent with $TAG =~ $TAG_REGEX, which stays unescaped.

/(\$\$)|\\?\$\{([a-zA-Z_]\w*)}|\\?\$([a-zA-Z_]\w*)/g,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
(_m: string, escape: string, var1: string, var2: string) => {
if (escape !== undefined) return "$";
return envs[var1 || var2] ?? "";
},
);
return `${op}${pre}/${expandedPattern}/`;

@firecow firecow Jul 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unset var → // → reaches eval as "anything" =~ // and aborts the run. Master returns false.

Suggested change
return `${op}${pre}/${expandedPattern}/`;
return `${op}${pre}/${expandedPattern || "(?:)"}/`;

Still false (matchRE2JS skips zero-length matches, global.ts:21).

});

// Expand all variables
evalStr = this.expandTextWith(evalStr, {
unescape: JSON.stringify("$"),
Expand Down
39 changes: 39 additions & 0 deletions tests/rules-regex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,45 @@ describe("gitlab rules regex", () => {
});
});

/* eslint-disable @stylistic/quotes */
// A `$VAR` referenced *inside* a regex literal on the RHS of `=~`/`!~` must be
// substituted with its raw value, not a quoted JS string literal.
const variableInRegexTests: {rule: string; envs: {[key: string]: string}; jsExpression: string; evalResult: boolean}[] = [
{
// Drupal gitlab_templates: include.drupalci.main.yml
rule: "$CORE_PHP_MIN == $CORE_PHP_MAX && ($PHP_VERSION =~ '/^\\$_TARGET_PHP$/' || $PHP_VERSION =~ '/^\\$CORE_PHP_MAX$/')",
envs: {CORE_PHP_MIN: "8.3", CORE_PHP_MAX: "8.3", _TARGET_PHP: "8.3", PHP_VERSION: "8.3"},
jsExpression: '"8.3" == "8.3" && ("8.3".matchRE2JS(RE2JS.compile("^8.3$", 0)) != null || "8.3".matchRE2JS(RE2JS.compile("^8.3$", 0)) != null)',
evalResult: true,
},
{
// bare $VAR inside a bare regex literal
rule: "$CI_COMMIT_BRANCH =~ /$BRANCHNAME/",
envs: {CI_COMMIT_BRANCH: "master", BRANCHNAME: "master"},
jsExpression: '"master".matchRE2JS(RE2JS.compile("master", 0)) != null',
evalResult: true,
},
{
// RHS is a variable that *holds* a regex literal — must still work
rule: "$TAG =~ $TAG_REGEX",
envs: {TAG: "prefix/1.0.0", TAG_REGEX: "/^prefix\\/.+/"},
jsExpression: '"prefix/1.0.0".matchRE2JS(RE2JS.compile("^prefix\\/.+", 0)) != null',
evalResult: true,
},
];
/* eslint-enable @stylistic/quotes */

describe("gitlab rules regex with variable interpolation", () => {
variableInRegexTests.forEach((t) => {
test(`- if: '${t.rule}'\n\t => ${t.evalResult}`, () => {
const evalSpy = vi.spyOn(global, "eval");
const res = Utils.evaluateRuleIf(t.rule, t.envs);
expect(res).toBe(t.evalResult);
expect(evalSpy).toHaveBeenCalledWith(t.jsExpression);
});
});
});

describe("gitlab rules regex [invalid]", () => {
tests.filter(t => t.expectedErrSubStr)
.forEach((t) => {
Expand Down
6 changes: 5 additions & 1 deletion tests/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,16 @@ test.concurrent("https://github.com/firecow/gitlab-ci-local/issues/350", () => {
rulesResult = Utils.getRulesResult({argv, cwd: "", rules, variables}, gitData);
expect(rulesResult).toEqual({when: "manual", allowFailure: false, variables: undefined});

// A $VAR referenced inside a regex literal expands to its raw value, so
// /$BRANCHNAME/ becomes /master/ which matches "master". (Previously the
// value was wrongly quoted inside the pattern, producing /"master"/ which
// never matched.)
rules = [
{if: "$CI_COMMIT_BRANCH =~ /$BRANCHNAME/", when: "manual"},
];
variables = {CI_COMMIT_BRANCH: "master", BRANCHNAME: "master"};
rulesResult = Utils.getRulesResult({argv, cwd: "", rules, variables}, gitData);
expect(rulesResult).toEqual({when: "never", allowFailure: false, variables: undefined});
expect(rulesResult).toEqual({when: "manual", allowFailure: false, variables: undefined});
});

test.concurrent("https://github.com/firecow/gitlab-ci-local/issues/300", () => {
Expand Down