Compare commits

...

67 Commits

Author SHA1 Message Date
Francesco Renzi 4efa31459b works 2025-12-09 10:43:21 +00:00
Francesco Renzi f8ea05739d Add more tests 2025-11-28 15:37:32 +00:00
Francesco Renzi 73dd3c33c4 prettier 2025-11-28 14:57:59 +00:00
Francesco Renzi e5800c8843 Setup CodeActions and add quickfix for missing inputs 2025-11-28 14:56:01 +00:00
Francesco Renzi bba2a01c01 docs 2025-11-28 08:59:43 +00:00
Francesco Renzi ec52bd7358 Add instructions on how to run locally and in neovim 2025-11-27 15:37:14 +00:00
eric sciple 03ffd0c44d Add validation for literal text in if conditions (#216)
* Validate literal text in if-condition format expressions

* test escaped left brace
2025-11-25 11:28:18 -06:00
eric sciple 03d68e89c6 Refactor if-condition to use schema-driven validation and AST-based status function detection (#218)
- Read allowed context from schema definition instead of hardcoded constants
- Parse expressions into AST to accurately detect status functions (avoids false positives from string literals)
- Export ensureStatusFunction helper that combines checking and wrapping logic
- Remove step-if.yml from skipped tests (now passes with accurate detection)
- Add tests for if-condition wrapping in hover/completion position mapping
2025-11-25 08:56:34 -06:00
eric sciple bad1fb96af Remove isExpression flag and implement convertToIfCondition to align with Go parser architecture (#217) 2025-11-24 09:12:26 -06:00
eric sciple 7f8bba4305 Merge pull request #214 from actions/release/0.3.20
Release version 0.3.20
2025-11-19 10:34:20 -06:00
GitHub Actions 43feb1a1f4 Release extension version 0.3.20 2025-11-19 16:32:52 +00:00
eric sciple d4aeaa3f3f Merge pull request #213 from indigok/patch-1
Add new artifact-metadata permission to schema
2025-11-19 10:19:40 -06:00
Indigo e4f8f24be3 Closing bracket
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 14:40:37 -08:00
Indigo 168cf44245 Add new artifact-metadata permission to schema 2025-11-13 13:54:34 -08:00
Francesco Renzi d4676627d8 Merge pull request #207 from actions/release/0.3.19
Release version 0.3.19
2025-09-30 12:39:48 +02:00
GitHub Actions d6b3b9d3e8 Release extension version 0.3.19 2025-09-30 10:37:47 +00:00
eric sciple 9ba7e48fbf Merge pull request #206 from lawrencegripper/lg/image-event
Add `on.image_version` support to language parser
2025-09-29 00:03:54 -05:00
Lawrence Gripper 6bd54f1b94 Merge branch 'lg/image-event' of github.com:lawrencegripper/languageservices into lg/image-event 2025-09-25 08:48:59 +00:00
Lawrence Gripper fcc72a8d97 Implement handling of new filters in typescript converter 2025-09-25 08:46:57 +00:00
Lawrence Gripper ce3b746742 Merge branch 'main' into lg/image-event 2025-09-24 11:42:22 +01:00
Lawrence Gripper 300c0dc569 Add support to language parser 2025-09-24 10:36:32 +00:00
eric sciple 6f63074d43 Merge pull request #204 from actions/release/0.3.18
Release version 0.3.18
2025-09-10 09:00:30 -05:00
GitHub Actions 7504f49ab6 Release extension version 0.3.18 2025-09-10 13:58:01 +00:00
eric sciple 629c9e23da Merge pull request #201 from lawrencegripper/lg/snapshot-keyword
Snapshot support
2025-09-09 12:40:55 -05:00
Lawrence Gripper 9838063a4e Fix up test for new limited context 2025-09-09 11:20:19 +00:00
Lawrence Gripper 01c3723641 fixup completion tests now we have new keywords 2025-09-09 11:09:05 +00:00
Lawrence Gripper 7cf82aa761 review: only add snapshot for factory job. remove context which isn't applicable 2025-09-09 10:31:20 +00:00
eric sciple 028715d071 Merge pull request #193 from actions/dependabot/npm_and_yarn/form-data-4.0.4
Bump form-data from 4.0.2 to 4.0.4
2025-09-04 14:12:17 -05:00
lawrencegripper cec59d9a4d More version bumping 🤦 2025-09-04 15:48:30 +00:00
lawrencegripper f316d205a9 chore: bump versions 2025-09-04 15:45:24 +00:00
Lawrence Gripper dd8308d7f9 Update workflow-parser/src/workflow-v1.0.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-04 16:37:04 +01:00
lawrencegripper 17f511bb6e chore(lint): Run prettier 2025-09-04 15:34:52 +00:00
lawrencegripper fca6e0aec1 Bump the version of relevant packages 2025-09-04 15:27:11 +00:00
lawrencegripper 4faa096820 Add support for new snapshot keyword and object into workflow parser 2025-09-04 15:25:36 +00:00
lawrencegripper ce274ee2ce 🐛 Add types to avoid npm run test failing with Cannot find module
Example error:

> src/templates/template-context.ts:1:28 - error TS2307: Cannot find module '@actions/expressions/funcs/info' or its corresponding type declarations.

related:

- https://github.com/actions/languageservices/issues/146
2025-09-04 15:02:25 +00:00
dependabot[bot] a13e5cd088 Bump form-data from 4.0.2 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 07:47:53 +00:00
Anthony Zavala 1f3436c3ca Merge pull request #192 from actions/anthonyzavala/bump-webpack-dev-server
Bump `webpack-dev-server: >=5.2.1`
2025-06-16 15:24:40 -07:00
Anthony Zavala 880d3e4109 Bump webpack-dev-server: >=5.2.1 2025-06-16 22:19:01 +00:00
Ben De St Paer-Gotch 09fd00ed88 Merge pull request #191 from actions/nebuk89-patch-1
Update README.md
2025-06-06 11:44:46 +01:00
Ben De St Paer-Gotch 435a10d9b6 Update README.md 2025-06-02 10:40:25 +01:00
Anthony Zavala 311a948ff0 Merge pull request #182 from actions/release/0.3.17
Release version 0.3.17
2025-05-07 14:06:00 -07:00
GitHub Actions b0fd29ab60 Release extension version 0.3.17 2025-05-07 21:04:23 +00:00
Anthony Zavala ccf95ef540 Merge pull request #181 from actions/anthonyzavala/bump-octokit-rest-and-lerna
Bump `@octokit/rest` from 19.0.7 to 21.1.1 & `lerna` from 8.2.1 to 8.2.2
2025-05-07 12:55:40 -07:00
Anthony Zavala e597a0c800 update env secret and vars parameters 2025-05-07 19:43:30 +00:00
Anthony Zavala 80c99e6e38 Bump @octokit/rest from 19.0.7 to 21.1.1 & lerna from 8.2.1 to 8.2.2 2025-05-07 18:11:36 +00:00
Anthony Zavala 655d268694 Merge pull request #180 from actions/alert-autofix-2
Potential fix for code scanning alert no. 2: Workflow does not contain permissions
2025-05-07 10:34:45 -07:00
Anthony Zavala 756ce20db2 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-07 10:24:31 -07:00
Beth Brennan 04b9c0c333 Merge pull request #179 from actions/release/0.3.16
Release version 0.3.16
2025-05-07 09:40:37 -04:00
GitHub Actions ffef418dbc Release extension version 0.3.16 2025-05-07 13:36:26 +00:00
Beth Brennan e2ec264801 Merge pull request #175 from sgoedecke/patch-1
Update workflow-v1.0.json to include models permission
2025-04-15 12:51:34 -04:00
Sean Goedecke ea15cac4e0 Update workflow-v1.0.json to include models permission 2025-04-11 16:02:48 +10:00
Yang Cao 81db06000a Merge pull request #166 from actions/dependabot/npm_and_yarn/lerna-8.2.1
Bump lerna from 6.0.3 to 8.2.1
2025-03-11 10:54:49 -04:00
dependabot[bot] f0a24df8db Bump lerna from 6.0.3 to 8.2.1
Bumps [lerna](https://github.com/lerna/lerna/tree/HEAD/packages/lerna) from 6.0.3 to 8.2.1.
- [Release notes](https://github.com/lerna/lerna/releases)
- [Changelog](https://github.com/lerna/lerna/blob/main/packages/lerna/CHANGELOG.md)
- [Commits](https://github.com/lerna/lerna/commits/v8.2.1/packages/lerna)

---
updated-dependencies:
- dependency-name: lerna
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-10 01:44:06 +00:00
Josh Gross 7c0bffb677 Merge pull request #164 from actions/joshmgross/update-workflows
Update workflow actions and remove release notes file
2025-03-05 13:17:49 -05:00
Josh Gross 6fedfd7fa4 Update workflow actions and remove release notes file 2025-03-05 13:10:09 -05:00
Josh Gross 8725c3c1c6 Merge pull request #163 from actions/release/0.3.15
Release version 0.3.15
2025-03-05 13:06:15 -05:00
GitHub Actions 977d0ea9cd Release extension version 0.3.15 2025-03-05 18:02:03 +00:00
Josh Gross 48247b8730 Merge pull request #145 from gillisandrew/bug/import-assertions
Remove import assertions
2025-03-05 12:58:01 -05:00
eric sciple bdee101604 Merge pull request #152 from actions/release/0.3.14
Release version 0.3.14
2025-01-29 15:34:45 -06:00
GitHub Actions 7a41cd9e66 Release extension version 0.3.14 2025-01-29 21:32:29 +00:00
eric sciple 0d97e79d94 Merge pull request #150 from ericsciple/users/ericsciple/25-01-description
Add root-level description keyword
2025-01-29 15:15:08 -06:00
eric sciple 50b08a3a22 Add root-level description keyword 2025-01-28 19:24:42 +00:00
Andrew Gillis f02e9593c2 Remove import assertions 2024-11-29 09:31:04 -05:00
Liela Rotschy 3a8c29c2df Merge pull request #102 from actions/release/0.3.13
Release version 0.3.13
2024-09-10 12:12:22 -06:00
GitHub Actions e6e3bb41e2 Release extension version 0.3.13 2024-09-10 18:10:03 +00:00
Liela Rotschy b147158840 Merge pull request #101 from actions/dependabot/npm_and_yarn/axios-1.7.7
Bump axios from 1.6.7 to 1.7.7
2024-09-10 12:07:56 -06:00
dependabot[bot] 1b970c131f Bump axios from 1.6.7 to 1.7.7
Bumps [axios](https://github.com/axios/axios) from 1.6.7 to 1.7.7.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.7...v1.7.7)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-09 17:08:04 +00:00
65 changed files with 6870 additions and 3063 deletions
+4 -2
View File
@@ -1,4 +1,6 @@
name: Build & Test
permissions:
contents: read
on:
push:
@@ -11,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js 16.15
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 16.15
cache: 'npm'
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: "16"
+5 -5
View File
@@ -24,10 +24,10 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Check if version has changed
id: check-version
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const version = '${{ inputs.version }}' || require('./lerna.json').version;
@@ -65,9 +65,9 @@ jobs:
PKG_VERSION: "" # will be set in the workflow
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 16.x
cache: "npm"
@@ -80,7 +80,7 @@ jobs:
- run: npm ci
- name: Create release
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const fs = require("fs");
+152
View File
@@ -0,0 +1,152 @@
# Using GitHub Actions Language Server in Neovim
## Prerequisites
- Node.js 18+
- Neovim 0.11+ with the new LSP config format
## Setup Options
### Option 1: Install from npm (Recommended)
Once published, you can install globally:
```bash
npm install -g @actions/languageserver
```
Then configure Neovim to use the installed binary:
```lua
-- ~/.config/nvim/lsp/actionsls.lua
return {
cmd = { "actions-languageserver" },
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Note:** This requires the package to be published to npm first.
### Option 2: Local Development Build
For development or if the npm package isn't published yet:
### 1. Clone and build
```bash
git clone https://github.com/actions/languageservices.git
cd languageservices
npm install
npm run build --workspaces --if-present
```
### 2. Bundle the server
The server needs to be bundled into a single file to avoid ESM module resolution issues:
```bash
cd languageserver
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
```
This creates `dist/server-bundled.cjs` (~5.6MB) that contains the entire server.
### 3. Configure Neovim
Create `~/.config/nvim/lsp/actionsls.lua`:
```lua
return {
cmd = {
"/absolute/path/to/languageservices/languageserver/bin/actions-languageserver",
},
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Important:** Replace `/absolute/path/to/languageservices` with your actual clone path.
## Filetype Detection for GitHub Actions Workflows
To ensure the LSP only runs on GitHub Actions workflow files (not all YAML files), set up filetype detection:
**Option A:** In `~/.config/nvim/init.lua`:
```lua
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
pattern = ".github/workflows/*.{yml,yaml}",
callback = function()
vim.bo.filetype = "yaml.ghaction"
end,
})
```
**Option B:** Create `~/.config/nvim/ftdetect/ghaction.vim`:
```vim
au BufRead,BufNewFile .github/workflows/*.yml,*.yaml setfiletype yaml.ghaction
```
This sets the filetype to `yaml.ghaction` for files in `.github/workflows/`, matching the `filetypes` setting in your LSP config.
### 4. Enable the LSP in your init.lua
Add to your Neovim configuration:
```lua
vim.lsp.enable('actionsls')
```
### 5. Restart Neovim
Open any `.github/workflows/*.yml` file. The filetype detection will set it to `yaml.ghaction`, and the language server will attach automatically.
## Files Created
- `languageserver/dist/server-bundled.cjs` - Bundled server (~5.6MB)
- `languageserver/bin/actions-languageserver` - Shell wrapper script
The `dist/` directory is gitignored; you'll need to rebuild after pulling updates.
## Troubleshooting
Check if the server is running:
```vim
:lua =vim.lsp.get_clients()
```
View LSP logs:
```bash
tail -f ~/.local/state/nvim/lsp.log
```
Manually start the server to test:
```vim
:lua vim.lsp.start({name='actionsls', cmd={'/path/to/bin/actions-languageserver'}, root_dir=vim.fn.getcwd(), init_options={sessionToken='', logLevel='info'}})
```
## Notes
- The main code change is in `languageserver/src/index.ts` to use dynamic imports, avoiding loading browser modules in Node.js
- The bundling step is necessary because TypeScript outputs ESM with bare imports that Node.js can't resolve
- Only workflow files in git repositories will activate the LSP (due to `root_markers = { ".git" }`)
+16 -2
View File
@@ -8,6 +8,20 @@ This repository contains multiple npm packages for working with GitHub Actions w
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service
## Contributing
### Note
See [CONTRIBUTING.md](./CONTRIBUTING.md)
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
We continue to focus our resources on strategic areas that help our customers be successful while making developers' lives easier. While GitHub Actions remains a key part of this vision, we are allocating resources towards other areas of Actions and are not taking contributions to this repository at this time. The GitHub public roadmap is the best place to follow along for any updates on features were working on and what stage theyre in.
We are taking the following steps to better direct requests related to GitHub Actions, including:
1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions)
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report.
3. Security Issues should be handled as per our [security.md](security.md)
We will still provide security updates for this project and fix major breaking changes during this time.
You are welcome to still raise bugs in this repo.
+1 -1
View File
@@ -34,6 +34,6 @@
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.11.1"
"webpack-dev-server": ">=5.2.1"
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.12",
"version": "0.3.20",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -9,10 +9,12 @@
},
"exports": {
".": {
"import": "./dist/index.js"
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./*": {
"import": "./dist/*.js"
"import": "./dist/*.js",
"types": "./dist/*.d.ts"
}
},
"typesVersions": {
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.12",
"version": "0.3.20",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -32,6 +32,7 @@
},
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
@@ -42,10 +43,13 @@
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.12",
"@actions/workflow-parser": "^0.3.12",
"@octokit/rest": "^19.0.7",
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.7",
@@ -61,6 +65,7 @@
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
+17 -1
View File
@@ -1,8 +1,11 @@
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -158,6 +164,16 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
return timeOperation("codeAction", async () => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
@@ -125,7 +125,7 @@ async function getRemoteSecrets(
environmentSecrets:
(environmentName &&
(await cache.get(`${repo.owner}/${repo.name}/secrets/environment/${environmentName}`, undefined, () =>
fetchEnvironmentSecrets(octokit, repo.id, environmentName)
fetchEnvironmentSecrets(octokit, repo.owner, repo.name, environmentName)
))) ||
[],
orgSecrets: await cache.get(`${repo.owner}/secrets`, undefined, () => fetchOrganizationSecrets(octokit, repo))
@@ -151,14 +151,16 @@ async function fetchSecrets(octokit: Octokit, owner: string, name: string): Prom
async function fetchEnvironmentSecrets(
octokit: Octokit,
repositoryId: number,
owner: string,
name: string,
environmentName: string
): Promise<StringData[]> {
try {
return await octokit.paginate(
octokit.actions.listEnvironmentSecrets,
{
repository_id: repositoryId,
owner,
repo: name,
environment_name: environmentName,
per_page: 100
},
@@ -115,7 +115,7 @@ export async function getRemoteVariables(
environmentVariables:
(environmentName &&
(await cache.get(`${repo.owner}/${repo.name}/vars/environment/${environmentName}`, undefined, () =>
fetchEnvironmentVariables(octokit, repo.id, environmentName)
fetchEnvironmentVariables(octokit, repo.owner, repo.name, environmentName)
))) ||
[],
organizationVariables: await cache.get(`${repo.owner}/vars`, undefined, () =>
@@ -146,14 +146,16 @@ async function fetchVariables(octokit: Octokit, owner: string, name: string): Pr
async function fetchEnvironmentVariables(
octokit: Octokit,
repositoryId: number,
owner: string,
name: string,
environmentName: string
): Promise<Pair[]> {
try {
return await octokit.paginate(
octokit.actions.listEnvironmentVariables,
{
repository_id: repositoryId,
owner: owner,
repo: name,
environment_name: environmentName,
per_page: 100
},
+3 -3
View File
@@ -1,12 +1,12 @@
import {Connection} from "vscode-languageserver";
import { Connection } from "vscode-languageserver";
import {
BrowserMessageReader,
BrowserMessageWriter,
createConnection as createBrowserConnection
} from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import { createConnection as createNodeConnection } from "vscode-languageserver/node";
import {initConnection} from "./connection";
import { initConnection } from "./connection";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.12",
"version": "0.3.20",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -44,8 +44,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.12",
"@actions/workflow-parser": "^0.3.12",
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
+1 -1
View File
@@ -1,7 +1,7 @@
import {promises as fs} from "fs";
import Webhook from "./webhook.js";
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json" assert {type: "json"};
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json";
import {deduplicateWebhooks} from "./deduplicate.js";
const schema = schemaImport as any;
+54
View File
@@ -0,0 +1,54 @@
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types";
import {quickfixProviders} from "./quickfix";
// Aggregate all providers by kind
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, quickfixProviders]
// [CodeActionKind. Refactor, refactorProviders],
// [CodeActionKind.Source, sourceProviders],
// etc
]);
export interface CodeActionConfig {
// TODO: actionsMetadataProvider, fileProvider, etc.
}
export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
}
export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri
};
// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];
for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}
return actions;
}
export type {CodeActionContext, CodeActionProvider} from "./types";
@@ -0,0 +1,67 @@
import { CodeAction, TextEdit } from "vscode-languageserver-types";
import { CodeActionContext, CodeActionProvider } from "../types";
import { DiagnosticCode, MissingInputsDiagnosticData } from "../../validate-action";
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
const edits = createInputEdits(data);
if (!edits) {
return undefined;
}
const inputNames = data.missingInputs.map(i => i.name).join(", ");
return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits,
},
},
};
},
};
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
const edits: TextEdit[] = [];
if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText: inputLines.map(line => line + "\n").join(""),
});
} else {
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
const withIndent = " ".repeat(data.stepIndent);
const inputIndent = " ".repeat(data.stepIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText,
});
}
return edits;
}
@@ -0,0 +1,4 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner";
import {ValidationConfig} from "../../validate";
import {ActionMetadata, ActionReference} from "../../action";
import {clearCache} from "../../utils/workflow-cache";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node. js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add . yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -0,0 +1,227 @@
import * as fs from "fs";
import * as path from "path";
import { TextEdit } from "vscode-languageserver-types";
import { TextDocument } from "vscode-languageserver-textdocument";
import { validate, ValidationConfig } from "../../validate";
import { getCodeActions, CodeActionParams } from "../index";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(". golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
console.log(found);
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
diagnostics: [diagnostic]
};
const actions = getCodeActions(params);
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,7 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,10 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -0,0 +1,8 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
+21
View File
@@ -0,0 +1,21 @@
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
// TODO: add things like workflow template, parsed content, etc.
}
/**
* A provider that can produce a code action for a given diagnostic
*/
export interface CodeActionProvider {
/**
* The diagnostic codes this provider handles
*/
diagnosticCodes: (string | number | undefined)[];
/**
* Create a code action for the diagnostic, if applicable
*/
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
}
+7 -7
View File
@@ -44,7 +44,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(8);
expect(result.length).toEqual(9);
expect(result[0].label).toEqual("concurrency");
});
@@ -70,7 +70,7 @@ jobs:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(20);
expect(result.length).toEqual(21);
});
it("string definition completion in sequence", async () => {
@@ -243,7 +243,7 @@ jobs:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
expect(result).toHaveLength(21);
});
it("job key with comment afterwards", async () => {
@@ -254,7 +254,7 @@ jobs:
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
expect(result).toHaveLength(21);
});
it("job key with other values afterwards", async () => {
@@ -266,7 +266,7 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(19);
expect(result).toHaveLength(20);
});
it("step key without space after colon", async () => {
@@ -335,7 +335,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(16);
expect(result).toHaveLength(17);
});
it("complete from behind a colon will replace it", async () => {
@@ -348,7 +348,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(16);
expect(result).toHaveLength(17);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.json" assert {type: "json"};
import descriptions from "./descriptions.json";
export const RootContext = "root";
const FunctionContext = "functions";
@@ -3,8 +3,8 @@ import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import schedule from "./schedule.json" assert {type: "json"};
import workflow_call from "./workflow_call.json" assert {type: "json"};
import schedule from "./schedule.json";
import workflow_call from "./workflow_call.json";
const customEventPayloads: {[name: string]: unknown} = {
schedule,
+12 -2
View File
@@ -21,8 +21,18 @@ describe("end-to-end", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(8);
expect(result.length).toEqual(9);
const labels = result.map(x => x.label);
expect(labels).toEqual(["concurrency", "defaults", "env", "jobs", "name", "on", "permissions", "run-name"]);
expect(labels).toEqual([
"concurrency",
"defaults",
"description",
"env",
"jobs",
"name",
"on",
"permissions",
"run-name"
]);
});
});
@@ -69,6 +69,59 @@ jobs:
}
});
});
it("job-level if condition without status function (gets wrapped)", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
if: git|hub.event_name == 'push'
runs-on: ubuntu-latest`)
).toEqual<ExpressionPos>({
expression: "success() && (github.event_name == 'push')",
position: {line: 0, column: 17}, // "success() && (".length + 3 = 17
documentRange: {
start: {line: 3, character: 8},
end: {line: 3, character: 35} // End of the original condition in the document
}
});
});
it("job-level if condition with status function (not wrapped)", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
if: alw|ays()
runs-on: ubuntu-latest`)
).toEqual<ExpressionPos>({
expression: "always()",
position: {line: 0, column: 3},
documentRange: {
start: {line: 3, character: 8},
end: {line: 3, character: 16}
}
});
});
it("step-level if condition without status function (gets wrapped)", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: steps.test.outc|ome == 'success'
run: echo hello`)
).toEqual<ExpressionPos>({
expression: "success() && (steps.test.outcome == 'success')",
position: {line: 0, column: 29}, // Actual position in the wrapped expression
documentRange: {
start: {line: 5, character: 12},
end: {line: 5, character: 43} // End of the original condition in the document
}
});
});
});
function testMapToExpressionPos(input: string) {
@@ -1,6 +1,7 @@
import {Pos} from "@actions/expressions/lexer";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
import {Position, Range as LSPRange} from "vscode-languageserver-textdocument";
import {mapRange} from "../utils/range";
import {posWithinRange} from "./pos-range";
@@ -16,12 +17,52 @@ export type ExpressionPos = {
documentRange: LSPRange;
};
/**
* Maps a document position to an expression position for hover/completion features.
*
* This handles both explicit expressions (with ${{ }}) and implicit expressions (like if conditions).
* For if conditions without ${{ }}, this applies the same conversion as the parser's convertToIfCondition,
* wrapping them in `success() && (...)` when no status function is present.
*
* @param token The template token at the position
* @param position The position in the document
* @returns Expression and adjusted position, or undefined if not an expression
*/
export function mapToExpressionPos(token: TemplateToken, position: Position): ExpressionPos | undefined {
const pos: Pos = {
line: position.line + 1,
column: position.character + 1
};
// Handle if conditions that are string tokens (job-if, step-if, snapshot-if)
const definitionKey = token.definition?.key;
if (
isString(token) &&
token.range &&
(definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if")
) {
const condition = token.value.trim();
if (condition) {
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
const exprRange = mapRange(token.range);
// Calculate offset: find where the original condition appears in the final expression
// If wrapped, it will be after "success() && (", otherwise it's at position 0
const offset = finalCondition.indexOf(condition);
return {
expression: finalCondition,
position: {
line: pos.line - exprRange.start.line - 1,
column: pos.column - exprRange.start.character - 1 + offset
},
documentRange: exprRange
};
}
}
if (!isBasicExpression(token)) {
return undefined;
}
@@ -155,8 +155,8 @@ jobs:
contents:
"Causes the step to always execute, and returns `true`, even when canceled. The `always` expression is best used at the step level or on tasks that you expect to run even when a job is canceled. For example, you can use `always` to send logs even when a job is canceled.",
range: {
start: {line: 3, character: 11},
end: {line: 3, character: 17}
start: {line: 3, character: 8},
end: {line: 3, character: 14}
}
});
});
+1
View File
@@ -5,3 +5,4 @@ export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
export {getCodeActions} from "./code-actions";
@@ -1,12 +1,11 @@
import {isString} from "@actions/workflow-parser";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {StringDefinition} from "@actions/workflow-parser/templates/schema/string-definition";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
export function isPotentiallyExpression(token: TemplateToken): boolean {
const isAlwaysExpression =
token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression;
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
return isAlwaysExpression || containsExpression;
// If conditions are always expressions (job-if, step-if, snapshot-if)
const definitionKey = token.definition?.key;
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
return containsExpression || isIfCondition;
}
+67 -12
View File
@@ -1,12 +1,30 @@
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {mapRange} from "./utils/range";
import {ValidationConfig} from "./validate";
import { isMapping } from "@actions/workflow-parser";
import { isActionStep } from "@actions/workflow-parser/model/type-guards";
import { Step } from "@actions/workflow-parser/model/workflow-template";
import { ScalarToken } from "@actions/workflow-parser/templates/tokens/scalar-token";
import { TemplateToken } from "@actions/workflow-parser/templates/tokens/template-token";
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
import { ActionReference, parseActionReference } from "./action";
import { mapRange } from "./utils/range";
import { ValidationConfig } from "./validate";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
hasWithKey: boolean;
// Indentation of the `with:` key if present, or the step's base indentation
withIndent?: number;
stepIndent: number;
// Position where new content should be inserted
insertPosition: { line: number; character: number };
}
export async function validateAction(
diagnostics: Diagnostic[],
@@ -35,7 +53,7 @@ export async function validateAction(
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
for (const { key, value } of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
@@ -45,7 +63,7 @@ export async function validateAction(
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
for (const { key } of withToken) {
stepInputs.set(key.toString(), key);
}
}
@@ -83,10 +101,47 @@ export async function validateAction(
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
const stepIndent = stepToken.range ? stepToken.range.start.column - 1 : 0; // 0-indexed
const withIndent = withKey?.range ? withKey.range.start.column - 1 : undefined;
// Calculate insert position
// For withToken, we need to handle empty mappings specially - insert after the with: line
let insertPosition: { line: number; character: number };
if (withToken?.range) {
// Check if with: has any children by comparing start and end lines
const hasChildren = stepInputs.size > 0;
if (hasChildren) {
// Insert after the last child
insertPosition = { line: withToken.range.end.line - 1, character: 0 };
} else {
// Empty with: block - insert on the next line after with:
insertPosition = { line: withKey!.range!.end.line, character: 0 };
}
} else if (stepToken.range) {
insertPosition = { line: stepToken.range.end.line - 1, character: 0 };
} else {
insertPosition = { line: 0, character: 0 };
}
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
})),
hasWithKey: withKey !== undefined,
withIndent,
stepIndent,
insertPosition
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message
message: message,
code: DiagnosticCode.MissingRequiredInputs,
data: diagnosticData
});
}
}
+74 -3
View File
@@ -249,7 +249,28 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: true,
insertPosition: {
character: 0,
line: 9
},
missingInputs: [
{
default: undefined,
name: "path"
}
],
stepIndent: 6,
withIndent: 6
}
}
]);
});
@@ -294,7 +315,32 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: true,
insertPosition: {
character: 0,
line: 9
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
],
stepIndent: 6,
withIndent: 6
}
}
]);
});
@@ -323,7 +369,32 @@ jobs:
line: 6
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: false,
insertPosition: {
character: 0,
line: 7
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
],
stepIndent: 6,
withIndent: undefined
}
}
]);
});
@@ -0,0 +1,214 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {registerLogger} from "./log";
import {createDocument} from "./test-utils/document";
import {TestLogger} from "./test-utils/logger";
import {clearCache} from "./utils/workflow-cache";
import {validate} from "./validate";
registerLogger(new TestLogger());
beforeEach(() => {
clearCache();
});
describe("expression literal text in conditions", () => {
describe("job-if", () => {
it("errors when literal text mixed with embedded expression", async () => {
const input = `
on: push
jobs:
build:
if: push == \${{ github.event_name }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
message:
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
it("allows format with only replacement tokens", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('{0}', github.event_name) }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("allows format with only replacement tokens and whitespace", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('{0}{1}', github.event_name, 'test') }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
// Only replacement tokens, no literal text
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("errors with literal text and replacement tokens mixed", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('event is {0}', github.event_name) }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
message:
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
it("errors with escaped left brace followed by replacement token", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('{{{0}', github.event_name) }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
message:
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
});
describe("step-if", () => {
it("errors when literal text mixed with embedded expression", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: success == \${{ job.status }}
run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
message:
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
it("allows valid expressions", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: \${{ success() }}
run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
});
describe("snapshot-if", () => {
it("errors when literal text mixed with embedded expression", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
steps:
- run: echo hi
snapshot:
image-name: my-image
if: ubuntu == \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
message:
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
});
describe("non-if fields", () => {
it("does not error for format in run", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('Event is {0}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
// Format with literal text is OK outside of if conditions
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
});
});
@@ -1505,4 +1505,174 @@ jobs:
expect(result).toEqual([]);
});
});
describe("if condition context restrictions", () => {
describe("job-level if", () => {
it("allows github context", async () => {
const input = `
on: push
jobs:
build:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows needs context", async () => {
const input = `
on: push
jobs:
a:
runs-on: ubuntu-latest
steps:
- run: echo hello
b:
needs: a
if: needs.a.result == 'success'
runs-on: ubuntu-latest
steps:
- run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows inputs context", async () => {
const input = `
on:
workflow_dispatch:
inputs:
environment:
type: string
jobs:
build:
if: inputs.environment == 'prod'
runs-on: ubuntu-latest
steps:
- run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
// Note: vars and matrix contexts are validated at runtime based on their existence
// vars context only exists if organization/repository variables are defined
// matrix context only exists if a strategy.matrix is defined
});
describe("step-level if", () => {
it("allows steps context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: setup
run: echo hello
- if: steps.setup.outcome == 'success'
run: echo world`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows job context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: job.status == 'success'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows runner context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: runner.os == 'Linux'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows env context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
env:
MY_VAR: value
steps:
- if: env.MY_VAR == 'value'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows matrix context in matrix job", async () => {
const input = `
on: push
jobs:
build:
strategy:
matrix:
os: [ubuntu, windows]
runs-on: ubuntu-latest
steps:
- if: matrix.os == 'ubuntu'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows hashFiles function", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: hashFiles('**/*.txt') != ''
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows all contexts together", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
env:
JOB_VAR: job-value
steps:
- id: first
run: echo hello
- if: github.event_name == 'push' && steps.first.outcome == 'success' && job.status == 'success' && runner.os == 'Linux' && env.JOB_VAR == 'job-value'
run: echo world`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
});
});
});
+123 -6
View File
@@ -1,7 +1,8 @@
import {Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
@@ -104,10 +105,44 @@ async function additionalValidations(
token,
validationToken.definitionInfo?.allowedContext || [],
config?.contextProviderConfig,
getProviderContext(documentUri, template, root, token.range)
getProviderContext(documentUri, template, root, token.range),
key?.definition?.key
);
}
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
const definitionKey = token.definition?.key;
if (
isString(token) &&
token.range &&
(definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if")
) {
// Convert the string to an expression token for validation
const condition = token.value.trim();
if (condition) {
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
// Create a BasicExpressionToken for validation
const expressionToken = new BasicExpressionToken(
token.file,
token.range,
finalCondition,
token.definitionInfo,
undefined,
token.source
);
await validateExpression(
diagnostics,
expressionToken,
validationToken.definitionInfo?.allowedContext || [],
config?.contextProviderConfig,
getProviderContext(documentUri, template, root, token.range)
);
}
}
if (token.definition?.key === "regular-step" && token.range) {
const context = getProviderContext(documentUri, template, root, token.range);
await validateAction(diagnostics, token, context.step, config);
@@ -179,17 +214,99 @@ function getProviderContext(
return getWorkflowContext(documentUri, template, path);
}
/**
* Checks if a format function contains literal text in its format string.
* This indicates user confusion about how expressions work.
*
* Example: format('push == {0}', github.event_name)
* The literal text "push == " will always evaluate to truthy.
*
* @param expr The expression to check
* @returns true if the expression is a format() call with literal text
*/
function hasFormatWithLiteralText(expr: Expr): boolean {
// If this is a logical AND expression (from ensureStatusFunction wrapping)
// check the right side for the format call
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
return hasFormatWithLiteralText(expr.args[1]);
}
if (!(expr instanceof FunctionCall)) {
return false;
}
// Check if this is a format function
if (expr.functionName.lexeme.toLowerCase() !== "format") {
return false;
}
// Check if the first argument is a string literal
if (expr.args.length < 1) {
return false;
}
const firstArg = expr.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
return false;
}
// Get the format string and trim whitespace
const formatString = firstArg.literal.coerceString();
const trimmed = formatString.trim();
// Check if there's literal text (non-replacement tokens) after trimming
let inToken = false;
for (let i = 0; i < trimmed.length; i++) {
if (!inToken && trimmed[i] === "{") {
inToken = true;
} else if (inToken && trimmed[i] === "}") {
inToken = false;
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
// OK - this is a replacement token like {0}, {1}, etc.
} else {
// Found literal text
return true;
}
}
return false;
}
async function validateExpression(
diagnostics: Diagnostic[],
token: BasicExpressionToken,
allowedContext: string[],
contextProviderConfig: ContextProviderConfig | undefined,
workflowContext: WorkflowContext
workflowContext: WorkflowContext,
keyDefinitionKey?: string
) {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Check for literal text in if condition
const definitionKey = keyDefinitionKey || token.definitionInfo?.definition?.key;
if (definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if") {
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message:
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: "expression-literal-text-in-condition"
});
}
} catch {
// Ignore parse errors here
}
}
// Validate the expression
for (const expression of token.originalExpressions || [token]) {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
let expr: Expr | undefined;
try {
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.12"
"version": "0.3.20"
}
+4510 -2946
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,6 +8,6 @@
"./languageserver"
],
"devDependencies": {
"lerna": "^6.0.3"
"lerna": "^8.2.2"
}
}
-1
View File
@@ -1 +0,0 @@
Release 0.3.5
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.12",
"version": "0.3.20",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -9,10 +9,12 @@
},
"exports": {
".": {
"import": "./dist/index.js"
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./*": {
"import": "./dist/*.js"
"import": "./dist/*.js",
"types": "./dist/*.d.ts"
}
},
"typesVersions": {
@@ -43,7 +45,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.12",
"@actions/expressions": "^0.3.20",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+4 -3
View File
@@ -194,10 +194,11 @@ jobs:
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const ifToken = build.get(1).value;
expect(ifToken.toString()).toEqual("${{ github.event_name == 'push' }}");
// Without isExpression: true, the value is kept as a string until convertToIfCondition processes it
expect(ifToken.toString()).toEqual("github.event_name == 'push'");
if (!isBasicExpression(ifToken)) {
throw new Error("expected if to be a basic expression");
if (!isString(ifToken)) {
throw new Error("expected if to be a string (will be converted to expression later)");
}
});
});
+198 -2
View File
@@ -74,7 +74,7 @@ jobs:
{
id: "build",
if: {
expr: "success()",
expr: "success() && (true)",
type: 3
},
name: "build",
@@ -85,7 +85,7 @@ jobs:
{
id: "deploy",
if: {
expr: "success()",
expr: "success() && (true)",
type: 3
},
name: "deploy",
@@ -382,4 +382,200 @@ jobs:
]
});
});
describe("if condition context validation", () => {
it("validates job-level if with allowed contexts", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
if: github.event_name == 'push' && needs.test.result == 'success'
needs: test
runs-on: ubuntu-latest
test:
runs-on: ubuntu-latest`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should convert successfully - github and needs are allowed in job-level if
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(2);
});
it("validates job-level if rejects disallowed contexts", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
if: steps.test.outcome == 'success'
runs-on: ubuntu-latest
steps:
- id: test
run: echo hello`
},
nullTrace
);
await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should have error - steps context not allowed in job-level if
const errors = result.context.errors.getErrors();
expect(errors.length).toBeGreaterThan(0);
const errorMessages = errors.map(e => e.message).join(" ");
expect(errorMessages.toLowerCase()).toMatch(/steps|context/);
});
it("validates step-level if allows all contexts", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: first
run: echo hello
- if: steps.first.outcome == 'success' && job.status == 'success'
run: echo world`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should convert successfully - steps and job contexts allowed in step-level if
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(1);
});
it("handles case-insensitive status functions in if conditions", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: Success()
run: echo "uppercase Success"
- if: FAILURE()
run: echo "uppercase FAILURE"
- if: Cancelled() || Always()
run: echo "mixed case"`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should convert successfully - status functions are case-insensitive
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(1);
// Verify the conditions are preserved without wrapping in success() &&
const job = template.jobs[0];
expect(job.type).toBe("job");
if (job.type === "job") {
expect(job.steps[0].if?.expression).toBe("Success()");
expect(job.steps[1].if?.expression).toBe("FAILURE()");
expect(job.steps[2].if?.expression).toBe("Cancelled() || Always()");
}
});
it("handles empty if condition", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
job1:
if: ""
runs-on: ubuntu-latest
steps:
- run: echo hello
job2:
if: ''
runs-on: ubuntu-latest
steps:
- if: ""
run: echo world
- if: ''
run: echo test`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Empty conditions should default to success()
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(2);
const job1 = template.jobs[0];
expect(job1.if?.expression).toBe("success()");
const job2 = template.jobs[1];
expect(job2.if?.expression).toBe("success()");
if (job2.type === "job") {
expect(job2.steps[0].if?.expression).toBe("success()");
expect(job2.steps[1].if?.expression).toBe("success()");
}
});
it("handles status functions with property access", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: success().outputs.result
run: echo "success with property"
- if: failure().outputs.value
run: echo "failure with property"
- if: always() && steps.test.outcome
run: echo "always with &&"`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should not wrap - status functions are present even with property access
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(1);
const job = template.jobs[0];
expect(job.type).toBe("job");
if (job.type === "job") {
expect(job.steps[0].if?.expression).toBe("success().outputs.result");
expect(job.steps[1].if?.expression).toBe("failure().outputs.value");
expect(job.steps[2].if?.expression).toBe("always() && steps.test.outcome");
}
});
});
});
@@ -7,10 +7,12 @@ import {TokenType} from "../../templates/tokens/types";
import {
BranchFilterConfig,
EventsConfig,
NamesFilterConfig,
PathFilterConfig,
ScheduleConfig,
TagFilterConfig,
TypesFilterConfig,
VersionsFilterConfig,
WorkflowFilterConfig
} from "../workflow-template";
import {isValidCron} from "./cron";
@@ -76,10 +78,11 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
...convertPatternFilter("tags", eventToken),
...convertPatternFilter("paths", eventToken),
...convertFilter("types", eventToken),
...convertFilter("versions", eventToken),
...convertFilter("names", eventToken),
...convertFilter("workflows", eventToken)
};
}
return result;
}
@@ -121,8 +124,8 @@ function convertPatternFilter<T extends BranchFilterConfig & TagFilterConfig & P
return result;
}
function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig>(
name: "types" | "workflows",
function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & VersionsFilterConfig & NamesFilterConfig>(
name: "types" | "workflows" | "versions" | "names",
token: MappingToken
): T {
const result = {} as T;
@@ -0,0 +1,138 @@
import {Lexer, Parser} from "@actions/expressions";
import {Binary, Expr, FunctionCall, Grouping, IndexAccess, Logical, Unary} from "@actions/expressions/ast";
import {DefinitionInfo} from "../../templates/schema/definition-info";
import {splitAllowedContext} from "../../templates/allowed-context";
import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, ExpressionToken, TemplateToken} from "../../templates/tokens";
/**
* Ensures a condition expression contains a status function call.
* If the condition doesn't contain success(), failure(), cancelled(), or always(),
* wraps it in `success() && (condition)`.
*
* Parses the expression to accurately detect status functions, avoiding false positives
* from string literals or property access. If parsing fails (e.g., partially typed expression),
* returns the original condition unchanged to allow validation to report the actual error.
*
* @param condition The condition expression to check
* @param definitionInfo Schema definition containing allowed contexts for parsing
* @returns The condition with status function guaranteed, or original on parse error
*/
export function ensureStatusFunction(condition: string, definitionInfo: DefinitionInfo | undefined): string {
const allowedContext = definitionInfo?.allowedContext || [];
try {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const lexer = new Lexer(condition);
const result = lexer.lex();
const parser = new Parser(result.tokens, namedContexts, functions);
const tree = parser.parse();
// Check if tree contains status function
if (walkTreeToFindStatusFunctionCalls(tree)) {
return condition; // Already has status function
}
// Wrap it
return `success() && (${condition})`;
} catch {
// Parse error - return original and let validation report the actual error
// This is important for hover/autocomplete on partially-typed expressions
return condition;
}
}
/**
* Converts an if condition token to a BasicExpressionToken.
* Treats the value as a string and parses it as an expression.
* Wraps the condition in success() && (...) if it doesn't already contain a status function.
* This allows both 'if: success()' and 'if: ${{ success() }}' to work correctly.
*
* Reads the allowed context directly from the schema definition attached to the token,
* ensuring consistency with the schema.
*
* @param context The template context for error reporting
* @param token The token containing the if condition
* @returns A BasicExpressionToken with the processed condition, or undefined on error
*/
export function convertToIfCondition(context: TemplateContext, token: TemplateToken): BasicExpressionToken | undefined {
const scalar = token.assertScalar("if condition");
// Get allowed context from the schema definition attached to the token
const allowedContext = token.definitionInfo?.allowedContext || [];
// If it's already an expression, use its value
let condition: string;
let source: string | undefined;
if (scalar instanceof BasicExpressionToken) {
condition = scalar.expression;
source = scalar.source;
} else {
// Otherwise, treat it as a string
const stringToken = scalar.assertString("if condition");
condition = stringToken.value.trim();
source = stringToken.source;
}
let finalCondition: string;
if (!condition) {
// Empty condition defaults to success()
finalCondition = "success()";
} else {
// Ensure the condition has a status function, wrapping if needed
finalCondition = ensureStatusFunction(condition, token.definitionInfo);
}
// Validate the expression before creating the token
try {
ExpressionToken.validateExpression(finalCondition, allowedContext);
} catch (err) {
context.error(token, err as Error);
return undefined;
}
// Create a BasicExpressionToken with the final condition
return new BasicExpressionToken(token.file, token.range, finalCondition, token.definitionInfo, undefined, source);
}
/**
* Walks an expression AST to find status function calls (success, failure, cancelled, always).
* Recursively checks all nodes including function arguments and logical/binary operations.
*/
function walkTreeToFindStatusFunctionCalls(tree: Expr | undefined): boolean {
if (!tree) {
return false;
}
if (tree instanceof FunctionCall) {
const funcName = tree.functionName.lexeme.toLowerCase();
if (funcName === "success" || funcName === "failure" || funcName === "cancelled" || funcName === "always") {
return true;
}
// Check arguments recursively
return tree.args.some(arg => walkTreeToFindStatusFunctionCalls(arg));
}
if (tree instanceof Binary) {
return walkTreeToFindStatusFunctionCalls(tree.left) || walkTreeToFindStatusFunctionCalls(tree.right);
}
if (tree instanceof Unary) {
return walkTreeToFindStatusFunctionCalls(tree.expr);
}
if (tree instanceof Logical) {
return tree.args.some(arg => walkTreeToFindStatusFunctionCalls(arg));
}
if (tree instanceof Grouping) {
return walkTreeToFindStatusFunctionCalls(tree.group);
}
if (tree instanceof IndexAccess) {
return walkTreeToFindStatusFunctionCalls(tree.expr) || walkTreeToFindStatusFunctionCalls(tree.index);
}
return false;
}
+24 -4
View File
@@ -2,6 +2,7 @@ import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
import {isSequence, isString} from "../../templates/tokens/type-guards";
import {Step, WorkflowJob} from "../workflow-template";
import {convertToIfCondition} from "./if-condition";
import {convertConcurrency} from "./concurrency";
import {convertToJobContainer, convertToJobServices} from "./container";
import {handleTemplateTokenErrors} from "./handle-errors";
@@ -16,7 +17,17 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
context.error(jobKey, error);
}
let concurrency, container, env, environment, name, outputs, runsOn, services, strategy: TemplateToken | undefined;
let concurrency,
container,
env,
environment,
ifCondition,
name,
outputs,
runsOn,
services,
strategy,
snapshot: TemplateToken | undefined;
let needs: StringToken[] | undefined = undefined;
let steps: Step[] = [];
let workflowJobRef: StringToken | undefined;
@@ -50,6 +61,10 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
environment = item.value;
break;
case "if":
ifCondition = convertToIfCondition(context, item.value);
break;
case "name":
name = item.value.assertScalar("job name");
break;
@@ -86,6 +101,10 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
services = item.value;
break;
case "snapshot":
snapshot = item.value;
break;
case "steps":
steps = convertSteps(context, item.value);
break;
@@ -121,7 +140,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
id: jobKey,
name: jobName(name, jobKey),
needs: needs || [],
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
ref: workflowJobRef,
"input-definitions": undefined,
"input-values": workflowJobInputs,
@@ -138,7 +157,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
id: jobKey,
name: jobName(name, jobKey),
needs,
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
env,
concurrency,
environment,
@@ -147,7 +166,8 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
container,
services,
outputs,
steps
steps,
snapshot
};
}
}
+7 -3
View File
@@ -2,6 +2,7 @@ import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
import {isSequence} from "../../templates/tokens/type-guards";
import {isActionStep} from "../type-guards";
import {convertToIfCondition} from "./if-condition";
import {ActionStep, Step} from "../workflow-template";
import {handleTemplateTokenErrors} from "./handle-errors";
import {IdBuilder} from "./id-builder";
@@ -52,7 +53,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
let uses: StringToken | undefined;
let continueOnError: boolean | ScalarToken | undefined;
let env: MappingToken | undefined;
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined);
let ifCondition: BasicExpressionToken | undefined;
for (const item of mapping) {
const key = item.key.assertString("steps item key");
switch (key.value) {
@@ -77,6 +78,9 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
case "env":
env = item.value.assertMapping("step env");
break;
case "if":
ifCondition = convertToIfCondition(context, item.value);
break;
case "continue-on-error":
if (!item.value.isExpression) {
continueOnError = item.value.assertBoolean("steps item continue-on-error").value;
@@ -90,7 +94,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
return {
id: id?.value || "",
name,
if: ifCondition,
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
"continue-on-error": continueOnError,
env,
run
@@ -101,7 +105,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
return {
id: id?.value || "",
name,
if: ifCondition,
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
"continue-on-error": continueOnError,
env,
uses
@@ -41,6 +41,7 @@ export type BaseJob = {
concurrency?: TemplateToken;
strategy?: TemplateToken;
outputs?: MappingToken;
snapshot?: TemplateToken;
};
// `job-factory` in the schema
@@ -129,6 +130,7 @@ export type EventsConfig = {
repository_dispatch?: TypesFilterConfig;
release?: TypesFilterConfig;
watch?: TypesFilterConfig;
image_versions?: TypesFilterConfig & VersionsFilterConfig & NamesFilterConfig;
// Index signature to allow easier lookup
[eventName: string]: unknown;
@@ -138,6 +140,14 @@ export type TypesFilterConfig = {
types?: string[];
};
export type VersionsFilterConfig = {
versions?: string[];
};
export type NamesFilterConfig = {
names?: string[];
};
export type BranchFilterConfig = {
branches?: string[];
"branches-ignore"?: string[];
@@ -8,7 +8,6 @@ import {DefinitionType} from "./schema/definition-type";
import {MappingDefinition} from "./schema/mapping-definition";
import {ScalarDefinition} from "./schema/scalar-definition";
import {SequenceDefinition} from "./schema/sequence-definition";
import {StringDefinition} from "./schema/string-definition";
import {ANY, CLOSE_EXPRESSION, INSERT_DIRECTIVE, OPEN_EXPRESSION} from "./template-constants";
import {TemplateContext} from "./template-context";
import {
@@ -456,14 +455,7 @@ class TemplateReader {
let startExpression: number = raw.indexOf(OPEN_EXPRESSION);
if (startExpression < 0) {
// Doesn't contain "${{"
// Check if value should still be evaluated as an expression
if (definitionInfo.definition instanceof StringDefinition && definitionInfo.definition.isExpression) {
const expression = this.parseIntoExpressionToken(token.range!, raw, allowedContext, token, definitionInfo);
if (expression) {
return expression;
}
}
// Doesn't contain "{{"
return token;
}
+111 -8
View File
@@ -7,6 +7,7 @@
"properties": {
"on": "on",
"name": "workflow-name",
"description": "workflow-description",
"run-name": "run-name",
"defaults": "workflow-defaults",
"env": "workflow-env",
@@ -28,6 +29,7 @@
"required": true
},
"name": "workflow-name",
"description": "workflow-description",
"run-name": "run-name",
"defaults": "workflow-defaults",
"env": "workflow-env",
@@ -44,6 +46,10 @@
"description": "The name of the workflow that GitHub displays on your repository's 'Actions' tab.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#name)",
"string": {}
},
"workflow-description": {
"description": "A description for your workflow or reusable workflow",
"string": {}
},
"run-name": {
"context": [
"github",
@@ -93,6 +99,7 @@
"discussion_comment": "discussion-comment",
"fork": "fork",
"gollum": "gollum",
"image_version": "image-version",
"issue_comment": "issue-comment",
"issues": "issues",
"label": "label",
@@ -134,6 +141,7 @@
"discussion-comment-string",
"fork-string",
"gollum-string",
"image-version-string",
"issue-comment-string",
"issues-string",
"label-string",
@@ -430,6 +438,47 @@
"description": "Runs your workflow when someone creates or updates a Wiki page.",
"null": {}
},
"image-version-string": {
"description": "Runs your workflow when an image version is created or changes state.",
"string": {
"constant": "image_version"
}
},
"image-version": {
"description": "Runs your workflow when an image version is created or changes state.",
"one-of": [
"null",
"image-version-mapping"
]
},
"image-version-mapping": {
"mapping": {
"properties": {
"types": "image-version-activity",
"names": "event-names",
"versions": "event-versions"
}
}
},
"image-version-activity": {
"description": "The types of image version activity that trigger the workflow. Supported activity types: `created`, `ready`, `deleted`.",
"one-of": [
"image-version-activity-type",
"image-version-activity-types"
]
},
"image-version-activity-types": {
"sequence": {
"item-type": "image-version-activity-type"
}
},
"image-version-activity-type": {
"allowed-values": [
"created",
"ready",
"deleted"
]
},
"issue-comment-string": {
"description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.",
"string": {
@@ -1215,6 +1264,13 @@
"sequence-of-non-empty-string"
]
},
"event-names": {
"description": "Use the `names` filter when you want to include names via patterns or when you want to both include and exclude names using patterns. ",
"one-of": [
"non-empty-string",
"sequence-of-non-empty-string"
]
},
"event-tags": {
"description": "Use the `tags` filter when you want to include tag name patterns or when you want to both include and exclude tag names patterns. You cannot use both the `tags` and `tags-ignore` filters for the same event in a workflow.",
"one-of": [
@@ -1243,6 +1299,13 @@
"sequence-of-non-empty-string"
]
},
"event-versions": {
"description": "Use the `versions` filter when you want to include versions via patterns or when you want to both include and exclude versions using patterns. ",
"one-of": [
"non-empty-string",
"sequence-of-non-empty-string"
]
},
"repository-dispatch-string": {
"description": "You can use the GitHub API to trigger a webhook event called `repository_dispatch` when you want to trigger a workflow for activity that happens outside of GitHub.",
"string": {
@@ -1515,6 +1578,10 @@
"type": "permission-level-any",
"description": "Actions workflows, workflow runs, and artifacts."
},
"artifact-metadata": {
"type": "permission-level-any",
"description": "Storage and deployment records for build artifacts."
},
"attestations": {
"type": "permission-level-any",
"description": "Artifact attestations."
@@ -1543,6 +1610,10 @@
"type": "permission-level-any",
"description": "Issues and related comments, assignees, labels, and milestones."
},
"models": {
"type": "permission-level-read-or-no-access",
"description": "Call AI models with GitHub Models."
},
"packages": {
"type": "permission-level-any",
"description": "Packages published to the GitHub Package Platform."
@@ -1700,7 +1771,8 @@
"concurrency": "job-concurrency",
"outputs": "job-outputs",
"defaults": "job-defaults",
"steps": "steps"
"steps": "steps",
"snapshot": "snapshot"
}
}
},
@@ -1770,9 +1842,7 @@
"cancelled(0,0)",
"success(0,MAX)"
],
"string": {
"is-expression": true
}
"string": {}
},
"job-if-result": {
"context": [
@@ -1844,6 +1914,41 @@
"loose-value-type": "any"
}
},
"snapshot": {
"description": "Use `snapshot` to define a custom image you want to create or update after your job succeeds by taking a snapshot of your runner.",
"one-of": [
"non-empty-string",
"snapshot-mapping"
]
},
"snapshot-mapping": {
"mapping": {
"properties": {
"image-name": {
"description": "The desired name of the custom image you want to create or update.",
"type": "non-empty-string",
"required": true
},
"if": "snapshot-if",
"version": {
"description": "The desired major version updates upon a new custom image version creation.",
"type": "non-empty-string"
}
}
}
},
"snapshot-if": {
"context": [
"github",
"inputs",
"vars",
"needs",
"strategy",
"matrix"
],
"description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
"string": {}
},
"runs-on": {
"description": "Use `runs-on` to define the type of machine to run the job on.\n* The destination machine can be either a GitHub-hosted runner, larger runner, or a self-hosted runner.\n* You can target runners based on the labels assigned to them, or their group membership, or a combination of these.\n* You can provide `runs-on` as a single string or as an array of strings.\n* If you specify an array of strings, your workflow will execute on any runner that matches all of the specified `runs-on` values.\n* If you would like to run your workflow on multiple machines, use `jobs.<job_id>.strategy`.",
"context": [
@@ -2107,9 +2212,7 @@
"hashFiles(1,255)"
],
"description": "Use the `if` conditional to prevent a step from running unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
"string": {
"is-expression": true
}
"string": {}
},
"step-if-result": {
"context": [
@@ -2514,4 +2617,4 @@
}
}
}
}
}
@@ -1,6 +1,6 @@
import {JSONObjectReader} from "../templates/json-object-reader";
import {TemplateSchema} from "../templates/schema";
import WorkflowSchema from "../workflow-v1.0.json" assert {type: "json"};
import WorkflowSchema from "../workflow-v1.0.json";
let schema: TemplateSchema;
@@ -0,0 +1,58 @@
include-source: false # Drop file/line/col from output
---
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
snapshot:
image-name: custom-image
version: 1.*
if: ${{ github.event_name == 'something' }}
---
{
"jobs": [
{
"type": "job",
"id": "build",
"name": "build",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
],
"snapshot": {
"type": 2,
"map": [
{
"Key": "image-name",
"Value": "custom-image"
},
{
"Key": "version",
"Value": "1.*"
},
{
"Key": "if",
"Value": {
"type": 3,
"expr": "github.event_name == 'something'"
}
}
]
}
}
]
}
+42
View File
@@ -0,0 +1,42 @@
include-source: false # Drop file/line/col from output
---
# on: push
# jobs:
# job1:
# runs-on: windows-2019
# snapshot: custom-image
# steps:
# - run: echo 1
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
snapshot: custom-image
---
{
"jobs": [
{
"type": "job",
"id": "build",
"name": "build",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
],
"snapshot": "custom-image"
}
]
}
@@ -0,0 +1,49 @@
include-source: false
skip:
- C#
- Go
---
on:
image_version:
names: testing
versions: 1.*
jobs:
my-job:
runs-on: ubuntu-latest
steps:
- run: echo hi
---
{
"events": {
"image_version": {
"versions": [
"1.*"
],
"names": [
"testing"
]
}
},
"jobs": [
{
"type": "job",
"id": "my-job",
"name": "my-job",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
}
]
}
@@ -0,0 +1,53 @@
include-source: false
skip:
- C#
- Go
---
on:
image_version:
types:
- ready
names:
- one
- two
jobs:
my-job:
runs-on: ubuntu-latest
steps:
- run: echo hi
---
{
"events": {
"image_version": {
"types": [
"ready"
],
"names": [
"one",
"two"
]
}
},
"jobs": [
{
"type": "job",
"id": "my-job",
"name": "my-job",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
}
]
}
@@ -0,0 +1,53 @@
include-source: false
skip:
- C#
- Go
---
on:
image_version:
types:
- ready
versions:
- "1.0.0"
- "1.0.1"
jobs:
my-job:
runs-on: ubuntu-latest
steps:
- run: echo hi
---
{
"events": {
"image_version": {
"types": [
"ready"
],
"versions": [
"1.0.0",
"1.0.1"
]
}
},
"jobs": [
{
"type": "job",
"id": "my-job",
"name": "my-job",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
}
]
}
@@ -0,0 +1,39 @@
include-source: false
skip:
- C#
- Go
---
on: image_version
jobs:
my-job:
runs-on: ubuntu-latest
steps:
- run: echo hi
---
{
"events": {
"image_version": {}
},
"jobs": [
{
"type": "job",
"id": "my-job",
"name": "my-job",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
}
]
}
@@ -0,0 +1,34 @@
include-source: false # Drop file/line/col from output
---
description: My workflow description
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
---
{
"jobs": [
{
"type": "job",
"id": "build",
"name": "build",
"if": {
"type": 3,
"expr": "success()"
},
"runs-on": "ubuntu-latest",
"steps": [
{
"id": "__run",
"if": {
"type": 3,
"expr": "success()"
},
"run": "echo hi"
}
]
}
]
}
-3
View File
@@ -50,7 +50,6 @@ errors-step-uses-syntax.yml
errors-unclosed-tokens.yml
errors-yaml-invalid-style.yml
errors-yaml-tags-explicit-unsupported.yml
escape-html-values.yml
float-folded-style.yml
insert.yml
is-partial-rerun.yml
@@ -59,7 +58,6 @@ job-cancel-timeout-minutes.yml
job-concurrency.yml
job-continue-on-error.yml
job-defaults.yml
job-if.yml
job-permissions.yml
job-timeout-minutes.yml
matrix-basic.yml
@@ -85,7 +83,6 @@ reusable-workflow-job-permissions-overrides-default-write.yml
reusable-workflow-job-permissions-overrides-workflow-level.yml
root-env-defaults.yml
round-to-infinity.yml
step-if.yml
scientific-notation-number.yml
skip-reusable-workflows.yml
workflow-defaults.yml