From 38215af13797492bbceceda2bbc29abb80289010 Mon Sep 17 00:00:00 2001 From: David Ruzicka Date: Wed, 3 Dec 2025 18:12:34 +0100 Subject: [PATCH 1/5] fix: return incomplete context for secrets/vars when API unavailable When GitHub API client or repository context is not available, mark secrets and vars contexts as incomplete instead of returning undefined. This prevents false 'Context access might be invalid' warnings for repository secrets and variables. Fixes github/vscode-github-actions#222 --- languageserver/src/connection.ts | 11 ++- languageserver/src/context-providers.test.ts | 76 ++++++++++++++++++++ languageserver/src/context-providers.ts | 32 ++++++++- languageserver/src/initializationOptions.ts | 10 +++ 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 languageserver/src/context-providers.test.ts diff --git a/languageserver/src/connection.ts b/languageserver/src/connection.ts index 90b139b2..e91af70b 100644 --- a/languageserver/src/connection.ts +++ b/languageserver/src/connection.ts @@ -23,7 +23,7 @@ import {Commands} from "./commands"; import {contextProviders} from "./context-providers"; import {descriptionProvider} from "./description-provider"; import {getFileProvider} from "./file-provider"; -import {InitializationOptions, RepositoryContext} from "./initializationOptions"; +import {InitializationOptions, RepositoryContext, SecretsValidationMode} from "./initializationOptions"; import {onCompletion} from "./on-completion"; import {ReadFileRequest, Requests} from "./request"; import {getActionsMetadataProvider} from "./utils/action-metadata"; @@ -36,6 +36,7 @@ export function initConnection(connection: Connection) { let client: Octokit | undefined; let repos: RepositoryContext[] = []; + let secretsValidation: SecretsValidationMode = "auto"; const cache = new TTLCache(); let hasWorkspaceFolderCapability = false; @@ -62,6 +63,10 @@ export function initConnection(connection: Connection) { setLogLevel(options.logLevel); } + if (options.secretsValidation) { + secretsValidation = options.secretsValidation; + } + const result: InitializeResult = { capabilities: { textDocumentSync: TextDocumentSyncKind.Full, @@ -107,7 +112,7 @@ export function initConnection(connection: Connection) { const config: ValidationConfig = { valueProviderConfig: valueProviders(client, repoContext, cache), - contextProviderConfig: contextProviders(client, repoContext, cache), + contextProviderConfig: contextProviders(client, repoContext, cache, secretsValidation), actionsMetadataProvider: getActionsMetadataProvider(client, cache), fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest); @@ -138,7 +143,7 @@ export function initConnection(connection: Connection) { const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)); return await hover(getDocument(documents, textDocument), position, { descriptionProvider: descriptionProvider(client, cache), - contextProviderConfig: repoContext && contextProviders(client, repoContext, cache), + contextProviderConfig: repoContext && contextProviders(client, repoContext, cache, secretsValidation), fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { return await connection.sendRequest(Requests.ReadFile, {path}); }) diff --git a/languageserver/src/context-providers.test.ts b/languageserver/src/context-providers.test.ts new file mode 100644 index 00000000..51c6a271 --- /dev/null +++ b/languageserver/src/context-providers.test.ts @@ -0,0 +1,76 @@ +import {DescriptionDictionary} from "@actions/expressions"; + +import {contextProviders} from "./context-providers"; +import {TTLCache} from "./utils/cache"; + +describe("contextProviders", () => { + describe("with secretsValidation = 'auto' (default)", () => { + it("returns incomplete secrets context when client is undefined", async () => { + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext!("secrets", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("returns incomplete vars context when client is undefined", async () => { + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext!("vars", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("preserves existing context when provided for secrets", async () => { + const existingContext = new DescriptionDictionary(); + existingContext.add("EXISTING_SECRET", {kind: 0, value: "***"} as never); + + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext!("secrets", existingContext, {} as never, 0); + + expect(result).toBe(existingContext); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("returns undefined for other context types", async () => { + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext!("steps", undefined, {} as never, 0); + + expect(result).toBeUndefined(); + }); + }); + + describe("with secretsValidation = 'always'", () => { + it("returns undefined for secrets when not signed in (triggers warnings)", async () => { + const config = contextProviders(undefined, undefined, new TTLCache(), "always"); + const result = await config.getContext!("secrets", undefined, {} as never, 0); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for vars when not signed in (triggers warnings)", async () => { + const config = contextProviders(undefined, undefined, new TTLCache(), "always"); + const result = await config.getContext!("vars", undefined, {} as never, 0); + + expect(result).toBeUndefined(); + }); + }); + + describe("with secretsValidation = 'never'", () => { + it("returns incomplete secrets context even when signed in", async () => { + const config = contextProviders(undefined, undefined, new TTLCache(), "never"); + const result = await config.getContext!("secrets", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("returns incomplete vars context even when signed in", async () => { + const config = contextProviders(undefined, undefined, new TTLCache(), "never"); + const result = await config.getContext!("vars", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + }); +}); diff --git a/languageserver/src/context-providers.ts b/languageserver/src/context-providers.ts index 243baf77..737c1659 100644 --- a/languageserver/src/context-providers.ts +++ b/languageserver/src/context-providers.ts @@ -6,15 +6,36 @@ import {Octokit} from "@octokit/rest"; import {getSecrets} from "./context-providers/secrets"; import {getStepsContext} from "./context-providers/steps"; import {getVariables} from "./context-providers/variables"; -import {RepositoryContext} from "./initializationOptions"; +import {RepositoryContext, SecretsValidationMode} from "./initializationOptions"; import {TTLCache} from "./utils/cache"; export function contextProviders( client: Octokit | undefined, repo: RepositoryContext | undefined, - cache: TTLCache + cache: TTLCache, + secretsValidation: SecretsValidationMode = "auto" ): ContextProviderConfig { + // Handle missing client/repo based on validation mode if (!repo || !client) { + // "never" - always suppress validation + // "auto" - suppress when context is incomplete (client or repo missing) + // "always" - show warnings even when context is incomplete + const shouldSuppress = secretsValidation === "never" || secretsValidation === "auto"; + + if (shouldSuppress) { + // Mark secrets/vars as incomplete to prevent false warnings + return { + getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => { + if (name === "secrets" || name === "vars") { + const dict = defaultContext || new DescriptionDictionary(); + dict.complete = false; + return Promise.resolve(dict); + } + return Promise.resolve(undefined); + } + }; + } + // "always" mode - return undefined to trigger warnings return {getContext: () => Promise.resolve(undefined)}; } @@ -24,6 +45,13 @@ export function contextProviders( workflowContext: WorkflowContext, mode: Mode ) => { + // If validation is disabled, mark as incomplete + if (secretsValidation === "never" && (name === "secrets" || name === "vars")) { + const dict = defaultContext || new DescriptionDictionary(); + dict.complete = false; + return dict; + } + switch (name) { case "secrets": return await getSecrets(workflowContext, client, cache, repo, defaultContext, mode); diff --git a/languageserver/src/initializationOptions.ts b/languageserver/src/initializationOptions.ts index 59ef4623..790a4cdb 100644 --- a/languageserver/src/initializationOptions.ts +++ b/languageserver/src/initializationOptions.ts @@ -1,6 +1,8 @@ import {LogLevel} from "@actions/languageservice/log"; export {LogLevel} from "@actions/languageservice/log"; +export type SecretsValidationMode = "auto" | "always" | "never"; + export interface InitializationOptions { /** * GitHub token that will be used to retrieve additional information from github.com @@ -28,6 +30,14 @@ export interface InitializationOptions { * If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3" */ gitHubApiUrl?: string; + + /** + * Controls validation of secrets and variables context access + * - "auto": Validate only when signed in (recommended) + * - "always": Always validate - show warnings even when not signed in + * - "never": Never validate secrets/variables access + */ + secretsValidation?: SecretsValidationMode; } export interface RepositoryContext { From 592428fa5d5bad1a2a16405dfaa65204c4284408 Mon Sep 17 00:00:00 2001 From: David Ruzicka Date: Wed, 3 Dec 2025 22:39:13 +0100 Subject: [PATCH 2/5] fix: context-provider tests --- languageserver/src/context-providers.test.ts | 31 +++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/languageserver/src/context-providers.test.ts b/languageserver/src/context-providers.test.ts index 51c6a271..5a15432e 100644 --- a/languageserver/src/context-providers.test.ts +++ b/languageserver/src/context-providers.test.ts @@ -1,13 +1,24 @@ import {DescriptionDictionary} from "@actions/expressions"; +import {Octokit} from "@octokit/rest"; import {contextProviders} from "./context-providers"; +import {RepositoryContext} from "./initializationOptions"; import {TTLCache} from "./utils/cache"; +const mockClient = new Octokit(); +const mockRepo: RepositoryContext = { + id: 123, + owner: "test-owner", + name: "test-repo", + workspaceUri: "file:///test", + organizationOwned: false +}; + describe("contextProviders", () => { describe("with secretsValidation = 'auto' (default)", () => { it("returns incomplete secrets context when client is undefined", async () => { const config = contextProviders(undefined, undefined, new TTLCache()); - const result = await config.getContext!("secrets", undefined, {} as never, 0); + const result = await config.getContext("secrets", undefined, {} as never, 0); expect(result).toBeInstanceOf(DescriptionDictionary); expect((result as DescriptionDictionary).complete).toBe(false); @@ -15,7 +26,7 @@ describe("contextProviders", () => { it("returns incomplete vars context when client is undefined", async () => { const config = contextProviders(undefined, undefined, new TTLCache()); - const result = await config.getContext!("vars", undefined, {} as never, 0); + const result = await config.getContext("vars", undefined, {} as never, 0); expect(result).toBeInstanceOf(DescriptionDictionary); expect((result as DescriptionDictionary).complete).toBe(false); @@ -26,7 +37,7 @@ describe("contextProviders", () => { existingContext.add("EXISTING_SECRET", {kind: 0, value: "***"} as never); const config = contextProviders(undefined, undefined, new TTLCache()); - const result = await config.getContext!("secrets", existingContext, {} as never, 0); + const result = await config.getContext("secrets", existingContext, {} as never, 0); expect(result).toBe(existingContext); expect((result as DescriptionDictionary).complete).toBe(false); @@ -34,7 +45,7 @@ describe("contextProviders", () => { it("returns undefined for other context types", async () => { const config = contextProviders(undefined, undefined, new TTLCache()); - const result = await config.getContext!("steps", undefined, {} as never, 0); + const result = await config.getContext("steps", undefined, {} as never, 0); expect(result).toBeUndefined(); }); @@ -43,14 +54,14 @@ describe("contextProviders", () => { describe("with secretsValidation = 'always'", () => { it("returns undefined for secrets when not signed in (triggers warnings)", async () => { const config = contextProviders(undefined, undefined, new TTLCache(), "always"); - const result = await config.getContext!("secrets", undefined, {} as never, 0); + const result = await config.getContext("secrets", undefined, {} as never, 0); expect(result).toBeUndefined(); }); it("returns undefined for vars when not signed in (triggers warnings)", async () => { const config = contextProviders(undefined, undefined, new TTLCache(), "always"); - const result = await config.getContext!("vars", undefined, {} as never, 0); + const result = await config.getContext("vars", undefined, {} as never, 0); expect(result).toBeUndefined(); }); @@ -58,16 +69,16 @@ describe("contextProviders", () => { describe("with secretsValidation = 'never'", () => { it("returns incomplete secrets context even when signed in", async () => { - const config = contextProviders(undefined, undefined, new TTLCache(), "never"); - const result = await config.getContext!("secrets", undefined, {} as never, 0); + const config = contextProviders(mockClient, mockRepo, new TTLCache(), "never"); + const result = await config.getContext("secrets", undefined, {} as never, 0); expect(result).toBeInstanceOf(DescriptionDictionary); expect((result as DescriptionDictionary).complete).toBe(false); }); it("returns incomplete vars context even when signed in", async () => { - const config = contextProviders(undefined, undefined, new TTLCache(), "never"); - const result = await config.getContext!("vars", undefined, {} as never, 0); + const config = contextProviders(mockClient, mockRepo, new TTLCache(), "never"); + const result = await config.getContext("vars", undefined, {} as never, 0); expect(result).toBeInstanceOf(DescriptionDictionary); expect((result as DescriptionDictionary).complete).toBe(false); From ffde65105b5a9f0c816a7e86a76052fe2fc034f7 Mon Sep 17 00:00:00 2001 From: David Ruzicka Date: Wed, 3 Dec 2025 23:16:22 +0100 Subject: [PATCH 3/5] Fix simplified `getContext()` function signature Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- languageserver/src/context-providers.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/languageserver/src/context-providers.ts b/languageserver/src/context-providers.ts index 737c1659..db1c316f 100644 --- a/languageserver/src/context-providers.ts +++ b/languageserver/src/context-providers.ts @@ -25,7 +25,12 @@ export function contextProviders( if (shouldSuppress) { // Mark secrets/vars as incomplete to prevent false warnings return { - getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => { + getContext: ( + name: string, + defaultContext: DescriptionDictionary | undefined, + workflowContext: WorkflowContext, + mode: Mode + ) => { if (name === "secrets" || name === "vars") { const dict = defaultContext || new DescriptionDictionary(); dict.complete = false; From 030e1a4ced6415e245325c654c0675c5e328bcd0 Mon Sep 17 00:00:00 2001 From: David Ruzicka Date: Wed, 3 Dec 2025 23:16:42 +0100 Subject: [PATCH 4/5] Fix simplified `getContext()` function signature. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- languageserver/src/context-providers.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/languageserver/src/context-providers.ts b/languageserver/src/context-providers.ts index db1c316f..1252e6a6 100644 --- a/languageserver/src/context-providers.ts +++ b/languageserver/src/context-providers.ts @@ -41,7 +41,14 @@ export function contextProviders( }; } // "always" mode - return undefined to trigger warnings - return {getContext: () => Promise.resolve(undefined)}; + return { + getContext: ( + name: string, + defaultContext: DescriptionDictionary | undefined, + workflowContext: WorkflowContext, + mode: Mode + ) => Promise.resolve(undefined) + }; } const getContext = async ( From 4c038a34f4b0814e915c8b50e67364da47e685a1 Mon Sep 17 00:00:00 2001 From: David Ruzicka Date: Wed, 3 Dec 2025 23:21:30 +0100 Subject: [PATCH 5/5] fix: handle default case in getContext() for contextProviders --- languageserver/src/context-providers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/languageserver/src/context-providers.ts b/languageserver/src/context-providers.ts index 1252e6a6..cbaf5242 100644 --- a/languageserver/src/context-providers.ts +++ b/languageserver/src/context-providers.ts @@ -71,6 +71,8 @@ export function contextProviders( return await getVariables(workflowContext, client, cache, repo, defaultContext); case "steps": return await getStepsContext(client, cache, defaultContext, workflowContext); + default: + return undefined; } };