From 108b8c2766d1c83a596b720b2239f5bc14af37df Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 8 Dec 2025 09:18:59 -0600 Subject: [PATCH] Support YAML anchors and aliases (#234) Fixes https://github.com/github/vscode-github-actions/issues/405 YAML anchors (&name) and aliases (*name) are now properly supported. When an alias is encountered during parsing, it is resolved to its anchored value, making aliases transparent to the rest of the system. Changes: - workflow-parser: Handle isAlias nodes in YamlObjectReader.getNodes() - languageservice: Add tests for various anchor/alias patterns Test cases: - Anchors in env mappings - Multiple aliases to same anchor - Anchors in matrix strategy - Anchors in steps - Scalar anchors (e.g., runs-on) --- .../src/validate.yaml-anchors.test.ts | 202 ++++++++++++++++++ .../src/workflows/yaml-object-reader.ts | 49 ++++- 2 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 languageservice/src/validate.yaml-anchors.test.ts diff --git a/languageservice/src/validate.yaml-anchors.test.ts b/languageservice/src/validate.yaml-anchors.test.ts new file mode 100644 index 0000000..6920b1a --- /dev/null +++ b/languageservice/src/validate.yaml-anchors.test.ts @@ -0,0 +1,202 @@ +import {validate} from "./validate"; +import {createDocument} from "./test-utils/document"; +import {clearCache} from "./utils/workflow-cache"; + +beforeEach(() => { + clearCache(); +}); + +describe("YAML anchors and aliases", () => { + it("should handle anchors and aliases in env", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + job1: + runs-on: ubuntu-latest + env: &env + ENV1: env1 + ENV2: env2 + steps: + - run: exit 0 + job2: + runs-on: ubuntu-latest + env: *env + steps: + - run: exit 0 +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should handle multiple aliases to the same anchor", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +env: &shared + SHARED: true +jobs: + job1: + runs-on: ubuntu-latest + env: *shared + steps: + - run: exit 0 + job2: + runs-on: ubuntu-latest + env: *shared + steps: + - run: exit 0 + job3: + runs-on: ubuntu-latest + env: *shared + steps: + - run: exit 0 +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should handle anchors in matrix strategy", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + include: &matrix-include + - os: ubuntu-latest + node: 18 + - os: windows-latest + node: 20 + steps: + - run: exit 0 + test2: + runs-on: ubuntu-latest + strategy: + matrix: + include: *matrix-include + steps: + - run: exit 0 +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should handle anchors in steps", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - &checkout + uses: actions/checkout@v4 + - run: npm test + deploy: + runs-on: ubuntu-latest + steps: + - *checkout + - run: npm run deploy +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should handle scalar anchors", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: &runner ubuntu-latest + steps: + - run: exit 0 + test: + runs-on: *runner + steps: + - run: exit 0 +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should work without anchors (control test)", async () => { + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + job1: + runs-on: ubuntu-latest + env: + ENV1: env1 + ENV2: env2 + steps: + - run: exit 0 + job2: + runs-on: ubuntu-latest + env: + ENV1: env1 + ENV2: env2 + steps: + - run: exit 0 +` + ); + const result = await validate(doc); + expect(result).toEqual([]); + }); + + it("should handle circular aliases without hanging", async () => { + // This is an invalid use case (alias referencing parent) but should not hang + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + env: &myenv + FOO: bar + nested: *myenv + steps: + - run: exit 0 +` + ); + // Should complete without hanging - circular portion is silently ignored + // which may cause downstream validation errors, but that's acceptable + const result = await validate(doc); + expect(result).toBeDefined(); + }); + + it("should handle undefined alias references", async () => { + // Reference to non-existent anchor - yaml library should report error + const doc = createDocument( + "wf.yaml", + ` +on: push +jobs: + build: + runs-on: ubuntu-latest + env: *nonexistent + steps: + - run: exit 0 +` + ); + const result = await validate(doc); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/workflow-parser/src/workflows/yaml-object-reader.ts b/workflow-parser/src/workflows/yaml-object-reader.ts index ce93d9a..dc8fbfb 100644 --- a/workflow-parser/src/workflows/yaml-object-reader.ts +++ b/workflow-parser/src/workflows/yaml-object-reader.ts @@ -1,4 +1,16 @@ -import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml"; +import { + isAlias, + isCollection, + isDocument, + isMap, + isPair, + isScalar, + isSeq, + LineCounter, + parseDocument, + Scalar +} from "yaml"; +import type {Document} from "yaml"; import type {LinePos} from "yaml/dist/errors"; import type {NodeBase} from "yaml/dist/nodes/Node"; import {ObjectReader} from "../templates/object-reader"; @@ -22,30 +34,31 @@ export type YamlError = { export class YamlObjectReader implements ObjectReader { private readonly _generator: Generator; private _current!: IteratorResult; + private readonly doc: Document; private fileId?: number; private lineCounter = new LineCounter(); public errors: YamlError[] = []; constructor(fileId: number | undefined, content: string) { - const doc = parseDocument(content, { + this.doc = parseDocument(content, { lineCounter: this.lineCounter, keepSourceTokens: true, uniqueKeys: false // Uniqueness is validated by the template reader }); - for (const err of doc.errors) { + for (const err of this.doc.errors) { this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)}); } - this._generator = this.getNodes(doc); + this._generator = this.getNodes(this.doc, new Set()); this.fileId = fileId; } - private *getNodes(node: unknown): Generator { + private *getNodes(node: unknown, aliasResolutionStack: Set): Generator { let range = this.getRange(node as NodeBase | undefined); if (isDocument(node)) { yield new ParseEvent(EventType.DocumentStart); - for (const item of this.getNodes(node.contents)) { + for (const item of this.getNodes(node.contents, new Set())) { yield item; } yield new ParseEvent(EventType.DocumentEnd); @@ -59,7 +72,7 @@ export class YamlObjectReader implements ObjectReader { } for (const item of node.items) { - for (const child of this.getNodes(item)) { + for (const child of this.getNodes(item, aliasResolutionStack)) { yield child; } } @@ -74,12 +87,32 @@ export class YamlObjectReader implements ObjectReader { yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node)); } + // Handle YAML aliases - resolve to the anchored value + if (isAlias(node)) { + const resolved = node.resolve(this.doc); + if (resolved) { + // Prevent infinite recursion from circular aliases + if (aliasResolutionStack.has(resolved)) { + // Silently ignore circular reference - the missing content will cause + // downstream validation errors which is acceptable for this edge case + return; + } + // Track this node in the alias resolution stack + const newStack = new Set(aliasResolutionStack); + newStack.add(resolved); + // Yield the resolved node's contents + yield* this.getNodes(resolved, newStack); + } + // If unresolved, the yaml library already reports an error + return; + } + if (isPair(node)) { const scalarKey = node.key as Scalar; range = this.getRange(scalarKey); const key = scalarKey.value as string; yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined)); - for (const child of this.getNodes(node.value)) { + for (const child of this.getNodes(node.value, aliasResolutionStack)) { yield child; } }