Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4efa31459b | |||
| f8ea05739d | |||
| 73dd3c33c4 | |||
| e5800c8843 | |||
| bba2a01c01 | |||
| ec52bd7358 | |||
| 03ffd0c44d | |||
| 03d68e89c6 | |||
| bad1fb96af | |||
| 7f8bba4305 | |||
| 43feb1a1f4 | |||
| d4aeaa3f3f | |||
| e4f8f24be3 | |||
| 168cf44245 | |||
| d4676627d8 | |||
| d6b3b9d3e8 | |||
| 9ba7e48fbf | |||
| 6bd54f1b94 | |||
| fcc72a8d97 | |||
| ce3b746742 | |||
| 300c0dc569 | |||
| 6f63074d43 | |||
| 7504f49ab6 | |||
| 629c9e23da | |||
| 9838063a4e | |||
| 01c3723641 | |||
| 7cf82aa761 | |||
| 028715d071 | |||
| cec59d9a4d | |||
| f316d205a9 | |||
| dd8308d7f9 | |||
| 17f511bb6e | |||
| fca6e0aec1 | |||
| 4faa096820 | |||
| ce274ee2ce | |||
| a13e5cd088 | |||
| 1f3436c3ca | |||
| 880d3e4109 | |||
| 09fd00ed88 | |||
| 435a10d9b6 | |||
| 311a948ff0 | |||
| b0fd29ab60 | |||
| ccf95ef540 | |||
| e597a0c800 | |||
| 80c99e6e38 | |||
| 655d268694 | |||
| 756ce20db2 | |||
| 04b9c0c333 | |||
| ffef418dbc | |||
| e2ec264801 | |||
| ea15cac4e0 | |||
| 81db06000a | |||
| f0a24df8db | |||
| 7c0bffb677 | |||
| 6fedfd7fa4 | |||
| 8725c3c1c6 | |||
| 977d0ea9cd | |||
| 48247b8730 | |||
| bdee101604 | |||
| 7a41cd9e66 | |||
| 0d97e79d94 | |||
| 50b08a3a22 | |||
| f02e9593c2 | |||
| 3a8c29c2df | |||
| e6e3bb41e2 | |||
| b147158840 | |||
| 1b970c131f | |||
| 83bddd3332 | |||
| 53e3f1755d | |||
| 0751d266c2 | |||
| 4f4d671d85 | |||
| af7626066f | |||
| da50e32283 | |||
| f22ec34cdf | |||
| 950407cc05 | |||
| 04f923e2dc | |||
| 50bd1ab3b1 | |||
| 879aceaab3 | |||
| 5aa45f9482 | |||
| 02075a6585 | |||
| e9ca4c3e91 | |||
| 7c18d8fae8 | |||
| 657b14fd19 | |||
| c4ff28c60e | |||
| 9f3c3a8291 | |||
| 908852d57c | |||
| bf04ee63c7 | |||
| 795dd67915 | |||
| f809d5f89b | |||
| e42b020521 | |||
| acdfbcc609 | |||
| 5dbaa884db | |||
| 67d9f06795 | |||
| dec1281c4c | |||
| 4d64772250 | |||
| b200bedbce | |||
| 62cf97a1fd | |||
| c9aeb8d597 | |||
| 26d8080e56 | |||
| a1d07a6ffe | |||
| e6ce85f61f | |||
| c9afb14da5 | |||
| fe696132cf | |||
| 026f4e3ece | |||
| 098e785c13 | |||
| b0c2dec02f | |||
| def4fb41a9 | |||
| 84335c7203 | |||
| 7e062aa16b | |||
| b67105b9b4 | |||
| 1b823ebe67 | |||
| 4280a967a8 | |||
| ded93b55e7 | |||
| f966106367 | |||
| aaa71be634 | |||
| 818a321069 | |||
| c4d2f35a55 | |||
| 6513b0d15d | |||
| 207cfa12c0 | |||
| 28ab3928fd | |||
| afbe42bffe | |||
| a324b8b9dc | |||
| 4f7d03ed0c | |||
| 78acb30a9c | |||
| 4ddbbc9db7 | |||
| 3ea2cf1829 | |||
| 2c30f2f45f | |||
| cf2d9cd0b9 | |||
| 8f2f59092e | |||
| 5de89b0f8e | |||
| f4afa48ea4 | |||
| af5dd4b91e | |||
| 12d28370dc | |||
| 833b6fcac5 | |||
| cd7fabeb7f | |||
| 26da52bdf8 | |||
| 31aa95fb10 | |||
| b912482163 | |||
| 41436c6570 | |||
| 468b68840b | |||
| 57a77551b0 | |||
| a34a500176 | |||
| d7ae6f88f1 | |||
| 588c457cea | |||
| 82985934af | |||
| cb3ec583e0 | |||
| 7c3b116b19 | |||
| 4a6134be6c | |||
| 0080226132 | |||
| 896f780991 | |||
| 63b170f2a6 | |||
| 94451fa8f2 | |||
| 41e05b8ad1 | |||
| 5362fb1841 | |||
| 4e1f7cd9ac | |||
| 5c785ab41b | |||
| 9d246960f3 | |||
| eefd820cc5 | |||
| 58712f4d46 | |||
| 0bd67083ff | |||
| 40c20d5504 | |||
| c6cde72b37 | |||
| d47636092a | |||
| e292f8ca51 | |||
| 8f4080074b | |||
| b04e5db100 | |||
| 2795997f4c | |||
| 413ae51185 | |||
| 8bc0c5636e | |||
| 58bf3b35cc | |||
| 124ee84d1f | |||
| c4d478d459 | |||
| 9945ec321b |
+1
-1
@@ -1 +1 @@
|
||||
* @actions/actions-workflow-development-reviewers
|
||||
* @actions/actions-vscode-reviewers
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directories:
|
||||
- "/"
|
||||
- "/languageservice"
|
||||
- "/languageserver"
|
||||
- "expressions"
|
||||
- "browser-playground"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -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'
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
name: Create release PR
|
||||
|
||||
run-name: Create release PR for v${{ github.event.inputs.version }}
|
||||
run-name: Create release PR for new ${{ github.event.inputs.version }} version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
description: "Version to bump `package.json` to (format: x.y.z)"
|
||||
type: choice
|
||||
description: "What type of release is this"
|
||||
options:
|
||||
- "major"
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
jobs:
|
||||
create-release-pr:
|
||||
@@ -20,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"
|
||||
|
||||
@@ -31,21 +36,27 @@ jobs:
|
||||
git config --global user.email "github-actions@github.com"
|
||||
git config --global user.name "GitHub Actions"
|
||||
|
||||
git checkout -b release/${{ inputs.version }}
|
||||
NEW_VERSION=$(./script/workflows/increment-version.sh ${{ inputs.version }})
|
||||
|
||||
npx lerna version ${{ inputs.version }} --yes --no-push --no-git-tag-version --force-publish
|
||||
git checkout -b release/$NEW_VERSION
|
||||
|
||||
npx lerna version $NEW_VERSION --yes --no-push --no-git-tag-version --force-publish
|
||||
|
||||
git add **/package.json package-lock.json lerna.json
|
||||
git commit -m "Release extension version ${{ inputs.version }}"
|
||||
git commit -m "Release extension version $NEW_VERSION"
|
||||
|
||||
git push --set-upstream origin release/${{ inputs.version }}
|
||||
git push --set-upstream origin release/$NEW_VERSION
|
||||
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Create PR
|
||||
run: |
|
||||
LAST_PR=$(gh pr list --repo ${{ github.repository }} --limit 1 --state merged --search "Release version" --json number | jq -r '.[0].number')
|
||||
./script/workflows/generate-release-notes.sh $LAST_PR ${{ env.new_version }}
|
||||
gh pr create \
|
||||
--title "Release version ${{ inputs.version }}" \
|
||||
--body "Release version ${{ inputs.version }}" \
|
||||
--title "Release version ${{ env.new_version }}" \
|
||||
--body-file releasenotes.md \
|
||||
--base main \
|
||||
--head release/${{ inputs.version }}
|
||||
--head release/${{ env.new_version }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
*/node_modules
|
||||
*/dist
|
||||
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
+152
@@ -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" }`)
|
||||
@@ -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 we’re working on and what stage they’re 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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.4",
|
||||
"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": {
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import "../dist/cli.bundle.cjs";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.4",
|
||||
"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.4",
|
||||
"@actions/workflow-parser": "^0.3.4",
|
||||
"@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",
|
||||
|
||||
Executable
+10
@@ -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
|
||||
@@ -1,8 +1,9 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
|
||||
export function getClient(token: string, userAgent?: string): Octokit {
|
||||
export function getClient(token: string, userAgent?: string, apiUrl?: string): Octokit {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
userAgent: userAgent || `GitHub Actions Language Server`
|
||||
userAgent: userAgent || `GitHub Actions Language Server`,
|
||||
baseUrl: apiUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -51,7 +54,7 @@ export function initConnection(connection: Connection) {
|
||||
const options = params.initializationOptions as InitializationOptions;
|
||||
|
||||
if (options.sessionToken) {
|
||||
client = getClient(options.sessionToken, options.userAgent);
|
||||
client = getClient(options.sessionToken, options.userAgent, options.gitHubApiUrl);
|
||||
}
|
||||
|
||||
if (options.repos) {
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function getSecrets(
|
||||
}
|
||||
|
||||
const eventsConfig = workflowContext?.template?.events;
|
||||
|
||||
if (eventsConfig?.workflow_call) {
|
||||
// Unpredictable secrets may be passed in via a workflow_call trigger
|
||||
secretsContext.complete = false;
|
||||
@@ -38,6 +39,7 @@ export async function getSecrets(
|
||||
}
|
||||
|
||||
let environmentName: string | undefined;
|
||||
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
environmentName = workflowContext.job.environment.value;
|
||||
@@ -46,10 +48,17 @@ export async function getSecrets(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic enviornment, in those situations we
|
||||
// want to make sure we skip doing secret validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,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))
|
||||
@@ -142,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
|
||||
},
|
||||
|
||||
@@ -99,13 +99,13 @@ it("adds action outputs", async () => {
|
||||
key: "conclusion",
|
||||
value: new data.Null(),
|
||||
description:
|
||||
"The result of a completed step after `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
"The result of a completed step after [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
},
|
||||
{
|
||||
key: "outcome",
|
||||
value: new data.Null(),
|
||||
description:
|
||||
"The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
"The result of a completed step before [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,9 +2,10 @@ import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {Pair} from "@actions/expressions/data/expressiondata";
|
||||
import {StringData} from "@actions/expressions/data/index";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {warn} from "@actions/languageservice/log";
|
||||
import {log, warn} from "@actions/languageservice/log";
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RequestError} from "@octokit/request-error";
|
||||
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
@@ -42,50 +43,58 @@ export async function getVariables(
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
try {
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
|
||||
// Build combined map of variables
|
||||
const variablesMap = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: data.StringData;
|
||||
description?: string;
|
||||
}
|
||||
>();
|
||||
// Build combined map of variables
|
||||
const variablesMap = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: data.StringData;
|
||||
description?: string;
|
||||
}
|
||||
>();
|
||||
|
||||
variables.organizationVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Organization variable`
|
||||
})
|
||||
);
|
||||
variables.organizationVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Organization variable`
|
||||
})
|
||||
);
|
||||
|
||||
// Override org variables with repo variables
|
||||
variables.repoVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Repository variable`
|
||||
})
|
||||
);
|
||||
// Override org variables with repo variables
|
||||
variables.repoVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Repository variable`
|
||||
})
|
||||
);
|
||||
|
||||
// Override repo variables with environment veriables (if defined)
|
||||
variables.environmentVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Variable for environment \`${environmentName || ""}\``
|
||||
})
|
||||
);
|
||||
// Override repo variables with environment veriables (if defined)
|
||||
variables.environmentVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Variable for environment \`${environmentName || ""}\``
|
||||
})
|
||||
);
|
||||
|
||||
// Sort variables by key and add to context
|
||||
Array.from(variablesMap.values())
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description));
|
||||
// Sort variables by key and add to context
|
||||
Array.from(variablesMap.values())
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description));
|
||||
|
||||
return variablesContext;
|
||||
return variablesContext;
|
||||
} catch (e) {
|
||||
if (!(e instanceof RequestError)) throw e;
|
||||
if (e.name == "HttpError" && e.status == 404) {
|
||||
log("Failure to request variables. Ignore if you're using GitHub Enterprise Server below version 3.8");
|
||||
return variablesContext;
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemoteVariables(
|
||||
@@ -106,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, () =>
|
||||
@@ -137,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,7 +3,7 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import vscodeURI from "vscode-uri/lib/umd";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
|
||||
export function getFileProvider(
|
||||
client: Octokit | undefined,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface InitializationOptions {
|
||||
* Desired log level
|
||||
*/
|
||||
logLevel?: LogLevel;
|
||||
|
||||
/**
|
||||
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
||||
*/
|
||||
gitHubApiUrl?: string;
|
||||
}
|
||||
|
||||
export interface RepositoryContext {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.20",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -44,11 +44,11 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.4",
|
||||
"@actions/workflow-parser": "^0.3.4",
|
||||
"@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.7",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
Vendored
+7
@@ -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"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
restore-keys: ${{ runner.os }}-
|
||||
path: ""
|
||||
key: ""
|
||||
+8
@@ -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 }}-
|
||||
+9
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ describe("expressions", () => {
|
||||
label: "api_url",
|
||||
documentation: {
|
||||
kind: "markdown",
|
||||
value: "The URL of the GitHub Actions REST API."
|
||||
value: "The URL of the GitHub REST API."
|
||||
},
|
||||
kind: CompletionItemKind.Variable
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import {complete} from "./complete";
|
||||
import {registerLogger} from "./log";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
import {TestLogger} from "./test-utils/logger";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
@@ -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},
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
expect(result.map(e => e.label)).toContain("runs-on");
|
||||
|
||||
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
|
||||
expect(textEdit.newText).toEqual("runs-on");
|
||||
expect(textEdit.newText).toEqual("runs-on: ");
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 3, character: 4},
|
||||
end: {line: 3, character: 10}
|
||||
@@ -421,7 +421,7 @@ jobs:
|
||||
expect(result.map(e => e.label)).toContain("runs-on");
|
||||
|
||||
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
|
||||
expect(textEdit.newText).toEqual("runs-on");
|
||||
expect(textEdit.newText).toEqual("runs-on: ");
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 3, character: 4},
|
||||
end: {line: 3, character: 4}
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
});
|
||||
|
||||
it("custom indentation", async () => {
|
||||
@@ -471,11 +471,11 @@ jobs:
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a new line and indentation for mapping keys", async () => {
|
||||
it("adds a new line and indentation for mapping keys when the key is given", async () => {
|
||||
const input = "concurrency: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
@@ -485,4 +485,36 @@ jobs:
|
||||
]);
|
||||
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
|
||||
});
|
||||
|
||||
it("does not add new line if no key in line", async () => {
|
||||
const input = "run-n|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
||||
});
|
||||
|
||||
it("adds new line for nested mapping", async () => {
|
||||
const input = "on:\n workflow_dispatch: in|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
|
||||
});
|
||||
|
||||
it("adds : for one-of", async () => {
|
||||
const input = "on:\n check_run:\n ty|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
|
||||
});
|
||||
|
||||
it("does not add : for one-of in key mode", async () => {
|
||||
const input = "on:\n check_run: ty|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {isPlaceholder, transform} from "./utils/transform";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
import {definitionValues} from "./value-providers/definition";
|
||||
import {DefinitionValueMode, definitionValues} from "./value-providers/definition";
|
||||
|
||||
export function getExpressionInput(input: string, pos: number): string {
|
||||
// Find start marker around the cursor position
|
||||
@@ -180,7 +180,7 @@ async function getValues(
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = definitionValues(def, indentation);
|
||||
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,15 +49,15 @@
|
||||
"description": "Returns `true` when any previous step of a job fails. If you have a chain of dependent jobs, `failure()` returns `true` if any ancestor job fails."
|
||||
},
|
||||
"hashFiles": {
|
||||
"description": "Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`."
|
||||
"description": "Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`. This function calculates an individual SHA-256 hash for each matched file, and then uses those hashes to calculate a final SHA-256 hash for the set of files. If the `path` pattern does not match any files, this returns an empty string. For more information about SHA-256, see \"[SHA-2](https://wikipedia.org/wiki/SHA-2).\"\n\nYou can use pattern matching characters to match file names. Pattern matching is case-insensitive on Windows. For more information about supported pattern matching characters, see \"[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet).\""
|
||||
}
|
||||
},
|
||||
"github": {
|
||||
"action": {
|
||||
"description": "The name of the action currently running, or the [`id`](https://docs.github.com/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsid) of a step. GitHub Actions removes special characters, and uses the name `__run` when the current step runs a script without an `id`. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name `__run`, and the second script will be named `__run_2`. Similarly, the second invocation of `actions/checkout` will be `actionscheckout2`."
|
||||
"description": "The name of the action currently running, or the [`id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsid) of a step. GitHub removes special characters, and uses the name `__run` when the current step runs a script without an `id`. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name `__run`, and the second script will be named `__run_2`. Similarly, the second invocation of `actions/checkout` will be `actionscheckout2`."
|
||||
},
|
||||
"action_path": {
|
||||
"description": "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action."
|
||||
"description": "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action, for example by changing directories to the path: `cd ${{ github.action_path }}`."
|
||||
},
|
||||
"action_ref": {
|
||||
"description": "For a step executing an action, this is the ref of the action being executed. For example, `v2`."
|
||||
@@ -71,17 +71,24 @@
|
||||
"actor": {
|
||||
"description": "The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from `github.triggering_actor`. Any workflow re-runs will use the privileges of `github.actor`, even if the actor initiating the re-run (`github.triggering_actor`) has different privileges."
|
||||
},
|
||||
"actor_id": {
|
||||
"description": "The account ID of the person or app that triggered the initial workflow run. For example, `1234567`. Note that this is different from the actor username.",
|
||||
"versions": {
|
||||
"ghes": ">=3.9",
|
||||
"ghae": ">=3.9"
|
||||
}
|
||||
},
|
||||
"api_url": {
|
||||
"description": "The URL of the GitHub Actions REST API."
|
||||
"description": "The URL of the GitHub REST API."
|
||||
},
|
||||
"base_ref": {
|
||||
"description": "The `base_ref` or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either `pull_request` or `pull_request_target`."
|
||||
},
|
||||
"env": {
|
||||
"description": "Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see [Workflow commands](https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#setting-an-environment-variable)."
|
||||
"description": "Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable).\""
|
||||
},
|
||||
"event": {
|
||||
"description": "The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in [Event that trigger workflows](/articles/events-that-trigger-workflows/). For example, for a workflow run triggered by the [`push` event](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows#push), this object contains the contents of the [push webhook payload](https://docs.github.com/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push)."
|
||||
"description": "The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in \"[Events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\" For example, for a workflow run triggered by the [`push` event](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows#push), this object contains the contents of the [push webhook payload](https://docs.github.com/webhooks-and-events/webhooks/webhook-events-and-payloads#push)."
|
||||
},
|
||||
"event_name": {
|
||||
"description": "The name of the event that triggered the workflow run."
|
||||
@@ -90,53 +97,58 @@
|
||||
"description": "The path to the file on the runner that contains the full event webhook payload."
|
||||
},
|
||||
"graphql_url": {
|
||||
"description": "The URL of the GitHub Actions GraphQL API."
|
||||
"description": "The URL of the GitHub GraphQL API."
|
||||
},
|
||||
"head_ref": {
|
||||
"description": "The `head_ref` or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either `pull_request` or `pull_request_target`."
|
||||
},
|
||||
"job": {
|
||||
"description": "The [`job_id`](/actions/reference/workflow-syntax-for-github-actions#jobsjob_id) of the current job. <br /> Note: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
||||
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
||||
},
|
||||
"ref": {
|
||||
"description": "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by `push`, this is the branch or tag ref that was pushed. For workflows triggered by `pull_request`, this is the pull request merge branch. For workflows triggered by `release`, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is `refs/heads/<branch_name>`, for pull requests it is `refs/pull/<pr_number>/merge`, and for tags it is `refs/tags/<tag_name>`. For example, `refs/heads/feature-branch-1`.",
|
||||
"job_workflow_sha": {
|
||||
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
|
||||
"versions": {
|
||||
"ghes": "3.3",
|
||||
"ghae": "3.3"
|
||||
}
|
||||
},
|
||||
"ref_name": {
|
||||
"description": "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, `feature-branch-1`.",
|
||||
"versions": {
|
||||
"ghes": "3.3",
|
||||
"ghae": "3.3"
|
||||
}
|
||||
},
|
||||
"ref_protected": {
|
||||
"description": "`true` if branch protections are configured for the ref that triggered the workflow run.",
|
||||
"versions": {
|
||||
"ghes": "3.3",
|
||||
"ghae": "3.3"
|
||||
}
|
||||
},
|
||||
"ref_type": {
|
||||
"description": "The type of ref that triggered the workflow run. Valid values are `branch` or `tag`.",
|
||||
"versions": {
|
||||
"ghes": "3.3",
|
||||
"ghae": "3.3"
|
||||
"ghes": ">=3.9",
|
||||
"ghae": ">=3.9"
|
||||
}
|
||||
},
|
||||
"path": {
|
||||
"description": "Path on the runner to the file that sets system `PATH` variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see [Workflow commands](https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#adding-a-system-path)."
|
||||
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
|
||||
},
|
||||
"ref": {
|
||||
"description": "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by `push`, this is the branch or tag ref that was pushed. For workflows triggered by `pull_request`, this is the pull request merge branch. For workflows triggered by `release`, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is `refs/heads/<branch_name>`, for pull requests it is `refs/pull/<pr_number>/merge`, and for tags it is `refs/tags/<tag_name>`. For example, `refs/heads/feature-branch-1`."
|
||||
},
|
||||
"ref_name": {
|
||||
"description": "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, `feature-branch-1`."
|
||||
},
|
||||
"ref_protected": {
|
||||
"description": "`true` if branch protections are configured for the ref that triggered the workflow run."
|
||||
},
|
||||
"ref_type": {
|
||||
"description": "The type of ref that triggered the workflow run. Valid values are `branch` or `tag`."
|
||||
},
|
||||
"repository": {
|
||||
"description": "The owner and repository name. For example, `Codertocat/Hello-World`."
|
||||
"description": "The owner and repository name. For example, `octocat/Hello-World`."
|
||||
},
|
||||
"repository_id": {
|
||||
"description": "The ID of the repository. For example, `123456789`. Note that this is different from the repository name.",
|
||||
"versions": {
|
||||
"ghes": ">=3.9",
|
||||
"ghae": ">=3.9"
|
||||
}
|
||||
},
|
||||
"repository_owner": {
|
||||
"description": "The repository owner's name. For example, `Codertocat`."
|
||||
"description": "The repository owner's username. For example, `octocat`."
|
||||
},
|
||||
"repository_owner_id": {
|
||||
"description": "The repository owner's account ID. For example, `1234567`. Note that this is different from the owner's name.",
|
||||
"versions": {
|
||||
"ghes": ">=3.9",
|
||||
"ghae": ">=3.9"
|
||||
}
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"description": "The Git URL to the repository. For example, `git://github.com/codertocat/hello-world.git`."
|
||||
"description": "The Git URL to the repository. For example, `git://github.com/octocat/hello-world.git`."
|
||||
},
|
||||
"retention_days": {
|
||||
"description": "The number of days that workflow run logs and artifacts are kept."
|
||||
@@ -148,27 +160,19 @@
|
||||
"description": "A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run."
|
||||
},
|
||||
"run_attempt": {
|
||||
"description": "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.",
|
||||
"versions": {
|
||||
"ghes": "3.5",
|
||||
"ghae": "3.4"
|
||||
}
|
||||
"description": "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run."
|
||||
},
|
||||
"secret_source": {
|
||||
"description": "The source of a secret used in a workflow. Possible values are `None`, `Actions`, `Dependabot`, or `Codespaces`.",
|
||||
"versions": {
|
||||
"ghes": "3.3",
|
||||
"ghae": "3.3"
|
||||
}
|
||||
"description": "The source of a secret used in a workflow. Possible values are `None`, `Actions`, `Dependabot`, or `Codespaces`."
|
||||
},
|
||||
"server_url": {
|
||||
"description": "The URL of the GitHub server. For example: `https://github.com`."
|
||||
},
|
||||
"sha": {
|
||||
"description": "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see [Events that trigger workflows.](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows) For example, `ffac537e6cbbf934b08745a378932722df287a53`."
|
||||
"description": "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see \"[Events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\" For example, `ffac537e6cbbf934b08745a378932722df287a53`."
|
||||
},
|
||||
"token": {
|
||||
"description": "A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the `GITHUB_TOKEN` secret. For more information, see [Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
||||
"description": "A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the `GITHUB_TOKEN` secret. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\"\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
||||
},
|
||||
"triggering_actor": {
|
||||
"description": "The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from `github.actor`. Any workflow re-runs will use the privileges of `github.actor`, even if the actor initiating the re-run (`github.triggering_actor`) has different privileges."
|
||||
@@ -176,13 +180,27 @@
|
||||
"workflow": {
|
||||
"description": "The name of the workflow. If the workflow file doesn't specify a `name`, the value of this property is the full path of the workflow file in the repository."
|
||||
},
|
||||
"workflow_ref": {
|
||||
"description": "The ref path to the workflow. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`.",
|
||||
"versions": {
|
||||
"ghes": ">=3.9",
|
||||
"ghae": ">=3.9"
|
||||
}
|
||||
},
|
||||
"workflow_sha": {
|
||||
"description": "The commit SHA for the workflow file.",
|
||||
"versions": {
|
||||
"ghes": ">=3.9",
|
||||
"ghae": ">=3.9"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"GITHUB_TOKEN": {
|
||||
"description": "`GITHUB_TOKEN` is a secret that is automatically created for every workflow run, and is always included in the secrets context. For more information, see [Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication)."
|
||||
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
|
||||
}
|
||||
},
|
||||
"jobs": {
|
||||
@@ -195,13 +213,13 @@
|
||||
},
|
||||
"steps": {
|
||||
"outputs": {
|
||||
"description": "The set of outputs defined for the step."
|
||||
"description": "The set of outputs defined for the step. For more information, see \"[Metadata syntax for GitHub Actions](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions).\""
|
||||
},
|
||||
"conclusion": {
|
||||
"description": "The result of a completed step after `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
"description": "The result of a completed step after [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
},
|
||||
"outcome": {
|
||||
"description": "The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
"description": "The result of a completed step before [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
}
|
||||
},
|
||||
"runner": {
|
||||
@@ -218,24 +236,24 @@
|
||||
"description": "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them."
|
||||
},
|
||||
"tool_cache": {
|
||||
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software)\"."
|
||||
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
|
||||
},
|
||||
"debug": {
|
||||
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of 1. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
"fail-fast": {
|
||||
"description": "The `fail-fast` setting for the job. Possible values are `true` or `false`. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.fail-fast`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast)."
|
||||
},
|
||||
"max-parallel": {
|
||||
"description": "The `max-parallel` setting for the job. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.max-parallel`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel)."
|
||||
"description": "When `true`, all in-progress jobs are canceled if any job in a matrix fails. For more information, see \"[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast).\""
|
||||
},
|
||||
"job-index": {
|
||||
"description": "The index of the current job in the matrix. **Note:** This number is a zero-based number. The first job's index in the matrix is `0`."
|
||||
},
|
||||
"job-total": {
|
||||
"description": "The total number of jobs in the matrix. **Note:** This number **is not** a zero-based number. For example, for a matrix with four jobs, the value of `job-total` is `4`."
|
||||
},
|
||||
"max-parallel": {
|
||||
"description": "The maximum number of jobs that can run simultaneously when using a matrix job strategy. For more information, see \"[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel).\""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
|
||||
import {getInputsContext} from "./inputs";
|
||||
|
||||
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-cwontext
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
const keys = [
|
||||
"action",
|
||||
"action_path",
|
||||
@@ -16,6 +16,7 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
"action_repository",
|
||||
"action_status",
|
||||
"actor",
|
||||
"actor_id",
|
||||
"api_url",
|
||||
"base_ref",
|
||||
"env",
|
||||
@@ -25,13 +26,16 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
"graphql_url",
|
||||
"head_ref",
|
||||
"job",
|
||||
"job_workflow_sha",
|
||||
"path",
|
||||
"ref",
|
||||
"ref_name",
|
||||
"ref_protected",
|
||||
"ref_type",
|
||||
"path",
|
||||
"repository",
|
||||
"repository_id",
|
||||
"repository_owner",
|
||||
"repository_owner_id",
|
||||
"repositoryUrl",
|
||||
"retention_days",
|
||||
"run_id",
|
||||
@@ -43,6 +47,8 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
"token",
|
||||
"triggering_actor",
|
||||
"workflow",
|
||||
"workflow_ref",
|
||||
"workflow_sha",
|
||||
"workspace"
|
||||
];
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {parseFileReference} from "@actions/workflow-parser/workflows/file-reference";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {DocumentLink} from "vscode-languageserver-types";
|
||||
import vscodeURI from "vscode-uri/lib/umd"; // work around issues with the vscode-uri package
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
import {actionUrl, parseActionReference} from "./action";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
expect(result).toEqual<Hover>({
|
||||
contents:
|
||||
"Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`.",
|
||||
'Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`. This function calculates an individual SHA-256 hash for each matched file, and then uses those hashes to calculate a final SHA-256 hash for the set of files. If the `path` pattern does not match any files, this returns an empty string. For more information about SHA-256, see "[SHA-2](https://wikipedia.org/wiki/SHA-2)."\n\nYou can use pattern matching characters to match file names. Pattern matching is case-insensitive on Windows. For more information about supported pattern matching characters, see "[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet)."',
|
||||
range: {
|
||||
start: {line: 5, character: 22},
|
||||
end: {line: 5, character: 31}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,15 +9,30 @@ import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-sch
|
||||
import {Value} from "./config";
|
||||
import {stringsToValues} from "./strings-to-values";
|
||||
|
||||
export function definitionValues(def: Definition, indentation: string): Value[] {
|
||||
export enum DefinitionValueMode {
|
||||
/**
|
||||
* We're getting completion options for a parent token
|
||||
* foo:
|
||||
* ba|
|
||||
*/
|
||||
Parent,
|
||||
|
||||
/**
|
||||
* We're getting completion options for a key token. For example:
|
||||
* foo: |
|
||||
*/
|
||||
Key
|
||||
}
|
||||
|
||||
export function definitionValues(def: Definition, indentation: string, mode: DefinitionValueMode): Value[] {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
if (def instanceof MappingDefinition) {
|
||||
return mappingValues(def, schema.definitions, indentation);
|
||||
return mappingValues(def, schema.definitions, indentation, mode);
|
||||
}
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
return oneOfValues(def, schema.definitions, indentation);
|
||||
return oneOfValues(def, schema.definitions, indentation, mode);
|
||||
}
|
||||
|
||||
if (def instanceof BooleanDefinition) {
|
||||
@@ -36,7 +51,7 @@ export function definitionValues(def: Definition, indentation: string): Value[]
|
||||
if (def instanceof SequenceDefinition) {
|
||||
const itemDef = schema.getDefinition(def.itemType);
|
||||
if (itemDef) {
|
||||
return definitionValues(itemDef, indentation);
|
||||
return definitionValues(itemDef, indentation, mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +61,8 @@ export function definitionValues(def: Definition, indentation: string): Value[]
|
||||
function mappingValues(
|
||||
mappingDefinition: MappingDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode
|
||||
): Value[] {
|
||||
const properties: Value[] = [];
|
||||
for (const [key, value] of Object.entries(mappingDefinition.properties)) {
|
||||
@@ -60,21 +76,38 @@ function mappingValues(
|
||||
if (typeDef) {
|
||||
switch (typeDef.definitionType) {
|
||||
case DefinitionType.Sequence:
|
||||
insertText = `${key}:\n${indentation}- `;
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}:\n${indentation}${indentation}- `;
|
||||
} else {
|
||||
insertText = `${key}:\n${indentation}- `;
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.Mapping:
|
||||
insertText = `${key}:\n${indentation}`;
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}:\n${indentation}${indentation}`;
|
||||
} else {
|
||||
insertText = `${key}:\n${indentation}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.OneOf:
|
||||
// No special insertText in this case
|
||||
if (mode == DefinitionValueMode.Parent) {
|
||||
insertText = `${key}: `;
|
||||
} else {
|
||||
// No special insertText in this case
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.String:
|
||||
case DefinitionType.Boolean:
|
||||
insertText = `\n${indentation}${key}: `;
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}: `;
|
||||
} else {
|
||||
insertText = `${key}: `;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
insertText = `${key}: `;
|
||||
}
|
||||
@@ -93,11 +126,12 @@ function mappingValues(
|
||||
function oneOfValues(
|
||||
oneOfDefinition: OneOfDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode
|
||||
): Value[] {
|
||||
const values: Value[] = [];
|
||||
for (const key of oneOfDefinition.oneOf) {
|
||||
values.push(...definitionValues(definitions[key], indentation));
|
||||
values.push(...definitionValues(definitions[key], indentation, mode));
|
||||
}
|
||||
return distinctValues(values);
|
||||
}
|
||||
|
||||
+8
-3
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.3.4"
|
||||
}
|
||||
"packages": [
|
||||
"expressions",
|
||||
"workflow-parser",
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.20"
|
||||
}
|
||||
Generated
+4659
-2999
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -8,6 +8,6 @@
|
||||
"./languageserver"
|
||||
],
|
||||
"devDependencies": {
|
||||
"lerna": "^6.0.3"
|
||||
"lerna": "^8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# this script is used to generate release notes for a given release
|
||||
# first argument is the pull request id for the last release
|
||||
# the second is the new release number
|
||||
|
||||
# the script then grabs every pull request merged since that pull request
|
||||
# and outputs a string of release notes
|
||||
|
||||
# get the new release number
|
||||
NEW_RELEASE=$2
|
||||
|
||||
echo "Generating release notes for $NEW_RELEASE"
|
||||
|
||||
# get the last release pull request id
|
||||
LAST_RELEASE_PR=$1
|
||||
|
||||
|
||||
|
||||
#get when the last release was merged
|
||||
LAST_RELEASE_MERGED_AT=$(gh pr view $LAST_RELEASE_PR --repo actions/languageservices --json mergedAt | jq -r '.mergedAt')
|
||||
|
||||
CHANGELIST=$(gh pr list --repo actions/languageservices --base main --state merged --json title --search "merged:>$LAST_RELEASE_MERGED_AT -label:no-release")
|
||||
|
||||
# store the release notes in a variable so we can use it later
|
||||
|
||||
echo "Release $NEW_RELEASE" >> releasenotes.md
|
||||
|
||||
echo $CHANGELIST | jq -r '.[].title' | while read line; do
|
||||
echo " - $line" >> releasenotes.md
|
||||
done
|
||||
|
||||
echo " "
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION=$(cat lerna.json | jq -r '.version')
|
||||
|
||||
MAJOR=$(echo $VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $VERSION | cut -d. -f3)
|
||||
|
||||
if [ "$1" == "major" ]; then
|
||||
MAJOR=$((MAJOR+1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [ "$1" == "minor" ]; then
|
||||
MINOR=$((MINOR+1))
|
||||
PATCH=0
|
||||
elif [ "$1" == "patch" ]; then
|
||||
PATCH=$((PATCH+1))
|
||||
else
|
||||
echo "Invalid version type. Use 'major', 'minor' or 'patch'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
echo $NEW_VERSION
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
);
|
||||
```
|
||||
|
||||
`convertWorkflowTemplate` then takes that intermediate representation and converts it to a [`WorkflowTemplate`](./src/workflow-template.ts) object, which is a more convenient representation for working with workflows.
|
||||
`convertWorkflowTemplate` then takes that intermediate representation and converts it to a [`WorkflowTemplate`](./src/model/workflow-template.ts) object, which is a more convenient representation for working with workflows.
|
||||
|
||||
```typescript
|
||||
const workflowTemplate = await convertWorkflowTemplate(result.context, result.value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.4",
|
||||
"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.4",
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
@@ -1183,7 +1232,7 @@
|
||||
]
|
||||
},
|
||||
"workflow-run-activity": {
|
||||
"description": "The types of workflow run activity that trigger the workflow. Suupported activity types: `completed`, `requested`, `in_progress`.",
|
||||
"description": "The types of workflow run activity that trigger the workflow. Supported activity types: `completed`, `requested`, `in_progress`.",
|
||||
"one-of": [
|
||||
"workflow-run-activity-type",
|
||||
"workflow-run-activity-types"
|
||||
@@ -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,14 @@
|
||||
"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."
|
||||
},
|
||||
"checks": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Check runs and check suites."
|
||||
@@ -1539,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."
|
||||
@@ -1696,7 +1771,8 @@
|
||||
"concurrency": "job-concurrency",
|
||||
"outputs": "job-outputs",
|
||||
"defaults": "job-defaults",
|
||||
"steps": "steps"
|
||||
"steps": "steps",
|
||||
"snapshot": "snapshot"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1766,9 +1842,7 @@
|
||||
"cancelled(0,0)",
|
||||
"success(0,MAX)"
|
||||
],
|
||||
"string": {
|
||||
"is-expression": true
|
||||
}
|
||||
"string": {}
|
||||
},
|
||||
"job-if-result": {
|
||||
"context": [
|
||||
@@ -1840,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": [
|
||||
@@ -2103,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": [
|
||||
@@ -2489,7 +2596,7 @@
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
},
|
||||
"description": "Use `shell` to override the default shell settings in the runner's operating system. You can use built-in shell keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the comands specified in `run`."
|
||||
"description": "Use `shell` to override the default shell settings in the runner's operating system. You can use built-in shell keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the commands specified in `run`."
|
||||
},
|
||||
"working-directory": {
|
||||
"string": {
|
||||
@@ -2510,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'"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+49
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user