Skip to content
11 changes: 8 additions & 3 deletions languageserver/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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});
})
Expand Down
87 changes: 87 additions & 0 deletions languageserver/src/context-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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);

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(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(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);
});
});
});
48 changes: 45 additions & 3 deletions languageserver/src/context-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,49 @@ 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) {
return {getContext: () => Promise.resolve(undefined)};
// "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,
workflowContext: WorkflowContext,
mode: Mode
) => {
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: (
name: string,
defaultContext: DescriptionDictionary | undefined,
workflowContext: WorkflowContext,
mode: Mode
) => Promise.resolve(undefined)
};
}

const getContext = async (
Expand All @@ -24,13 +57,22 @@ 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);
case "vars":
return await getVariables(workflowContext, client, cache, repo, defaultContext);
case "steps":
return await getStepsContext(client, cache, defaultContext, workflowContext);
default:
return undefined;
}
};

Expand Down
10 changes: 10 additions & 0 deletions languageserver/src/initializationOptions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down