Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ffd53f3b | |||
| 7660f61777 | |||
| c04c1b26f4 | |||
| 4429c41275 | |||
| 7b9adb106e | |||
| 576402fc01 | |||
| 22c36bc946 | |||
| 4dd678cf30 | |||
| dfb411f71e | |||
| dec597b0db | |||
| bd7e5f0b70 | |||
| 37ba6ab105 | |||
| 216fcbb8c4 | |||
| 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 | |||
| c9afb14da5 | |||
| fe696132cf | |||
| 026f4e3ece | |||
| 098e785c13 | |||
| b0c2dec02f | |||
| def4fb41a9 | |||
| 84335c7203 | |||
| 7e062aa16b | |||
| b67105b9b4 | |||
| 1b823ebe67 |
@@ -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:
|
||||
@@ -10,18 +12,85 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16.15
|
||||
uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
- run: npm ci --engine-strict
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npm run format-check -ws
|
||||
- run: npm run build -ws
|
||||
- run: npm run lint -ws
|
||||
- run: npm test -ws
|
||||
|
||||
check-generated:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Regenerate JSON files
|
||||
run: |
|
||||
cd languageservice && npm run update-webhooks && cd ..
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "ERROR: Generated files are out of date!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo ""
|
||||
echo " cd languageservice && npm run update-webhooks && cd .."
|
||||
echo " git add -A && git commit -m 'Regenerate JSON files'"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate-webhooks:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate webhook optimization
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build workspaces
|
||||
run: npm run build -ws
|
||||
- name: Generate full webhooks file
|
||||
run: cd languageservice && npm run update-webhooks
|
||||
- name: Run optimization validation tests
|
||||
run: cd languageservice && npm test -- --testPathPattern=eventPayloads
|
||||
- name: Verify validation tests ran
|
||||
run: |
|
||||
if [ ! -f languageservice/src/context-providers/events/webhooks.full.validation-complete ]; then
|
||||
echo "ERROR: Validation tests did not run!"
|
||||
echo "The webhooks.full.validation-complete marker file was not created."
|
||||
exit 1
|
||||
fi
|
||||
echo "Validation tests completed at: $(cat languageservice/src/context-providers/events/webhooks.full.validation-complete)"
|
||||
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "16"
|
||||
|
||||
|
||||
@@ -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,11 +65,11 @@ 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
|
||||
node-version: 22.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
@@ -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");
|
||||
|
||||
+13
-1
@@ -2,4 +2,16 @@
|
||||
*/dist
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
# Optimized workflow schema (generated by optimize-workflow-schema.js)
|
||||
*.optimized.json
|
||||
|
||||
# Full webhooks source (generated by update-webhooks, used for validation tests)
|
||||
*.full.json
|
||||
|
||||
# Validation marker (generated by tests)
|
||||
*.validation-complete
|
||||
|
||||
@@ -8,6 +8,24 @@ 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
|
||||
## Documentation
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
|
||||
|
||||
### Note
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# JSON Data Files
|
||||
|
||||
This document describes the JSON data files used by the language service packages and how they are maintained.
|
||||
|
||||
## Overview
|
||||
|
||||
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
|
||||
|
||||
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped, shared objects are deduplicated, property names are interned
|
||||
2. **Compacted using a space-efficient format** — params use type-based dispatch arrays instead of objects
|
||||
3. **Minified at build time** — whitespace is removed to produce `.min.json` files
|
||||
|
||||
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
|
||||
|
||||
## Files
|
||||
|
||||
### languageservice
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
|
||||
| `src/context-providers/events/webhooks.objects.json` | Deduplicated shared object definitions referenced by webhooks |
|
||||
| `src/context-providers/events/webhooks.strings.json` | Interned property names shared by webhooks and objects |
|
||||
| `src/context-providers/events/schedule.json` | Schedule event context data |
|
||||
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
|
||||
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
|
||||
|
||||
### workflow-parser
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
|
||||
|
||||
## Generation
|
||||
|
||||
### Webhooks and Objects
|
||||
|
||||
The `webhooks.json`, `webhooks.objects.json`, and `webhooks.strings.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
|
||||
|
||||
```bash
|
||||
cd languageservice
|
||||
npm run update-webhooks
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Fetches webhook schemas from the GitHub API description
|
||||
2. **Validates** all events are categorized (fails if new events are found)
|
||||
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
|
||||
4. **Compacts** params into a space-efficient array format, keeping only `name`, `description`, and `childParamsGroups` (see [Compact Format](#compact-format))
|
||||
5. **Deduplicates** shared object definitions into `webhooks.objects.json`
|
||||
6. **Interns** duplicate property names into `webhooks.strings.json` (see [String Interning](#string-interning))
|
||||
7. Writes the optimized, pretty-printed JSON files
|
||||
|
||||
### Handling New Webhook Events
|
||||
|
||||
When GitHub adds a new webhook event, the script will fail with an error like:
|
||||
|
||||
```
|
||||
ERROR: New webhook event(s) detected!
|
||||
|
||||
The following events are not categorized:
|
||||
- new_event_name
|
||||
|
||||
Action required:
|
||||
1. Check if the event is a valid workflow trigger
|
||||
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
|
||||
```
|
||||
|
||||
**To resolve:**
|
||||
|
||||
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
|
||||
|
||||
2. Edit `languageservice/src/context-providers/events/event-filters.json`:
|
||||
- Add to `kept` array if it's a valid workflow trigger
|
||||
- Add to `dropped` array if it's GitHub App or API-only
|
||||
|
||||
3. Run `npm run update-webhooks` and commit the changes
|
||||
|
||||
#### Viewing Full Unprocessed Data
|
||||
|
||||
To see all available fields and events before optimization:
|
||||
|
||||
```bash
|
||||
npm run update-webhooks -- --all
|
||||
```
|
||||
|
||||
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
|
||||
|
||||
### Other Files
|
||||
|
||||
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
|
||||
|
||||
## Minification
|
||||
|
||||
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
|
||||
|
||||
```bash
|
||||
npm run minify-json
|
||||
```
|
||||
|
||||
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
|
||||
|
||||
The code imports the minified versions:
|
||||
|
||||
```ts
|
||||
import webhooks from "./events/webhooks.min.json"
|
||||
import objects from "./events/webhooks.objects.min.json"
|
||||
import strings from "./events/webhooks.strings.min.json"
|
||||
```
|
||||
|
||||
## CI Verification
|
||||
|
||||
CI verifies that generated source files are up-to-date:
|
||||
|
||||
1. Runs `npm run update-webhooks` to regenerate webhooks.json, webhooks.objects.json, and webhooks.strings.json
|
||||
2. Checks for uncommitted changes with `git diff --exit-code`
|
||||
|
||||
The `.min.json` files are generated at build time and are not committed to the repository.
|
||||
|
||||
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
|
||||
|
||||
## Dropped Events
|
||||
|
||||
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
|
||||
|
||||
See `dropped` array in `src/context-providers/events/event-filters.json` for the full list.
|
||||
|
||||
## Compact Format
|
||||
|
||||
Params are converted from verbose objects into compact arrays, keeping only the fields needed for autocompletion and hover docs (`name`, `description`, `childParamsGroups`). Unused fields like `type`, `in`, `isRequired`, `enum`, and `default` are discarded.
|
||||
|
||||
| Format | Meaning |
|
||||
|--------|---------|
|
||||
| `"name"` | Name only (no description, no children) |
|
||||
| `[name, desc]` | Name + description (arr[1] is a string) |
|
||||
| `[name, children]` | Name + children (arr[1] is an array) |
|
||||
| `[name, desc, children]` | Name + description + children |
|
||||
|
||||
The reader uses `typeof arr[1]` to determine the format: if it's a string, it's a description; if it's an array, it's children.
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
// Before (object format)
|
||||
{
|
||||
"name": "issue",
|
||||
"description": "The issue itself.",
|
||||
"childParamsGroups": [
|
||||
{ "name": "id" },
|
||||
{ "name": "title", "description": "Issue title" }
|
||||
]
|
||||
}
|
||||
|
||||
// After (compact format)
|
||||
["issue", "The issue itself.", [
|
||||
"id",
|
||||
["title", "Issue title"]
|
||||
]]
|
||||
```
|
||||
|
||||
## String Interning
|
||||
|
||||
Property names that appear 2+ times are "interned" into a shared string table (`webhooks.strings.json`). In the compact arrays, these names are replaced with non-negative numeric indices:
|
||||
|
||||
```json
|
||||
// webhooks.strings.json
|
||||
["url", "id", "name", ...] // Index 0 = "url", 1 = "id", 2 = "name"
|
||||
|
||||
// webhooks.json - uses indices instead of strings
|
||||
{
|
||||
"push": {
|
||||
"default": {
|
||||
"p": [
|
||||
[0, "The URL..."], // 0 = "url" from string table
|
||||
[1, "Unique ID"], // 1 = "id"
|
||||
2 // 2 = "name" (name-only, no description)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How to distinguish indices from other values:**
|
||||
|
||||
- **Negative numbers** → Object indices: `-1` = object 0, `-2` = object 1, etc. (formula: `-(index + 1)`)
|
||||
- **Non-negative numbers** → String indices (references into `webhooks.strings.json`)
|
||||
- **Literal strings** → Singletons (names appearing only once, not interned)
|
||||
|
||||
Singletons are kept as literal strings for readability and to avoid the overhead of adding rarely-used names to the string table.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Shared object definitions are extracted into `webhooks.objects.json` and referenced by negative index:
|
||||
|
||||
```json
|
||||
// webhooks.objects.json
|
||||
[
|
||||
["url", "The URL"], // Index 0 (referenced as -1)
|
||||
["id", "Unique identifier"], // Index 1 (referenced as -2)
|
||||
[...]
|
||||
]
|
||||
|
||||
// webhooks.json - negative numbers reference objects
|
||||
{
|
||||
"push": {
|
||||
"default": {
|
||||
"p": [-1, -2, ["ref", "The git ref"]] // -1 = object 0, -2 = object 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This reduces duplication when the same object structure appears in multiple events (e.g., `repository`, `sender`, `organization`).
|
||||
|
||||
## Size Reduction
|
||||
|
||||
The optimizations achieve approximately 99% file size reduction:
|
||||
|
||||
| Stage | Minified | Gzip |
|
||||
|-------|----------|------|
|
||||
| Original (webhooks.full.json) | 15.8 MB | 968 KB |
|
||||
| After optimization (combined) | 152 KB | 15.6 KB |
|
||||
| **Reduction** | **99%** | **98%** |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.23",
|
||||
"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": {
|
||||
@@ -42,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.23",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -43,16 +43,16 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.10",
|
||||
"@actions/workflow-parser": "^0.3.10",
|
||||
"@octokit/rest": "^19.0.7",
|
||||
"@actions/languageservice": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -125,7 +125,7 @@ async function getRemoteSecrets(
|
||||
environmentSecrets:
|
||||
(environmentName &&
|
||||
(await cache.get(`${repo.owner}/${repo.name}/secrets/environment/${environmentName}`, undefined, () =>
|
||||
fetchEnvironmentSecrets(octokit, repo.id, environmentName)
|
||||
fetchEnvironmentSecrets(octokit, repo.owner, repo.name, environmentName)
|
||||
))) ||
|
||||
[],
|
||||
orgSecrets: await cache.get(`${repo.owner}/secrets`, undefined, () => fetchOrganizationSecrets(octokit, repo))
|
||||
@@ -151,14 +151,16 @@ async function fetchSecrets(octokit: Octokit, owner: string, name: string): Prom
|
||||
|
||||
async function fetchEnvironmentSecrets(
|
||||
octokit: Octokit,
|
||||
repositoryId: number,
|
||||
owner: string,
|
||||
name: string,
|
||||
environmentName: string
|
||||
): Promise<StringData[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listEnvironmentSecrets,
|
||||
{
|
||||
repository_id: repositoryId,
|
||||
owner,
|
||||
repo: name,
|
||||
environment_name: environmentName,
|
||||
per_page: 100
|
||||
},
|
||||
|
||||
@@ -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`."
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function getRemoteVariables(
|
||||
environmentVariables:
|
||||
(environmentName &&
|
||||
(await cache.get(`${repo.owner}/${repo.name}/vars/environment/${environmentName}`, undefined, () =>
|
||||
fetchEnvironmentVariables(octokit, repo.id, environmentName)
|
||||
fetchEnvironmentVariables(octokit, repo.owner, repo.name, environmentName)
|
||||
))) ||
|
||||
[],
|
||||
organizationVariables: await cache.get(`${repo.owner}/vars`, undefined, () =>
|
||||
@@ -146,14 +146,16 @@ async function fetchVariables(octokit: Octokit, owner: string, name: string): Pr
|
||||
|
||||
async function fetchEnvironmentVariables(
|
||||
octokit: Octokit,
|
||||
repositoryId: number,
|
||||
owner: string,
|
||||
name: string,
|
||||
environmentName: string
|
||||
): Promise<Pair[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listEnvironmentVariables,
|
||||
{
|
||||
repository_id: repositoryId,
|
||||
owner: owner,
|
||||
repo: name,
|
||||
environment_name: environmentName,
|
||||
per_page: 100
|
||||
},
|
||||
|
||||
@@ -3,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.23",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -37,22 +37,25 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/webhooks.objects.json src/context-providers/events/webhooks.strings.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
|
||||
"update-webhooks": "npx tsx script/webhooks/update-webhooks.ts",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.10",
|
||||
"@actions/workflow-parser": "^0.3.10",
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"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": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import Webhook from "./webhook";
|
||||
|
||||
/**
|
||||
* Get the name from a param.
|
||||
* Formats: "name" (string), or [name, ...] (array)
|
||||
*/
|
||||
function getParamName(param: any): string {
|
||||
if (typeof param === "string") {
|
||||
return param;
|
||||
}
|
||||
if (Array.isArray(param)) {
|
||||
return param[0];
|
||||
}
|
||||
return param.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get params from a webhook action.
|
||||
* Uses 'p' (short key) if present, falls back to 'bodyParameters'
|
||||
*/
|
||||
function getParams(webhook: any): any[] {
|
||||
return webhook.p || webhook.bodyParameters || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set params on a webhook action using the short key 'p'
|
||||
*/
|
||||
function setParams(webhook: any, params: any[]): void {
|
||||
if (webhook.p !== undefined) {
|
||||
webhook.p = params;
|
||||
} else {
|
||||
webhook.bodyParameters = params;
|
||||
}
|
||||
}
|
||||
|
||||
// Store any repeated body parameters in an array
|
||||
// and replace them in the webhook with an index in the array
|
||||
export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webhook>>): any[] {
|
||||
@@ -10,10 +43,11 @@ export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webh
|
||||
const objectCount: Record<string, number> = {};
|
||||
|
||||
for (const webhook of iterateWebhooks(webhooks)) {
|
||||
for (const param of webhook.bodyParameters) {
|
||||
objectsByName[param.name] ||= [];
|
||||
const index = findOrAdd(param, objectsByName[param.name]);
|
||||
const key = `${param.name}:${index}`;
|
||||
for (const param of getParams(webhook)) {
|
||||
const name = getParamName(param);
|
||||
objectsByName[name] ||= [];
|
||||
const index = findOrAdd(param, objectsByName[name]);
|
||||
const key = `${name}:${index}`;
|
||||
objectCount[key] ||= 0;
|
||||
objectCount[key]++;
|
||||
}
|
||||
@@ -27,18 +61,19 @@ export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webh
|
||||
|
||||
for (const webhook of iterateWebhooks(webhooks)) {
|
||||
const newParams: any[] = [];
|
||||
for (const param of webhook.bodyParameters) {
|
||||
const index = find(param, objectsByName[param.name]);
|
||||
const key = `${param.name}:${index}`;
|
||||
for (const param of getParams(webhook)) {
|
||||
const name = getParamName(param);
|
||||
const index = find(param, objectsByName[name]);
|
||||
const key = `${name}:${index}`;
|
||||
if (objectCount[key] > 1) {
|
||||
newParams.push(indexForParam(param, index, bodyParamIndexMap, duplicatedBodyParams));
|
||||
newParams.push(indexForParam(param, name, index, bodyParamIndexMap, duplicatedBodyParams));
|
||||
} else {
|
||||
// If an object is only used once, keep it inline
|
||||
newParams.push(param);
|
||||
}
|
||||
}
|
||||
|
||||
webhook.bodyParameters = newParams;
|
||||
setParams(webhook, newParams);
|
||||
}
|
||||
|
||||
return duplicatedBodyParams;
|
||||
@@ -74,11 +109,12 @@ function find(param: any, objects: any[]): number {
|
||||
|
||||
function indexForParam(
|
||||
param: any,
|
||||
paramName: string,
|
||||
paramNameIndex: number,
|
||||
objectIndexMap: Record<string, number>,
|
||||
duplicatedBodyParams: any[]
|
||||
): number {
|
||||
const key = `${param.name}:${paramNameIndex}`;
|
||||
const key = `${paramName}:${paramNameIndex}`;
|
||||
|
||||
const existingIndex = objectIndexMap[key];
|
||||
if (existingIndex !== undefined) {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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 {deduplicateWebhooks} from "./deduplicate.js";
|
||||
const schema = schemaImport as any;
|
||||
|
||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
||||
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
|
||||
|
||||
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
||||
if (!rawWebhooks) {
|
||||
throw new Error("No webhooks found in schema");
|
||||
}
|
||||
|
||||
const webhooks: Webhook[] = [];
|
||||
for (const webhook of Object.values(rawWebhooks)) {
|
||||
webhooks.push(new Webhook(webhook.post));
|
||||
}
|
||||
|
||||
await Promise.all(webhooks.map(webhook => webhook.process()));
|
||||
|
||||
// The category is the name of the webhook
|
||||
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
if (!webhook.action) webhook.action = "default";
|
||||
|
||||
if (categorizedWebhooks[webhook.category]) {
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
} else {
|
||||
categorizedWebhooks[webhook.category] = {};
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
}
|
||||
}
|
||||
|
||||
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
|
||||
|
||||
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
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";
|
||||
import {deduplicateWebhooks} from "./deduplicate.js";
|
||||
import eventFilters from "../../src/context-providers/events/event-filters.json";
|
||||
const schema = schemaImport as any;
|
||||
|
||||
const DROPPED_EVENTS = new Set(eventFilters.dropped);
|
||||
const KEPT_EVENTS = new Set(eventFilters.kept);
|
||||
|
||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
||||
const OBJECTS_PATH = "./src/context-providers/events/webhooks.objects.json";
|
||||
const STRINGS_PATH = "./src/context-providers/events/webhooks.strings.json";
|
||||
const FULL_OUTPUT_PATH = "./src/context-providers/events/webhooks.full.json";
|
||||
|
||||
/**
|
||||
* Fields discarded from each event action object (top level only).
|
||||
* Body parameters are compacted to only keep name, description, and childParamsGroups.
|
||||
*/
|
||||
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category", "action"];
|
||||
|
||||
/**
|
||||
* Convert a bodyParameter object to compact array format.
|
||||
*
|
||||
* Format (type-based dispatch):
|
||||
* - "name" - name only (just a string)
|
||||
* - [name, desc] - name + description (desc is string)
|
||||
* - [name, [...children]] - name + children (arr[1] is array)
|
||||
* - [name, desc, [...children]] - name + description + children
|
||||
*
|
||||
* The reader uses typeof to determine the meaning:
|
||||
* - string -> name only
|
||||
* - array with string arr[1] -> name + description
|
||||
* - array with array arr[1] -> name + children
|
||||
*/
|
||||
function compactParam(param: any): any {
|
||||
if (typeof param !== "object" || param === null) {
|
||||
return param;
|
||||
}
|
||||
|
||||
const name: string = param.name;
|
||||
const desc: string | undefined = param.description;
|
||||
const children: any[] | undefined = param.childParamsGroups;
|
||||
|
||||
const hasDesc = desc && desc.length > 0;
|
||||
const hasChildren = children && children.length > 0;
|
||||
|
||||
if (hasDesc && hasChildren) {
|
||||
return [name, desc, children.map(compactParam)];
|
||||
} else if (hasChildren) {
|
||||
return [name, children.map(compactParam)];
|
||||
} else if (hasDesc) {
|
||||
return [name, desc];
|
||||
} else {
|
||||
return name; // Just the string, not wrapped in array
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert event action data to compact format.
|
||||
*/
|
||||
function compactEventAction(action: any): any {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(action)) {
|
||||
if (EVENT_ACTION_FIELDS.includes(key)) {
|
||||
continue; // Discard this field
|
||||
}
|
||||
if (key === "bodyParameters" && Array.isArray(value)) {
|
||||
// Use short key 'p' for params
|
||||
result["p"] = value.map((p: any) => (typeof p === "number" ? p : compactParam(p)));
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all webhooks to compact format.
|
||||
* Structure: { eventName: { actionName: { ...fields } } }
|
||||
*/
|
||||
function compactWebhooks(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
|
||||
const result: Record<string, Record<string, any>> = {};
|
||||
for (const [eventName, actions] of Object.entries(webhooks)) {
|
||||
result[eventName] = {};
|
||||
for (const [actionName, actionData] of Object.entries(actions)) {
|
||||
result[eventName][actionName] = compactEventAction(actionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
||||
if (!rawWebhooks) {
|
||||
throw new Error("No webhooks found in schema");
|
||||
}
|
||||
|
||||
const webhooks: Webhook[] = [];
|
||||
for (const webhook of Object.values(rawWebhooks)) {
|
||||
webhooks.push(new Webhook(webhook.post));
|
||||
}
|
||||
|
||||
await Promise.all(webhooks.map(webhook => webhook.process()));
|
||||
|
||||
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
|
||||
const unknownEvents: string[] = [];
|
||||
for (const webhook of webhooks) {
|
||||
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
|
||||
if (!unknownEvents.includes(webhook.category)) {
|
||||
unknownEvents.push(webhook.category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownEvents.length > 0) {
|
||||
console.error("");
|
||||
console.error("══════════════════════════════════════════════════════════════════");
|
||||
console.error("ERROR: New webhook event(s) detected!");
|
||||
console.error("══════════════════════════════════════════════════════════════════");
|
||||
console.error("");
|
||||
console.error("The following events are not categorized:");
|
||||
for (const event of unknownEvents.sort()) {
|
||||
console.error(` - ${event}`);
|
||||
}
|
||||
console.error("");
|
||||
console.error("Action required:");
|
||||
console.error(" 1. Check if the event is a valid workflow trigger:");
|
||||
console.error(
|
||||
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
|
||||
);
|
||||
console.error("");
|
||||
console.error(" 2. Add the event to 'dropped' or 'kept' array in:");
|
||||
console.error(" languageservice/src/context-providers/events/event-filters.json");
|
||||
console.error("");
|
||||
console.error(" 3. See docs/json-data-files.md for more details.");
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build full webhooks (all events, no transformations) for validation tests
|
||||
const fullWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
if (!webhook.action) webhook.action = "default";
|
||||
|
||||
if (fullWebhooks[webhook.category]) {
|
||||
fullWebhooks[webhook.category][webhook.action] = webhook;
|
||||
} else {
|
||||
fullWebhooks[webhook.category] = {};
|
||||
fullWebhooks[webhook.category][webhook.action] = webhook;
|
||||
}
|
||||
}
|
||||
|
||||
// Write full version (before any optimizations)
|
||||
await fs.writeFile(FULL_OUTPUT_PATH, JSON.stringify(fullWebhooks, null, 2));
|
||||
console.log(`Wrote ${FULL_OUTPUT_PATH} (${Object.keys(fullWebhooks).length} events, unoptimized)`);
|
||||
|
||||
// The category is the name of the webhook
|
||||
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
if (!webhook.action) webhook.action = "default";
|
||||
|
||||
// Drop unused events
|
||||
if (DROPPED_EVENTS.has(webhook.category)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (categorizedWebhooks[webhook.category]) {
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
} else {
|
||||
categorizedWebhooks[webhook.category] = {};
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to compact format before deduplication
|
||||
const compactedWebhooks = compactWebhooks(categorizedWebhooks);
|
||||
|
||||
// Deduplicate after compacting
|
||||
const objectsArray = deduplicateWebhooks(compactedWebhooks);
|
||||
|
||||
// ============================================================================
|
||||
// String Interning (Phase 3)
|
||||
// ============================================================================
|
||||
// Intern duplicate property names to reduce file size.
|
||||
// Names appearing 2+ times are stored in a string table and referenced by index.
|
||||
// Singleton names stay as literal strings for readability.
|
||||
|
||||
/**
|
||||
* Collect all property names from params (for frequency counting)
|
||||
*/
|
||||
function collectNames(param: any, counts: Map<string, number>): void {
|
||||
if (typeof param === "number") return;
|
||||
if (typeof param === "string") {
|
||||
counts.set(param, (counts.get(param) || 0) + 1);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(param)) {
|
||||
const name = param[0] as string;
|
||||
counts.set(name, (counts.get(name) || 0) + 1);
|
||||
const children = Array.isArray(param[1]) ? param[1] : param[2];
|
||||
if (children) children.forEach((c: any) => collectNames(c, counts));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace duplicate names with indices into the string table.
|
||||
* Object references use negative indices: objectIndex -> -(objectIndex + 1)
|
||||
* String references use non-negative indices: stringIndex -> stringIndex
|
||||
*
|
||||
* @param param - The param to process
|
||||
* @param nameToIndex - Map from name to string table index
|
||||
*/
|
||||
function internNames(param: any, nameToIndex: Map<string, number>): any {
|
||||
// Object reference (already a number from deduplication) -> make negative
|
||||
if (typeof param === "number") return -(param + 1);
|
||||
|
||||
// String -> intern if in table, otherwise keep as literal
|
||||
if (typeof param === "string") {
|
||||
const idx = nameToIndex.get(param);
|
||||
return idx !== undefined ? idx : param;
|
||||
}
|
||||
|
||||
if (Array.isArray(param)) {
|
||||
const name = param[0] as string;
|
||||
const idx = nameToIndex.get(name);
|
||||
const internedName = idx !== undefined ? idx : name;
|
||||
|
||||
// Handle different array formats
|
||||
if (typeof param[1] === "string" && !Array.isArray(param[1])) {
|
||||
// [name, desc] or [name, desc, children]
|
||||
if (param.length === 2) {
|
||||
return [internedName, param[1]];
|
||||
} else {
|
||||
return [internedName, param[1], (param[2] as any[]).map((c: any) => internNames(c, nameToIndex))];
|
||||
}
|
||||
} else if (Array.isArray(param[1])) {
|
||||
// [name, children]
|
||||
return [internedName, param[1].map((c: any) => internNames(c, nameToIndex))];
|
||||
}
|
||||
// Shouldn't happen, but fallback
|
||||
return [internedName, ...param.slice(1)];
|
||||
}
|
||||
return param;
|
||||
}
|
||||
|
||||
// Pass 1: Count all names
|
||||
const nameCounts = new Map<string, number>();
|
||||
objectsArray.forEach((obj: any) => collectNames(obj, nameCounts));
|
||||
for (const event of Object.values(compactedWebhooks)) {
|
||||
for (const action of Object.values(event as Record<string, any>)) {
|
||||
if (action.p) action.p.forEach((p: any) => collectNames(p, nameCounts));
|
||||
}
|
||||
}
|
||||
|
||||
// Build string table from duplicates, sorted by frequency (most common first = smaller indices)
|
||||
const sortedNames = [...nameCounts.entries()].filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
||||
const stringTable = sortedNames.map(([name]) => name);
|
||||
const nameToIndex = new Map(stringTable.map((name, i) => [name, i]));
|
||||
|
||||
console.log(
|
||||
`String table: ${stringTable.length} interned names (${nameCounts.size - stringTable.length} singletons kept inline)`
|
||||
);
|
||||
|
||||
// Pass 2: Intern names in objects and webhooks
|
||||
// Objects use negative indices, strings use non-negative indices
|
||||
const internedObjects = objectsArray.map((obj: any) => internNames(obj, nameToIndex));
|
||||
const internedWebhooks: Record<string, Record<string, any>> = {};
|
||||
for (const [eventName, actions] of Object.entries(compactedWebhooks)) {
|
||||
internedWebhooks[eventName] = {};
|
||||
for (const [actionName, actionData] of Object.entries(actions as Record<string, any>)) {
|
||||
internedWebhooks[eventName][actionName] = {
|
||||
p: actionData.p.map((p: any) => internNames(p, nameToIndex))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Write optimized output with separate string table
|
||||
// Format: webhooks.strings.json has string table, webhooks.json/webhooks.objects.json reference by index
|
||||
const finalOutput = {
|
||||
"//": "Generated file - refer to docs/json-data-files.md for format documentation",
|
||||
...internedWebhooks
|
||||
};
|
||||
|
||||
await fs.writeFile(STRINGS_PATH, JSON.stringify(stringTable, null, 2));
|
||||
await fs.writeFile(OBJECTS_PATH, JSON.stringify(internedObjects, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(finalOutput, null, 2));
|
||||
|
||||
console.log(`Wrote ${STRINGS_PATH} (${stringTable.length} interned strings)`);
|
||||
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(compactedWebhooks).length} events)`);
|
||||
console.log(`Wrote ${OBJECTS_PATH} (${internedObjects.length} objects)`);
|
||||
@@ -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
|
||||
});
|
||||
@@ -1268,7 +1268,7 @@ jobs:
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
b:
|
||||
needs: [a]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -21,7 +21,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
|
|
||||
`;
|
||||
@@ -49,7 +49,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: monalisa
|
||||
|
|
||||
@@ -74,7 +74,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
|
|
||||
`;
|
||||
@@ -102,7 +102,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets: |
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
||||
@@ -117,7 +117,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
envPAT: "myPAT"
|
||||
|
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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.min.json";
|
||||
|
||||
export const RootContext = "root";
|
||||
const FunctionContext = "functions";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"dropped": [
|
||||
"branch_protection_configuration",
|
||||
"code_scanning_alert",
|
||||
"commit_comment",
|
||||
"custom_property",
|
||||
"custom_property_values",
|
||||
"dependabot_alert",
|
||||
"deploy_key",
|
||||
"github_app_authorization",
|
||||
"installation",
|
||||
"installation_repositories",
|
||||
"installation_target",
|
||||
"marketplace_purchase",
|
||||
"member",
|
||||
"membership",
|
||||
"merge_group",
|
||||
"meta",
|
||||
"org_block",
|
||||
"organization",
|
||||
"package",
|
||||
"personal_access_token_request",
|
||||
"ping",
|
||||
"repository",
|
||||
"repository_advisory",
|
||||
"repository_ruleset",
|
||||
"secret_scanning_alert",
|
||||
"secret_scanning_alert_location",
|
||||
"security_advisory",
|
||||
"security_and_analysis",
|
||||
"sponsorship",
|
||||
"star",
|
||||
"team",
|
||||
"team_add"
|
||||
],
|
||||
"kept": [
|
||||
"branch_protection_rule",
|
||||
"check_run",
|
||||
"check_suite",
|
||||
"create",
|
||||
"delete",
|
||||
"deployment",
|
||||
"deployment_status",
|
||||
"discussion",
|
||||
"discussion_comment",
|
||||
"fork",
|
||||
"gollum",
|
||||
"issue_comment",
|
||||
"issues",
|
||||
"label",
|
||||
"milestone",
|
||||
"page_build",
|
||||
"project",
|
||||
"project_card",
|
||||
"project_column",
|
||||
"projects_v2",
|
||||
"projects_v2_item",
|
||||
"public",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"pull_request_review_comment",
|
||||
"pull_request_review_thread",
|
||||
"push",
|
||||
"registry_package",
|
||||
"release",
|
||||
"repository_dispatch",
|
||||
"repository_import",
|
||||
"repository_vulnerability_alert",
|
||||
"status",
|
||||
"watch",
|
||||
"workflow_dispatch",
|
||||
"workflow_job",
|
||||
"workflow_run"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import {existsSync} from "fs";
|
||||
import {fileURLToPath} from "url";
|
||||
import {dirname, join} from "path";
|
||||
import {data, DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
|
||||
import eventFilters from "./event-filters.json";
|
||||
|
||||
const DROPPED_EVENTS = new Set(eventFilters.dropped);
|
||||
|
||||
// Check if full webhooks file exists (generated by npm run update-webhooks)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const fullWebhooksPath = join(__dirname, "webhooks.full.json");
|
||||
const hasFullWebhooks = existsSync(fullWebhooksPath);
|
||||
|
||||
type Param = {
|
||||
name: string;
|
||||
description?: string;
|
||||
childParamsGroups?: Param[];
|
||||
};
|
||||
|
||||
type FullAction = {
|
||||
action?: string;
|
||||
bodyParameters?: Param[];
|
||||
};
|
||||
|
||||
type FullWebhooks = {
|
||||
[event: string]: {
|
||||
[action: string]: FullAction;
|
||||
};
|
||||
};
|
||||
|
||||
describe("eventPayloads", () => {
|
||||
describe("getSupportedEventTypes", () => {
|
||||
it("returns action types for push event", () => {
|
||||
const types = getSupportedEventTypes("push");
|
||||
expect(types).toContain("default");
|
||||
});
|
||||
|
||||
it("returns action types for issues event", () => {
|
||||
const types = getSupportedEventTypes("issues");
|
||||
expect(types.length).toBeGreaterThan(1);
|
||||
expect(types).toContain("opened");
|
||||
expect(types).toContain("closed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventPayload", () => {
|
||||
it("returns payload for push event", () => {
|
||||
const payload = getEventPayload("push", "default");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// Verify common fields exist
|
||||
expect(payload?.get("ref")).toBeDefined();
|
||||
expect(payload?.get("repository")).toBeDefined();
|
||||
expect(payload?.get("sender")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns payload for issues event", () => {
|
||||
const payload = getEventPayload("issues", "opened");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
expect(payload?.get("action")).toBeDefined();
|
||||
expect(payload?.get("issue")).toBeDefined();
|
||||
expect(payload?.get("repository")).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves descriptions for hover documentation", () => {
|
||||
// This test ensures bodyParameters[].description is not stripped
|
||||
// during JSON optimization. The description field is used for hover
|
||||
// documentation in the workflow editor.
|
||||
const payload = getEventPayload("push", "default");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// Get the description for a well-known field
|
||||
// repository should have a description like "A repository on GitHub"
|
||||
const repoDescription = payload?.getDescription("repository");
|
||||
expect(repoDescription).toBeDefined();
|
||||
expect(repoDescription?.length).toBeGreaterThan(0);
|
||||
|
||||
// sender should have a description
|
||||
const senderDescription = payload?.getDescription("sender");
|
||||
expect(senderDescription).toBeDefined();
|
||||
expect(senderDescription?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves childParamsGroups for nested property access", () => {
|
||||
// This test ensures bodyParameters[].childParamsGroups is not stripped
|
||||
// during JSON optimization. childParamsGroups defines nested properties
|
||||
// used for autocompletion like github.event.repository.owner.login
|
||||
const payload = getEventPayload("push", "default");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// repository has nested properties like owner, license, etc.
|
||||
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
|
||||
expect(repository).toBeDefined();
|
||||
|
||||
// repository.owner should exist (nested via childParamsGroups)
|
||||
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
|
||||
expect(owner).toBeDefined();
|
||||
|
||||
// repository.owner.login should exist (deeply nested)
|
||||
const login = owner?.get("login");
|
||||
expect(login).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves name fields for property identification", () => {
|
||||
// This test ensures bodyParameters[].name is not stripped
|
||||
// during JSON optimization. The name field identifies each property.
|
||||
const payload = getEventPayload("issues", "opened");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// Verify well-known property names exist
|
||||
expect(payload?.get("action")).toBeDefined();
|
||||
expect(payload?.get("issue")).toBeDefined();
|
||||
expect(payload?.get("repository")).toBeDefined();
|
||||
expect(payload?.get("sender")).toBeDefined();
|
||||
|
||||
// Verify nested property names work
|
||||
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
|
||||
expect(issue?.get("title")).toBeDefined();
|
||||
expect(issue?.get("number")).toBeDefined();
|
||||
expect(issue?.get("user")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for unknown event", () => {
|
||||
const payload = getEventPayload("not_a_real_event", "default");
|
||||
expect(payload).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Optimization validation tests - only run if webhooks.full.json exists
|
||||
// This file is generated by: npm run update-webhooks
|
||||
// In CI, a separate job runs these tests after generating the file
|
||||
const describeIfFullExists = hasFullWebhooks ? describe : describe.skip;
|
||||
|
||||
// Marker file path - written after validation tests complete
|
||||
const fullValidationMarkerPath = join(__dirname, "webhooks.full.validation-complete");
|
||||
|
||||
describeIfFullExists("optimization validation", () => {
|
||||
let keptWebhooks: FullWebhooks;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Dynamically import the full webhooks file using fs to avoid TS module resolution
|
||||
const {readFileSync} = await import("fs");
|
||||
const fullWebhooks = JSON.parse(readFileSync(fullWebhooksPath, "utf-8")) as FullWebhooks;
|
||||
|
||||
// Filter to only kept events
|
||||
keptWebhooks = {};
|
||||
for (const [event, actions] of Object.entries(fullWebhooks)) {
|
||||
if (!DROPPED_EVENTS.has(event)) {
|
||||
keptWebhooks[event] = actions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Write marker file to prove validation tests ran
|
||||
const {writeFileSync} = await import("fs");
|
||||
writeFileSync(fullValidationMarkerPath, new Date().toISOString());
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a DescriptionDictionary from raw params (same logic as eventPayloads.ts)
|
||||
*/
|
||||
function buildFromParams(params: Param[]): DescriptionDictionary {
|
||||
const d = new DescriptionDictionary();
|
||||
for (const param of params) {
|
||||
if (param.childParamsGroups && param.childParamsGroups.length > 0) {
|
||||
const child = buildFromParams(param.childParamsGroups);
|
||||
d.add(param.name, child, param.description);
|
||||
} else {
|
||||
// Match the behavior in eventPayloads.ts - don't overwrite existing
|
||||
if (!d.get(param.name)) {
|
||||
d.add(param.name, new data.Null(), param.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two DescriptionDictionary structures recursively
|
||||
*/
|
||||
function compareStructures(full: DescriptionDictionary, optimized: DescriptionDictionary, path: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
const fullPairs = full.pairs();
|
||||
const optimizedPairs = optimized.pairs();
|
||||
|
||||
const fullKeys = new Set(fullPairs.map((p: DescriptionPair) => p.key));
|
||||
const optimizedKeys = new Set(optimizedPairs.map((p: DescriptionPair) => p.key));
|
||||
|
||||
// Check for missing keys in optimized
|
||||
for (const key of fullKeys) {
|
||||
if (!optimizedKeys.has(key)) {
|
||||
errors.push(`Missing key in optimized: ${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra keys in optimized
|
||||
for (const key of optimizedKeys) {
|
||||
if (!fullKeys.has(key)) {
|
||||
errors.push(`Extra key in optimized: ${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare descriptions and recurse into nested structures
|
||||
for (const fullPair of fullPairs) {
|
||||
const optimizedValue = optimized.get(fullPair.key);
|
||||
if (optimizedValue === undefined) continue;
|
||||
|
||||
// Compare descriptions
|
||||
const fullDesc = full.getDescription(fullPair.key) ?? "";
|
||||
const optimizedDesc = optimized.getDescription(fullPair.key) ?? "";
|
||||
if (fullDesc !== optimizedDesc) {
|
||||
errors.push(
|
||||
`Description mismatch at ${path}.${fullPair.key}: ` + `full="${fullDesc}" vs optimized="${optimizedDesc}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Recurse into nested dictionaries
|
||||
if (isDescriptionDictionary(fullPair.value) && isDescriptionDictionary(optimizedValue)) {
|
||||
errors.push(...compareStructures(fullPair.value, optimizedValue, `${path}.${fullPair.key}`));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
it("optimized webhooks match full source for all events and actions", () => {
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const [event, actions] of Object.entries(keptWebhooks)) {
|
||||
for (const [action, actionData] of Object.entries(actions)) {
|
||||
// Build from full source (use bodyParameters, may be undefined)
|
||||
const params = actionData.bodyParameters || [];
|
||||
const fullPayload = buildFromParams(params);
|
||||
|
||||
// Get from optimized (deduplicated) source
|
||||
const optimizedPayload = getEventPayload(event, action);
|
||||
|
||||
if (!optimizedPayload) {
|
||||
allErrors.push(`Missing optimized payload for ${event}.${action}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const errors = compareStructures(fullPayload, optimizedPayload, `${event}.${action}`);
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
fail(
|
||||
`Optimization validation failed:\n${allErrors.slice(0, 20).join("\n")}` +
|
||||
(allErrors.length > 20 ? `\n... and ${allErrors.length - 20} more errors` : "")
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("all full source events are present in optimized version", () => {
|
||||
for (const event of Object.keys(keptWebhooks)) {
|
||||
const types = getSupportedEventTypes(event);
|
||||
expect(types.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("all full source actions are present in optimized version", () => {
|
||||
for (const [event, actions] of Object.entries(keptWebhooks)) {
|
||||
for (const action of Object.keys(actions)) {
|
||||
const payload = getEventPayload(event, action);
|
||||
expect(payload).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
|
||||
import webhookObjects from "./objects.json";
|
||||
import webhooks from "./webhooks.json";
|
||||
import webhooksData from "./webhooks.min.json";
|
||||
import objectsData from "./webhooks.objects.min.json";
|
||||
import stringsData from "./webhooks.strings.min.json";
|
||||
|
||||
import schedule from "./schedule.json" assert {type: "json"};
|
||||
import workflow_call from "./workflow_call.json" assert {type: "json"};
|
||||
import schedule from "./schedule.min.json";
|
||||
import workflow_call from "./workflow_call.min.json";
|
||||
|
||||
const customEventPayloads: {[name: string]: unknown} = {
|
||||
schedule,
|
||||
@@ -49,9 +50,22 @@ type Param = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A full {@link Param} or an index into the objects array for deduplicated parameters
|
||||
* Compact format for params (written by update-webhooks.ts).
|
||||
*
|
||||
* Names can be interned (number = index into string table) or literal strings.
|
||||
* Type-based dispatch:
|
||||
* - number - interned name only (index into string table)
|
||||
* - "name" - literal name only (singleton, not interned)
|
||||
* - [name, desc] - name + description (name is number or string, desc is string)
|
||||
* - [name, [...children]] - name + children (arr[1] is array)
|
||||
* - [name, desc, [...children]] - name + description + children
|
||||
*/
|
||||
type DeduplicatedParam = Param | number;
|
||||
type InternedName = number | string;
|
||||
type CompactParam =
|
||||
| InternedName
|
||||
| [InternedName, string]
|
||||
| [InternedName, CompactParam[]]
|
||||
| [InternedName, string, CompactParam[]];
|
||||
|
||||
type WebhookPayload = {
|
||||
descriptionHtml: string;
|
||||
@@ -65,17 +79,33 @@ type Webhooks = {
|
||||
};
|
||||
};
|
||||
|
||||
type DeduplicatedWebhooks = {
|
||||
[name: string]: {
|
||||
[action: string]: WebhookPayload & {
|
||||
bodyParameters: DeduplicatedParam[];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Webhooks data format after optimization:
|
||||
* {
|
||||
* [event]: { [action]: { p: CompactParam[] } }
|
||||
* }
|
||||
*
|
||||
* String table and objects are loaded from separate files.
|
||||
*/
|
||||
type WebhooksData = {
|
||||
[key: string]: {[action: string]: {p: CompactParam[]}};
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
|
||||
const dedupedWebhookPayloads: DeduplicatedWebhooks = webhooks as any;
|
||||
const objects: Param[] = webhookObjects as any;
|
||||
const webhooksJson: WebhooksData = webhooksData as any;
|
||||
const objectsJson: CompactParam[] = objectsData as any;
|
||||
|
||||
// String table and objects are in separate files
|
||||
const stringTable: string[] = stringsData;
|
||||
const objects: CompactParam[] = objectsJson;
|
||||
|
||||
// Build event payloads map (skip "//" comment key)
|
||||
const dedupedWebhookPayloads: {[event: string]: {[action: string]: {p: CompactParam[]}}} = {};
|
||||
for (const [key, value] of Object.entries(webhooksJson)) {
|
||||
if (key !== "//" && typeof value === "object" && value !== null) {
|
||||
dedupedWebhookPayloads[key] = value as {[action: string]: {p: CompactParam[]}};
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Hydrated webhook payloads
|
||||
@@ -169,10 +199,14 @@ function getWebhookPayload(event: string, action: string): WebhookPayload | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get params from 'p' (compact format)
|
||||
const dedupedParams = deduplicatedPayload.p || [];
|
||||
|
||||
// Recreate the full payload and store it for reuse
|
||||
const params = deduplicatedPayload.bodyParameters.map(p => fullParam(p));
|
||||
const params = dedupedParams.map(p => fullParam(p));
|
||||
const payload = {
|
||||
...deduplicatedPayload,
|
||||
descriptionHtml: "",
|
||||
summaryHtml: "",
|
||||
bodyParameters: params
|
||||
};
|
||||
webhookPayloads[event] ||= {};
|
||||
@@ -180,13 +214,65 @@ function getWebhookPayload(event: string, action: string): WebhookPayload | unde
|
||||
return payload;
|
||||
}
|
||||
|
||||
function fullParam(dedupedParam: DeduplicatedParam): Param {
|
||||
if (typeof dedupedParam === "number") {
|
||||
if (dedupedParam >= objects.length) {
|
||||
throw new Error(`Unknown object ${dedupedParam}`);
|
||||
/**
|
||||
* Resolve an interned name (non-negative number -> string table lookup) or return literal string
|
||||
*/
|
||||
function resolveName(name: InternedName): string {
|
||||
if (typeof name === "number") {
|
||||
if (name < 0 || name >= stringTable.length) {
|
||||
throw new Error(`Unknown interned name index ${name}`);
|
||||
}
|
||||
return objects[dedupedParam];
|
||||
return stringTable[name];
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a deduplicated param to a full Param.
|
||||
*
|
||||
* Compact format (type-based dispatch):
|
||||
* - negative number - object index: -(n + 1) -> objects[-n - 1]
|
||||
* - non-negative number - interned string index -> stringTable[n]
|
||||
* - "name" - literal name only (singleton, not interned)
|
||||
* - [name, desc] - name + description (name can be number or string)
|
||||
* - [name, [...children]] - name + children (arr[1] is array)
|
||||
* - [name, desc, [...children]] - name + description + children
|
||||
*/
|
||||
function fullParam(dedupedParam: CompactParam): Param {
|
||||
// Negative number -> object index
|
||||
if (typeof dedupedParam === "number" && dedupedParam < 0) {
|
||||
const objectIndex = -(dedupedParam + 1);
|
||||
if (objectIndex >= objects.length) {
|
||||
throw new Error(`Unknown object index ${objectIndex} (from ${dedupedParam})`);
|
||||
}
|
||||
return fullParam(objects[objectIndex]);
|
||||
}
|
||||
|
||||
return dedupedParam;
|
||||
// Non-negative number or literal string -> name only
|
||||
if (typeof dedupedParam === "number" || typeof dedupedParam === "string") {
|
||||
return {
|
||||
name: resolveName(dedupedParam),
|
||||
description: ""
|
||||
} as Param;
|
||||
}
|
||||
|
||||
// Compact array format -> convert to Param object
|
||||
if (Array.isArray(dedupedParam)) {
|
||||
const arr = dedupedParam;
|
||||
const name = resolveName(arr[0]);
|
||||
|
||||
// Type-based dispatch: if arr[1] is string -> description, if array -> children
|
||||
const description = typeof arr[1] === "string" ? arr[1] : "";
|
||||
// arr[1] is children if it's an array, otherwise arr[2] is children (if it exists and is an array)
|
||||
const childrenArr = Array.isArray(arr[1]) ? arr[1] : Array.isArray(arr[2]) ? arr[2] : undefined;
|
||||
const childParamsGroups = childrenArr ? childrenArr.map(c => fullParam(c)) : undefined;
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
childParamsGroups
|
||||
} as Param;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected param format: ${JSON.stringify(dedupedParam)}`);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
[
|
||||
"url",
|
||||
"id",
|
||||
"node_id",
|
||||
"html_url",
|
||||
"name",
|
||||
"events_url",
|
||||
"avatar_url",
|
||||
"login",
|
||||
"repos_url",
|
||||
"type",
|
||||
"gravatar_id",
|
||||
"followers_url",
|
||||
"following_url",
|
||||
"gists_url",
|
||||
"starred_url",
|
||||
"subscriptions_url",
|
||||
"organizations_url",
|
||||
"received_events_url",
|
||||
"site_admin",
|
||||
"email",
|
||||
"deleted",
|
||||
"created_at",
|
||||
"description",
|
||||
"updated_at",
|
||||
"href",
|
||||
"labels_url",
|
||||
"comments_url",
|
||||
"number",
|
||||
"user",
|
||||
"statuses_url",
|
||||
"owner",
|
||||
"commits_url",
|
||||
"state",
|
||||
"open_issues",
|
||||
"sha",
|
||||
"title",
|
||||
"ref",
|
||||
"body",
|
||||
"hooks_url",
|
||||
"issues_url",
|
||||
"full_name",
|
||||
"private",
|
||||
"fork",
|
||||
"archive_url",
|
||||
"assignees_url",
|
||||
"blobs_url",
|
||||
"branches_url",
|
||||
"collaborators_url",
|
||||
"compare_url",
|
||||
"contents_url",
|
||||
"contributors_url",
|
||||
"deployments_url",
|
||||
"downloads_url",
|
||||
"forks_url",
|
||||
"git_commits_url",
|
||||
"git_refs_url",
|
||||
"git_tags_url",
|
||||
"issue_comment_url",
|
||||
"issue_events_url",
|
||||
"keys_url",
|
||||
"languages_url",
|
||||
"merges_url",
|
||||
"milestones_url",
|
||||
"notifications_url",
|
||||
"pulls_url",
|
||||
"releases_url",
|
||||
"stargazers_url",
|
||||
"subscribers_url",
|
||||
"subscription_url",
|
||||
"tags_url",
|
||||
"teams_url",
|
||||
"trees_url",
|
||||
"slug",
|
||||
"size",
|
||||
"repo",
|
||||
"closed_at",
|
||||
"permissions",
|
||||
"archived",
|
||||
"comments",
|
||||
"homepage",
|
||||
"license",
|
||||
"git_url",
|
||||
"ssh_url",
|
||||
"clone_url",
|
||||
"mirror_url",
|
||||
"svn_url",
|
||||
"language",
|
||||
"forks_count",
|
||||
"stargazers_count",
|
||||
"watchers_count",
|
||||
"default_branch",
|
||||
"open_issues_count",
|
||||
"is_template",
|
||||
"topics",
|
||||
"has_issues",
|
||||
"has_projects",
|
||||
"has_wiki",
|
||||
"has_pages",
|
||||
"has_downloads",
|
||||
"disabled",
|
||||
"visibility",
|
||||
"pushed_at",
|
||||
"forks",
|
||||
"delete_branch_on_merge",
|
||||
"allow_forking",
|
||||
"watchers",
|
||||
"allow_rebase_merge",
|
||||
"allow_squash_merge",
|
||||
"allow_auto_merge",
|
||||
"allow_update_branch",
|
||||
"allow_merge_commit",
|
||||
"from",
|
||||
"web_commit_signoff_required",
|
||||
"author_association",
|
||||
"label",
|
||||
"labels",
|
||||
"key",
|
||||
"admin",
|
||||
"pull",
|
||||
"triage",
|
||||
"push",
|
||||
"maintain",
|
||||
"draft",
|
||||
"public",
|
||||
"commits",
|
||||
"organization",
|
||||
"spdx_id",
|
||||
"master_branch",
|
||||
"active_lock_reason",
|
||||
"locked",
|
||||
"review_comments",
|
||||
"use_squash_pr_title_as_default",
|
||||
"squash_merge_commit_title",
|
||||
"squash_merge_commit_message",
|
||||
"merge_commit_title",
|
||||
"merge_commit_message",
|
||||
"role_name",
|
||||
"creator",
|
||||
"stargazers",
|
||||
"milestone",
|
||||
"members_url",
|
||||
"statuses",
|
||||
"assignee",
|
||||
"assignees",
|
||||
"permission",
|
||||
"privacy",
|
||||
"repositories_url",
|
||||
"base",
|
||||
"status",
|
||||
"head",
|
||||
"reactions",
|
||||
"+1",
|
||||
"-1",
|
||||
"confused",
|
||||
"eyes",
|
||||
"heart",
|
||||
"hooray",
|
||||
"laugh",
|
||||
"rocket",
|
||||
"total_count",
|
||||
"diff_url",
|
||||
"merged_at",
|
||||
"patch_url",
|
||||
"changes",
|
||||
"due_on",
|
||||
"repository_url",
|
||||
"color",
|
||||
"issue",
|
||||
"closed_issues",
|
||||
"pull_request",
|
||||
"conclusion",
|
||||
"default",
|
||||
"_links",
|
||||
"html",
|
||||
"author",
|
||||
"self",
|
||||
"pull_requests",
|
||||
"issue_url",
|
||||
"has_discussions",
|
||||
"metadata",
|
||||
"date",
|
||||
"review_comment",
|
||||
"auto_merge",
|
||||
"merge_commit_sha",
|
||||
"requested_reviewers",
|
||||
"requested_teams",
|
||||
"review_comment_url",
|
||||
"review_comments_url",
|
||||
"starred_at",
|
||||
"external_url",
|
||||
"issues",
|
||||
"checks",
|
||||
"contents",
|
||||
"deployments",
|
||||
"events",
|
||||
"head_sha",
|
||||
"username",
|
||||
"performed_via_github_app",
|
||||
"timeline_url",
|
||||
"additions",
|
||||
"changed_files",
|
||||
"deletions",
|
||||
"maintainer_can_modify",
|
||||
"mergeable",
|
||||
"mergeable_state",
|
||||
"merged",
|
||||
"merged_by",
|
||||
"rebaseable",
|
||||
"head_branch",
|
||||
"completed_at",
|
||||
"started_at",
|
||||
"parent",
|
||||
"commit_message",
|
||||
"commit_title",
|
||||
"enabled_by",
|
||||
"merge_method",
|
||||
"pages",
|
||||
"path",
|
||||
"actions",
|
||||
"administration",
|
||||
"content_references",
|
||||
"discussions",
|
||||
"emails",
|
||||
"environments",
|
||||
"keys",
|
||||
"members",
|
||||
"organization_administration",
|
||||
"organization_hooks",
|
||||
"organization_packages",
|
||||
"organization_plan",
|
||||
"organization_projects",
|
||||
"organization_secrets",
|
||||
"organization_self_hosted_runners",
|
||||
"organization_user_blocking",
|
||||
"packages",
|
||||
"repository_hooks",
|
||||
"repository_projects",
|
||||
"secret_scanning_alerts",
|
||||
"secrets",
|
||||
"security_events",
|
||||
"security_scanning_alert",
|
||||
"single_file",
|
||||
"team_discussions",
|
||||
"vulnerability_alerts",
|
||||
"workflows",
|
||||
"run_attempt",
|
||||
"committer",
|
||||
"message",
|
||||
"prerelease",
|
||||
"tag_name",
|
||||
"target_commitish",
|
||||
"repository",
|
||||
"repository_id",
|
||||
"head_commit",
|
||||
"timestamp",
|
||||
"tree_id",
|
||||
"to",
|
||||
"content_type",
|
||||
"published_at",
|
||||
"category",
|
||||
"emoji",
|
||||
"is_answerable",
|
||||
"state_reason",
|
||||
"affected_package_name",
|
||||
"affected_range",
|
||||
"external_identifier",
|
||||
"external_reference",
|
||||
"fixed_in",
|
||||
"ghsa_id",
|
||||
"severity",
|
||||
"check_run_url",
|
||||
"run_id",
|
||||
"run_url",
|
||||
"runner_group_id",
|
||||
"runner_group_name",
|
||||
"runner_id",
|
||||
"runner_name",
|
||||
"workflow_name",
|
||||
"steps",
|
||||
"installations_count",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"webhook_secret",
|
||||
"pem",
|
||||
"after",
|
||||
"before",
|
||||
"answer_chosen_at",
|
||||
"answer_chosen_by",
|
||||
"answer_html_url",
|
||||
"release",
|
||||
"assets",
|
||||
"assets_url",
|
||||
"tarball_url",
|
||||
"upload_url",
|
||||
"zipball_url",
|
||||
"app",
|
||||
"environment",
|
||||
"summary",
|
||||
"actor",
|
||||
"artifacts_url",
|
||||
"cancel_url",
|
||||
"check_suite_id",
|
||||
"check_suite_node_id",
|
||||
"check_suite_url",
|
||||
"event",
|
||||
"head_repository",
|
||||
"jobs_url",
|
||||
"logs_url",
|
||||
"previous_attempt_url",
|
||||
"referenced_workflows",
|
||||
"rerun_url",
|
||||
"run_number",
|
||||
"run_started_at",
|
||||
"triggering_actor",
|
||||
"workflow_id",
|
||||
"workflow_url",
|
||||
"note",
|
||||
"after_id",
|
||||
"column_id",
|
||||
"project_url",
|
||||
"dismiss_reason",
|
||||
"dismissed_at",
|
||||
"dismisser",
|
||||
"fix_reason",
|
||||
"fixed_at",
|
||||
"commit",
|
||||
"workflow_job",
|
||||
"action",
|
||||
"temp_clone_token",
|
||||
"subscribers_count",
|
||||
"network_count",
|
||||
"check_suite",
|
||||
"deployment",
|
||||
"task",
|
||||
"original_environment",
|
||||
"transient_environment",
|
||||
"production_environment",
|
||||
"payload",
|
||||
"workflow_run",
|
||||
"discussion",
|
||||
"comment",
|
||||
"child_comment_count",
|
||||
"discussion_id",
|
||||
"parent_id",
|
||||
"column_url",
|
||||
"commit_id",
|
||||
"pull_request_url",
|
||||
"browser_download_url",
|
||||
"download_count",
|
||||
"uploader",
|
||||
"discussion_url",
|
||||
"alert",
|
||||
"manifest",
|
||||
"installation_command",
|
||||
"version",
|
||||
"admin_enforced",
|
||||
"authorized_actor_names",
|
||||
"authorized_actors_only",
|
||||
"authorized_dismissal_actors_only",
|
||||
"linear_history_requirement_enforcement_level",
|
||||
"required_status_checks",
|
||||
"required_status_checks_enforcement_level",
|
||||
"check_run",
|
||||
"details_url",
|
||||
"external_id",
|
||||
"actions_meta",
|
||||
"check_runs_url",
|
||||
"latest_check_runs_count",
|
||||
"rerequestable",
|
||||
"runs_rerequestable",
|
||||
"workflow",
|
||||
"display_title",
|
||||
"project_card",
|
||||
"content_url",
|
||||
"short_description",
|
||||
"archived_at",
|
||||
"reason",
|
||||
"diff_hunk",
|
||||
"in_reply_to_id",
|
||||
"line",
|
||||
"original_commit_id",
|
||||
"original_line",
|
||||
"original_position",
|
||||
"original_start_line",
|
||||
"position",
|
||||
"pull_request_review_id",
|
||||
"side",
|
||||
"start_line",
|
||||
"start_side",
|
||||
"ref_type",
|
||||
"target_url",
|
||||
"new_repository",
|
||||
"pusher",
|
||||
"added",
|
||||
"distinct",
|
||||
"modified",
|
||||
"removed",
|
||||
"registry_package",
|
||||
"ecosystem",
|
||||
"namespace",
|
||||
"package_type",
|
||||
"package_version",
|
||||
"body_html",
|
||||
"docker_metadata",
|
||||
"package_files",
|
||||
"download_url",
|
||||
"md5",
|
||||
"sha1",
|
||||
"sha256",
|
||||
"package_url",
|
||||
"rubygems_metadata",
|
||||
"target_oid",
|
||||
"registry",
|
||||
"dismiss_comment"
|
||||
]
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
|
||||
b:
|
||||
needs: [a]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,7 +14,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
us|ername:
|
||||
`;
|
||||
@@ -31,7 +31,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs-no-description.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
|
||||
with:
|
||||
us|ername:
|
||||
`;
|
||||
@@ -48,7 +48,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
echo_outputs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
@@ -110,11 +110,8 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual(
|
||||
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
|
||||
"Actions schedules run at most every 5 minutes. " +
|
||||
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||
);
|
||||
// Cron description is now shown via diagnostics, not hover
|
||||
expect(result?.contents).toEqual("");
|
||||
});
|
||||
|
||||
it("on a cron mapping key", async () => {
|
||||
|
||||
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
|
||||
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
||||
import {Lexer} from "@actions/expressions/lexer";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
|
||||
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
|
||||
import {HoverVisitor} from "./expression-hover/visitor";
|
||||
import {info} from "./log";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken, TokenResult} from "./utils/find-token";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
|
||||
@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
|
||||
|
||||
info(`Calculating hover for token with definition ${token.definition.key}`);
|
||||
|
||||
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
|
||||
const tokenValue = (token as StringToken).value;
|
||||
const description = getCronDescription(tokenValue);
|
||||
if (description) {
|
||||
return {
|
||||
contents: description,
|
||||
range: mapRange(token.range)
|
||||
} satisfies Hover;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||
@@ -156,15 +143,6 @@ async function getDescription(
|
||||
return description || defaultDescription;
|
||||
}
|
||||
|
||||
function isCronMappingValue(tokenResult: TokenResult): boolean {
|
||||
return (
|
||||
tokenResult.parent?.definition?.key === "cron-mapping" &&
|
||||
!!tokenResult.token &&
|
||||
isString(tokenResult.token) &&
|
||||
tokenResult.token.value !== "cron"
|
||||
);
|
||||
}
|
||||
|
||||
function expressionHover(
|
||||
exprPos: ExpressionPos,
|
||||
context: DescriptionDictionary,
|
||||
|
||||
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
getFileContent: async ref => {
|
||||
switch (fileIdentifier(ref)) {
|
||||
case "monalisa/octocat/workflow.yaml@main":
|
||||
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
|
||||
return {
|
||||
name: "monalisa/octocat/workflow.yaml",
|
||||
name: "monalisa/octocat/.github/workflows/workflow.yaml",
|
||||
content: `
|
||||
on: workflow_call
|
||||
jobs:
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow.yaml":
|
||||
case "./.github/workflows/reusable-workflow.yaml":
|
||||
return {
|
||||
name: "reusable-workflow.yaml",
|
||||
name: ".github/workflows/reusable-workflow.yaml",
|
||||
content: `
|
||||
on: workflow_call
|
||||
jobs:
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-inputs.yaml":
|
||||
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-inputs.yaml",
|
||||
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -76,9 +76,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-inputs-no-description.yaml":
|
||||
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-inputs.yaml",
|
||||
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -95,9 +95,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-outputs.yaml":
|
||||
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-outputs.yaml",
|
||||
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -635,7 +635,7 @@ jobs:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
node: [14, 16]
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: User-\${{ strategy.fail-fast }}
|
||||
`;
|
||||
@@ -654,7 +654,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [14, 16]
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: \${{ matrix.node }}
|
||||
`;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message: "Invalid cron string",
|
||||
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
|
||||
range: {
|
||||
end: {
|
||||
character: 21,
|
||||
@@ -195,6 +195,96 @@ jobs:
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with interval less than 5 minutes shows warning", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
schedule:
|
||||
- cron: '*/1 * * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
),
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message:
|
||||
'Actions schedules run at most every 5 minutes. "*/1 * * * *" (runs every minute) will not run as frequently as specified.',
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||
},
|
||||
range: {
|
||||
end: {
|
||||
character: 25,
|
||||
line: 2
|
||||
},
|
||||
start: {
|
||||
character: 12,
|
||||
line: 2
|
||||
}
|
||||
}
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with interval of 5 minutes or more shows info", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
),
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message: "Runs every 5 minutes",
|
||||
severity: DiagnosticSeverity.Information,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||
},
|
||||
range: {
|
||||
end: {
|
||||
character: 25,
|
||||
line: 2
|
||||
},
|
||||
start: {
|
||||
character: 12,
|
||||
line: 2
|
||||
}
|
||||
}
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
schedule:
|
||||
- cron: '0,2 * * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
),
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.severity).toBe(DiagnosticSeverity.Warning);
|
||||
expect(result[0]?.message).toContain("Actions schedules run at most every 5 minutes.");
|
||||
});
|
||||
|
||||
it("invalid YAML", async () => {
|
||||
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
|
||||
// within the fromJSON() expression.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||
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";
|
||||
@@ -26,6 +28,9 @@ import {validateAction} from "./validate-action";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
|
||||
const CRON_SCHEDULE_DOCS_URL =
|
||||
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
|
||||
|
||||
export type ValidationConfig = {
|
||||
valueProviderConfig?: ValueProviderConfig;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
@@ -104,15 +109,72 @@ 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate step uses field format
|
||||
if (isString(token) && token.range && validationDefinition?.key === "step-uses") {
|
||||
validateStepUsesFormat(diagnostics, token);
|
||||
}
|
||||
|
||||
// Validate action metadata (inputs, required fields) for regular steps
|
||||
if (token.definition?.key === "regular-step" && token.range) {
|
||||
const context = getProviderContext(documentUri, template, root, token.range);
|
||||
await validateAction(diagnostics, token, context.step, config);
|
||||
}
|
||||
|
||||
// Validate job-level reusable workflow uses field format
|
||||
if (
|
||||
isString(token) &&
|
||||
token.range &&
|
||||
key &&
|
||||
isString(key) &&
|
||||
key.value === "uses" &&
|
||||
parent?.definition?.key === "workflow-job"
|
||||
) {
|
||||
validateWorkflowUsesFormat(diagnostics, token);
|
||||
}
|
||||
|
||||
// Validate cron expressions - warn if interval is less than 5 minutes
|
||||
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
|
||||
validateCronExpression(diagnostics, token);
|
||||
}
|
||||
|
||||
// Allowed values coming from the schema have already been validated. Only check if
|
||||
// a value provider is defined for a token and if it is, validate the values match.
|
||||
if (token.range && validationDefinition) {
|
||||
@@ -163,6 +225,357 @@ function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: Value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates cron expressions and provides diagnostics for valid cron schedules.
|
||||
* Shows a warning if the interval is less than 5 minutes (since GitHub Actions
|
||||
* schedules run at most every 5 minutes), otherwise shows an info message.
|
||||
*/
|
||||
function validateCronExpression(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const cronValue = token.value;
|
||||
|
||||
// Ensure we have a range for diagnostics
|
||||
if (!token.range) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check valid cron expressions - invalid ones are already caught by the parser
|
||||
const description = getCronDescription(cronValue);
|
||||
if (!description) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the cron specifies an interval less than 5 minutes
|
||||
if (hasCronIntervalLessThan5Minutes(cronValue)) {
|
||||
diagnostics.push({
|
||||
message: `Actions schedules run at most every 5 minutes. "${cronValue}" (${description.toLowerCase()}) will not run as frequently as specified.`,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: CRON_SCHEDULE_DOCS_URL
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Show info message for valid cron expressions
|
||||
diagnostics.push({
|
||||
message: description,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Information,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: CRON_SCHEDULE_DOCS_URL
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the format of a step's `uses` field.
|
||||
*
|
||||
* Valid formats:
|
||||
* - docker://image:tag
|
||||
* - ./local/path
|
||||
* - .\local\path (Windows)
|
||||
* - {owner}/{repo}@{ref}
|
||||
* - {owner}/{repo}/{path}@{ref}
|
||||
*/
|
||||
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const uses = token.value;
|
||||
|
||||
// Empty uses value
|
||||
if (!uses) {
|
||||
diagnostics.push({
|
||||
message: "`uses' value in action cannot be blank",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Docker image reference - always valid format
|
||||
if (uses.startsWith("docker://")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local action path - always valid format
|
||||
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
||||
const atSegments = uses.split("@");
|
||||
|
||||
// Must have exactly one @
|
||||
if (atSegments.length !== 2) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
const [repoPath, gitRef] = atSegments;
|
||||
|
||||
// Ref cannot be empty
|
||||
if (!gitRef) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split by / or \ to get path segments
|
||||
const pathSegments = repoPath.split(/[\\/]/);
|
||||
|
||||
// Must have at least owner and repo (both non-empty)
|
||||
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a reusable workflow reference (should be at job level, not step)
|
||||
// Path would be like: owner/repo/.github/workflows/file.yml
|
||||
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
||||
diagnostics.push({
|
||||
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
diagnostics.push({
|
||||
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the format of a job's `uses` field (reusable workflow reference).
|
||||
*
|
||||
* Valid formats:
|
||||
* - {owner}/{repo}/.github/workflows/{filename}.yml@{ref}
|
||||
* - {owner}/{repo}/.github/workflows/{filename}.yaml@{ref}
|
||||
* - {owner}/{repo}/.github/workflows-lab/{filename}.yml@{ref}
|
||||
* - {owner}/{repo}/.github/workflows-lab/{filename}.yaml@{ref}
|
||||
* - ./.github/workflows/{filename}.yml
|
||||
* - ./.github/workflows/{filename}.yaml
|
||||
* - ./.github/workflows-lab/{filename}.yml
|
||||
* - ./.github/workflows-lab/{filename}.yaml
|
||||
*/
|
||||
function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const uses = token.value;
|
||||
|
||||
// Local workflow reference
|
||||
if (uses.startsWith("./.github/workflows/") || uses.startsWith("./.github/workflows-lab/")) {
|
||||
// Cannot have @ version for local workflows
|
||||
if (uses.includes("@")) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "cannot specify version when calling local workflows");
|
||||
return;
|
||||
}
|
||||
|
||||
// Must have .yml or .yaml extension
|
||||
if (!uses.endsWith(".yml") && !uses.endsWith(".yaml")) {
|
||||
addWorkflowUsesFormatError(
|
||||
diagnostics,
|
||||
token,
|
||||
"workflow file should have either a '.yml' or '.yaml' file extension"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Must be at top level of .github/workflows/ or .github/workflows-lab/ (no subdirectories)
|
||||
const pathParts = uses.split("/");
|
||||
if (pathParts.length !== 4) {
|
||||
// Expected: ".", ".github", "workflows" or "workflows-lab", "filename.yml"
|
||||
addWorkflowUsesFormatError(
|
||||
diagnostics,
|
||||
token,
|
||||
"workflows must be defined at the top level of the .github/workflows/ directory"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filename cannot be just the extension
|
||||
const filename = pathParts[3];
|
||||
if (filename === ".yml" || filename === ".yaml") {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Malformed local workflow reference (starts with ./ but not in .github/workflows)
|
||||
if (uses.startsWith("./")) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "local workflow references must be rooted in '.github/workflows'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote workflow reference: must have @ for version
|
||||
const atSegments = uses.split("@");
|
||||
if (atSegments.length === 1) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
|
||||
return;
|
||||
}
|
||||
if (atSegments.length > 2) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "too many '@' in workflow reference");
|
||||
return;
|
||||
}
|
||||
|
||||
const [pathPart, version] = atSegments;
|
||||
|
||||
// Version cannot be empty
|
||||
if (!version) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
|
||||
return;
|
||||
}
|
||||
|
||||
// Must contain .github/workflows or .github/workflows-lab path
|
||||
const workflowsMatch = pathPart.match(/\.github\/workflows(-lab)?\//);
|
||||
if (!workflowsMatch || workflowsMatch.index === undefined) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "references to workflows must be rooted in '.github/workflows'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Split to get owner/repo and path
|
||||
const pathIdx = workflowsMatch.index;
|
||||
const nwoPart = pathPart.substring(0, pathIdx);
|
||||
const workflowPath = pathPart.substring(pathIdx);
|
||||
|
||||
// Validate NWO part: must be owner/repo/
|
||||
const nwoSegments = nwoPart.split("/").filter(s => s.length > 0);
|
||||
if (nwoSegments.length !== 2) {
|
||||
addWorkflowUsesFormatError(
|
||||
diagnostics,
|
||||
token,
|
||||
"references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate owner and repo names
|
||||
const [owner, repo] = nwoSegments;
|
||||
const nwoError = validateNWO(owner, repo);
|
||||
if (nwoError) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, nwoError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ref/version format
|
||||
const refError = validateRefName(version);
|
||||
if (refError) {
|
||||
addWorkflowUsesFormatError(diagnostics, token, refError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate workflow path is at top level
|
||||
const workflowPathParts = workflowPath.split("/");
|
||||
if (workflowPathParts.length !== 3) {
|
||||
// Expected: ".github", "workflows" or "workflows-lab", "filename.yml"
|
||||
addWorkflowUsesFormatError(
|
||||
diagnostics,
|
||||
token,
|
||||
"workflows must be defined at the top level of the .github/workflows/ directory"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Must have .yml or .yaml extension
|
||||
const filename = workflowPathParts[2];
|
||||
if (!filename.endsWith(".yml") && !filename.endsWith(".yaml")) {
|
||||
addWorkflowUsesFormatError(
|
||||
diagnostics,
|
||||
token,
|
||||
"workflow file should have either a '.yml' or '.yaml' file extension"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filename cannot be just the extension
|
||||
if (filename === ".yml" || filename === ".yaml") {
|
||||
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void {
|
||||
diagnostics.push({
|
||||
message: `Invalid workflow reference '${token.value}': ${reason}`,
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-workflow-uses-format"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the git ref/version format.
|
||||
* Based on Launch's ValidateRefName function.
|
||||
*/
|
||||
function validateRefName(refname: string): string | undefined {
|
||||
if (refname.length === 0) {
|
||||
return "no version specified";
|
||||
}
|
||||
|
||||
// Cannot be the single character '@'
|
||||
if (refname === "@") {
|
||||
return "version cannot be the single character '@'";
|
||||
}
|
||||
|
||||
// Cannot have certain invalid characters or sequences
|
||||
const invalidSequences = ["?", "*", "[", "]", "\\", "~", "^", ":", "@{", "..", "//"];
|
||||
for (const seq of invalidSequences) {
|
||||
if (refname.includes(seq)) {
|
||||
return `invalid character '${seq}' in version`;
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot begin or end with a slash '/' or a dot '.'
|
||||
if (refname.startsWith("/") || refname.endsWith("/") || refname.startsWith(".") || refname.endsWith(".")) {
|
||||
return "version cannot begin or end with a slash '/' or a dot '.'";
|
||||
}
|
||||
|
||||
// No slash-separated component can begin with a dot '.' or end with the sequence '.lock'
|
||||
const components = refname.split("/");
|
||||
for (const component of components) {
|
||||
if (component.startsWith(".") || component.endsWith(".lock")) {
|
||||
return `invalid version: ${refname}`;
|
||||
}
|
||||
}
|
||||
|
||||
// No ASCII control characters or whitespace
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f\x7f]/.test(refname)) {
|
||||
return "version cannot have ASCII control characters";
|
||||
}
|
||||
|
||||
if (/\s/.test(refname)) {
|
||||
return "version cannot have whitespace";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates owner and repository names.
|
||||
* Based on Launch's ValidateNWO function.
|
||||
*/
|
||||
function validateNWO(owner: string, repo: string): string | undefined {
|
||||
// Owner name: can have word chars, dots, and hyphens
|
||||
// \w in JS regex is [a-zA-Z0-9_]
|
||||
if (!/^[\w.-]+$/.test(owner)) {
|
||||
return "owner name must be a valid repository owner name";
|
||||
}
|
||||
|
||||
// Repository name: can have word chars, dots, and hyphens
|
||||
if (!/^[\w.-]+$/.test(repo)) {
|
||||
return "repository name is invalid";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getProviderContext(
|
||||
documentUri: URI,
|
||||
template: WorkflowTemplate,
|
||||
@@ -179,17 +592,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 {
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("validate uses format", () => {
|
||||
describe("valid formats", () => {
|
||||
it("standard org/repo@ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("org/repo with path @ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/aws/ec2@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("org/repo with deep path @ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/aws/nested/deep/path@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("docker image", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker://alpine:3.8
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("docker image with registry", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker://gcr.io/my-project/my-image:latest
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("local path with ./", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./my-action
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("local path with ./ and subdirectories", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./.github/actions/my-action
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("local path with .\\ (Windows)", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: .\\my-action
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("SHA ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("branch ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: owner/repo@feature/my-branch
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid formats", () => {
|
||||
it("missing @ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 28}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 29}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("missing org/owner", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: checkout@v4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout@v4'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 23}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty owner", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: /repo@v4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual '/repo@v4'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 20}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty repo", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: owner/@v4
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'owner/@v4'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 21}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple @ symbols", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4@extra
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@v4@extra'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 37}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("just a name with no slash", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: checkout
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 20}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty uses value", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ""
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual({
|
||||
message: "`uses' value in action cannot be blank",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 14}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
});
|
||||
|
||||
it("reusable workflow in step", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: owner/repo/.github/workflows/test.yml@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
end: {line: 5, character: 54}
|
||||
},
|
||||
code: "invalid-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow uses format validation", () => {
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("valid formats", () => {
|
||||
it("local workflow path", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("local workflow path with yaml extension", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yaml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("remote workflow with version", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("remote workflow with sha ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@abc123
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("remote workflow with branch ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("remote workflow with yaml extension", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yaml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("local workflows-lab path", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows-lab/test.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("local workflows-lab path with yaml extension", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows-lab/test.yaml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("remote workflows-lab with version", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows-lab/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid formats", () => {
|
||||
it("remote workflow missing version", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml': no version specified",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 47}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("local workflow with version", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference './.github/workflows/test.yml@v1': cannot specify version when calling local workflows",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 41}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("malformed local path not in .github/workflows", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./foo/bar.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference './foo/bar.yml': local workflow references must be rooted in '.github/workflows'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 23}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("missing .github/workflows path", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/test.yml@v1': references to workflows must be rooted in '.github/workflows'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 32}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("invalid file extension", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.txt@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.txt@v1': workflow file should have either a '.yml' or '.yaml' file extension",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 50}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("no extension", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test@v1': workflow file should have either a '.yml' or '.yaml' file extension",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 46}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("just a ref", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'test.yml@v1': references to workflows must be rooted in '.github/workflows'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 21}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("local without .github/workflows", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./workflows/test.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference './workflows/test.yml': local workflow references must be rooted in '.github/workflows'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 30}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
describe("invalid ref/version format", () => {
|
||||
it("empty version after @", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@': no version specified",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 48}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version with invalid character ?", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1?
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1?': invalid character '?' in version",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version with double dots", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1..v2
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1..v2': invalid character '..' in version",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 54}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version ending with dot", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1.
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version starting with slash", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@/v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version ending with .lock", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 68}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version with whitespace", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 60}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version with backslash", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1\\1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 52}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid owner/repo names", () => {
|
||||
it("owner with invalid characters", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner*/repo/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("repo with invalid characters", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo!name/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 55}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("owner with spaces", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner name/repo/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 55}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid workflow filename", () => {
|
||||
it("filename is just .yml", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 46}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("filename is just .yaml", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/.yaml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 47}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("local workflow filename is just .yml", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 34}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/workflow.yaml@not-a-branch
|
||||
uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 53,
|
||||
character: 71,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/workflow.yaml@main
|
||||
uses: monalisa/octocat/.github/workflows/workflow.yaml@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
@@ -87,7 +87,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow.yaml
|
||||
uses: ./.github/workflows/reusable-workflow.yaml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
@@ -102,7 +102,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
envPAT: pat
|
||||
`;
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 46,
|
||||
character: 64,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: monalisa
|
||||
secrets:
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.10"
|
||||
"version": "0.3.23"
|
||||
}
|
||||
Generated
+4110
-3090
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "actions-languageservices",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"./expressions",
|
||||
"./workflow-parser",
|
||||
@@ -8,6 +9,6 @@
|
||||
"./languageserver"
|
||||
],
|
||||
"devDependencies": {
|
||||
"lerna": "^6.0.3"
|
||||
"lerna": "^8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Release 0.3.5
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Minifies JSON files by removing whitespace.
|
||||
*
|
||||
* Usage: node script/minify-json.js <file1.json> <file2.json> ...
|
||||
*
|
||||
* For each input file, creates a corresponding .min.json file.
|
||||
* Example: src/data.json -> src/data.min.json
|
||||
*/
|
||||
|
||||
import {promises as fs} from "fs";
|
||||
import path from "path";
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error("Usage: node script/minify-json.js <file1.json> <file2.json> ...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
const data = JSON.parse(content);
|
||||
const minified = JSON.stringify(data);
|
||||
|
||||
// Replace .json with .min.json
|
||||
const ext = path.extname(file);
|
||||
const outputFile = file.slice(0, -ext.length) + ".min" + ext;
|
||||
|
||||
await fs.writeFile(outputFile, minified);
|
||||
|
||||
const originalSize = Buffer.byteLength(content, "utf8");
|
||||
const minifiedSize = Buffer.byteLength(minified, "utf8");
|
||||
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
|
||||
|
||||
console.log(`${file} -> ${outputFile} (${savings}% smaller)`);
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${file}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Workflow Schema Optimization Plan
|
||||
|
||||
## Current State (Commit 7660f61)
|
||||
|
||||
### What's Implemented
|
||||
|
||||
1. **Original schema preserved**: `workflow-v1.0.json` remains source of truth with 291 definitions
|
||||
2. **Optimization script**: `script/optimize-workflow-schema.js` prunes unused definitions
|
||||
3. **Generated files** (gitignored):
|
||||
- `workflow-v1.0.optimized.json` - 281 definitions (pruned)
|
||||
- `workflow-v1.0.optimized.min.json` - minified version loaded at runtime
|
||||
4. **Build pipeline**: `npm run minify-json` chains optimize → minify
|
||||
5. **Tests**: `workflow-schema.test.ts` validates schema integrity
|
||||
|
||||
### 10 Pruned Definitions
|
||||
|
||||
These are unreachable from `workflow-root-strict` entry point:
|
||||
|
||||
- `workflow-root` (non-strict variant)
|
||||
- `on` (non-strict variant)
|
||||
- `on-mapping` (non-strict variant)
|
||||
- `job-if-result`
|
||||
- `step-if-result`
|
||||
- `boolean-needs-context`
|
||||
- `number-needs-context`
|
||||
- `string-needs-context`
|
||||
- `boolean-steps-context`
|
||||
- `number-steps-context`
|
||||
|
||||
### Size Savings
|
||||
|
||||
| Metric | Original | Optimized | Savings |
|
||||
|--------|----------|-----------|---------|
|
||||
| Definitions | 291 | 281 | 10 removed |
|
||||
| Minified | 71,061 B | 69,022 B | 2.9% |
|
||||
| Gzipped | 12,318 B | 12,172 B | 1.2% |
|
||||
|
||||
## Optimization Strategies Evaluated
|
||||
|
||||
### ✅ Pruning Unused Definitions (IMPLEMENTED)
|
||||
- Removes definitions not reachable from entry point
|
||||
- 1.2% gzip savings
|
||||
- Low complexity, no runtime overhead
|
||||
|
||||
### ❌ Key Shortening
|
||||
- Replace long keys with short codes (e.g., `description` → `d`)
|
||||
- 1.5% gzip savings
|
||||
- NOT WORTH IT: Adds complexity, minimal benefit after gzip
|
||||
|
||||
### ❌ String Interning
|
||||
- Deduplicate repeated strings into lookup table
|
||||
- Makes gzip WORSE (-0.5%)
|
||||
- NOT WORTH IT: Gzip already handles repetition
|
||||
|
||||
### ❌ Compact Format (like webhooks)
|
||||
- Restructure to array-based format
|
||||
- Makes gzip WORSE (-0.4%)
|
||||
- NOT WORTH IT: Schema structure doesn't benefit
|
||||
|
||||
### ❌ Split Descriptions
|
||||
- Separate file for descriptions
|
||||
- Adds 791 bytes when both files gzipped
|
||||
- NOT WORTH IT: Worse total size
|
||||
|
||||
## Future Work
|
||||
|
||||
### Update from Server
|
||||
The current schema may be missing some definitions from the latest server version. A future PR should:
|
||||
1. Fetch latest `workflow-v1.0.json` from dotcom server
|
||||
2. Update the source file
|
||||
3. Verify tests still pass
|
||||
4. Note: Server version had issues last checked (e.g., `coerce-raw`, missing `branches` on merge-group)
|
||||
|
||||
### Entry Point
|
||||
- Entry point: `workflow-root-strict` (defined in `workflow-constants.ts`)
|
||||
- Non-strict `workflow-root` exists but is unused in this codebase
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
workflow-parser/
|
||||
├── src/
|
||||
│ ├── workflow-v1.0.json # Source of truth (tracked, 291 defs)
|
||||
│ ├── workflow-v1.0.optimized.json # Pruned (gitignored, 281 defs)
|
||||
│ ├── workflow-v1.0.optimized.min.json # Minified (gitignored)
|
||||
│ └── workflows/
|
||||
│ ├── workflow-schema.ts # Loader (imports optimized.min.json)
|
||||
│ └── workflow-schema.test.ts # Schema integrity tests
|
||||
└── script/
|
||||
└── optimize-workflow-schema.js # Pruning script
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- `workflow-schema.test.ts`:
|
||||
1. Schema loads from workflow-root-strict
|
||||
2. All referenced definitions are reachable
|
||||
3. Critical definitions exist (jobs, steps, runs-on, etc.)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.23",
|
||||
"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": {
|
||||
@@ -36,19 +38,23 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"optimize-schema": "node script/optimize-workflow-schema.js",
|
||||
"minify-json": "npm run optimize-schema && node ../script/minify-json.js src/workflow-v1.0.optimized.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.10",
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Optimizes workflow-v1.0.json by pruning unused definitions.
|
||||
*
|
||||
* Removes definitions not reachable from the entry point (workflow-root-strict).
|
||||
* Output is then minified by minify-json.js to produce the final .min.json file.
|
||||
*
|
||||
* Usage: node script/optimize-workflow-schema.js
|
||||
*/
|
||||
|
||||
import {promises as fs} from "fs";
|
||||
import path from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY_POINT = "workflow-root-strict";
|
||||
|
||||
const inputPath = path.join(__dirname, "..", "src", "workflow-v1.0.json");
|
||||
const outputPath = path.join(__dirname, "..", "src", "workflow-v1.0.optimized.json");
|
||||
|
||||
/**
|
||||
* Find all type references in a definition.
|
||||
*/
|
||||
function findRefs(obj) {
|
||||
const refs = [];
|
||||
|
||||
function visit(node) {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
if (typeof item === "string") refs.push(item);
|
||||
else visit(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (["type", "item-type", "loose-key-type", "loose-value-type"].includes(key)) {
|
||||
if (typeof value === "string") refs.push(value);
|
||||
} else if (key === "one-of") {
|
||||
visit(value);
|
||||
} else if (key === "properties") {
|
||||
for (const propValue of Object.values(value)) {
|
||||
if (typeof propValue === "string") refs.push(propValue);
|
||||
else if (propValue && typeof propValue === "object") visit(propValue);
|
||||
}
|
||||
} else if (["mapping", "sequence", "string"].includes(key)) {
|
||||
visit(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(obj);
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all definitions reachable from entry point.
|
||||
*/
|
||||
function findReachable(schema, entryPoint) {
|
||||
const reachable = new Set();
|
||||
const queue = [entryPoint];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const name = queue.shift();
|
||||
if (reachable.has(name) || !schema.definitions[name]) continue;
|
||||
reachable.add(name);
|
||||
|
||||
const refs = findRefs(schema.definitions[name]);
|
||||
for (const ref of refs) {
|
||||
if (!reachable.has(ref) && schema.definitions[ref]) {
|
||||
queue.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reachable;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await fs.readFile(inputPath, "utf8");
|
||||
const schema = JSON.parse(content);
|
||||
|
||||
const reachable = findReachable(schema, ENTRY_POINT);
|
||||
const allDefs = Object.keys(schema.definitions);
|
||||
const unused = allDefs.filter((name) => !reachable.has(name));
|
||||
|
||||
console.log(`Entry point: ${ENTRY_POINT}`);
|
||||
console.log(`Definitions: ${allDefs.length} -> ${reachable.size} (${unused.length} pruned)`);
|
||||
|
||||
if (unused.length > 0) {
|
||||
console.log("\nPruned:");
|
||||
unused.forEach((name) => console.log(` - ${name}`));
|
||||
}
|
||||
|
||||
// Create pruned schema preserving definition order
|
||||
const pruned = {version: schema.version, definitions: {}};
|
||||
for (const name of allDefs) {
|
||||
if (reachable.has(name)) {
|
||||
pruned.definitions[name] = schema.definitions[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Write output (will be minified by minify-json.js)
|
||||
await fs.writeFile(outputPath, JSON.stringify(pruned));
|
||||
|
||||
console.log(`\nOutput: ${outputPath}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {isValidCron, getCronDescription} from "./cron";
|
||||
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
|
||||
|
||||
describe("cron", () => {
|
||||
describe("valid cron", () => {
|
||||
@@ -66,14 +66,54 @@ describe("cron", () => {
|
||||
|
||||
describe("getCronDescription", () => {
|
||||
it(`Produces a sentence for valid cron`, () => {
|
||||
expect(getCronDescription("0 * * * *")).toEqual(
|
||||
"Runs every hour\n\n" +
|
||||
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||
);
|
||||
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
|
||||
});
|
||||
|
||||
it(`Returns nothing for invalid cron`, () => {
|
||||
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCronIntervalLessThan5Minutes", () => {
|
||||
it("returns true for step expressions with interval < 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
|
||||
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for step expressions with interval >= 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
|
||||
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for comma-separated values with gap < 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
|
||||
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for comma-separated values with gap >= 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
|
||||
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
|
||||
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for * (every minute)", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for range expressions (runs every minute in range)", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for single value (hourly)", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid cron", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,78 @@ type Range = {
|
||||
names?: Record<string, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a cron expression specifies an interval shorter than 5 minutes.
|
||||
* GitHub Actions schedules run at most every 5 minutes, so intervals < 5 min won't work as expected.
|
||||
*/
|
||||
export function hasCronIntervalLessThan5Minutes(cron: string): boolean {
|
||||
if (!isValidCron(cron)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = cron.split(/ +/);
|
||||
const minutePart = parts[0];
|
||||
|
||||
// Parse the minute field to determine the effective interval
|
||||
return getMinuteInterval(minutePart) < 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the minimum interval in minutes between cron executions based on the minute field.
|
||||
* Returns 60 if there's only one execution per hour, otherwise returns the minimum gap.
|
||||
*/
|
||||
function getMinuteInterval(minutePart: string): number {
|
||||
// Handle step expressions like */1, */3, 0-59/2
|
||||
if (minutePart.includes("/")) {
|
||||
const [, step] = minutePart.split("/");
|
||||
const stepNum = parseInt(step, 10);
|
||||
if (!isNaN(stepNum) && stepNum > 0) {
|
||||
return stepNum;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle comma-separated values like 0,2,4 or 0,1,5,10
|
||||
if (minutePart.includes(",")) {
|
||||
const values = minutePart
|
||||
.split(",")
|
||||
.map(v => parseInt(v, 10))
|
||||
.filter(n => !isNaN(n))
|
||||
.sort((a, b) => a - b);
|
||||
if (values.length >= 2) {
|
||||
let minGap = 60;
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
const gap = values[i] - values[i - 1];
|
||||
if (gap < minGap) {
|
||||
minGap = gap;
|
||||
}
|
||||
}
|
||||
// Check wrap-around gap from last minute to first minute of next hour
|
||||
const wrapGap = values[0] + 60 - values[values.length - 1];
|
||||
if (wrapGap < minGap) {
|
||||
minGap = wrapGap;
|
||||
}
|
||||
return minGap;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle range expressions like 0-4 (runs every minute from 0-4)
|
||||
if (minutePart.includes("-") && !minutePart.includes("/")) {
|
||||
const [start, end] = minutePart.split("-").map(v => parseInt(v, 10));
|
||||
if (!isNaN(start) && !isNaN(end) && end > start) {
|
||||
// A range without step means every minute in that range
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// * means every minute
|
||||
if (minutePart === "*") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Single value or unrecognized pattern - assume hourly (60 min interval)
|
||||
return 60;
|
||||
}
|
||||
|
||||
export function isValidCron(cron: string): boolean {
|
||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||
|
||||
@@ -46,11 +118,7 @@ export function getCronDescription(cronspec: string): string | undefined {
|
||||
}
|
||||
|
||||
// Make first character lowercase
|
||||
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||
result +=
|
||||
"\n\nActions schedules run at most every 5 minutes." +
|
||||
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
||||
return result;
|
||||
return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||
}
|
||||
|
||||
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
||||
|
||||
@@ -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;
|
||||
@@ -155,7 +158,7 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
|
||||
const cron = schedule.value.assertString(`schedule cron`);
|
||||
// Validate the cron string
|
||||
if (!isValidCron(cron.value)) {
|
||||
context.error(cron, "Invalid cron string");
|
||||
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
||||
}
|
||||
result.push({cron: cron.value});
|
||||
} else {
|
||||
|
||||
@@ -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": {
|
||||
@@ -1215,6 +1264,13 @@
|
||||
"sequence-of-non-empty-string"
|
||||
]
|
||||
},
|
||||
"event-names": {
|
||||
"description": "Use the `names` filter when you want to include names via patterns or when you want to both include and exclude names using patterns. ",
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"sequence-of-non-empty-string"
|
||||
]
|
||||
},
|
||||
"event-tags": {
|
||||
"description": "Use the `tags` filter when you want to include tag name patterns or when you want to both include and exclude tag names patterns. You cannot use both the `tags` and `tags-ignore` filters for the same event in a workflow.",
|
||||
"one-of": [
|
||||
@@ -1243,6 +1299,13 @@
|
||||
"sequence-of-non-empty-string"
|
||||
]
|
||||
},
|
||||
"event-versions": {
|
||||
"description": "Use the `versions` filter when you want to include versions via patterns or when you want to both include and exclude versions using patterns. ",
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"sequence-of-non-empty-string"
|
||||
]
|
||||
},
|
||||
"repository-dispatch-string": {
|
||||
"description": "You can use the GitHub API to trigger a webhook event called `repository_dispatch` when you want to trigger a workflow for activity that happens outside of GitHub.",
|
||||
"string": {
|
||||
@@ -1515,6 +1578,10 @@
|
||||
"type": "permission-level-any",
|
||||
"description": "Actions workflows, workflow runs, and artifacts."
|
||||
},
|
||||
"artifact-metadata": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Storage and deployment records for build artifacts."
|
||||
},
|
||||
"attestations": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Artifact attestations."
|
||||
@@ -1543,6 +1610,10 @@
|
||||
"type": "permission-level-any",
|
||||
"description": "Issues and related comments, assignees, labels, and milestones."
|
||||
},
|
||||
"models": {
|
||||
"type": "permission-level-read-or-no-access",
|
||||
"description": "Call AI models with GitHub Models."
|
||||
},
|
||||
"packages": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Packages published to the GitHub Package Platform."
|
||||
@@ -1700,7 +1771,8 @@
|
||||
"concurrency": "job-concurrency",
|
||||
"outputs": "job-outputs",
|
||||
"defaults": "job-defaults",
|
||||
"steps": "steps"
|
||||
"steps": "steps",
|
||||
"snapshot": "snapshot"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1770,9 +1842,7 @@
|
||||
"cancelled(0,0)",
|
||||
"success(0,MAX)"
|
||||
],
|
||||
"string": {
|
||||
"is-expression": true
|
||||
}
|
||||
"string": {}
|
||||
},
|
||||
"job-if-result": {
|
||||
"context": [
|
||||
@@ -1844,6 +1914,41 @@
|
||||
"loose-value-type": "any"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "Use `snapshot` to define a custom image you want to create or update after your job succeeds by taking a snapshot of your runner.",
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"snapshot-mapping"
|
||||
]
|
||||
},
|
||||
"snapshot-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image-name": {
|
||||
"description": "The desired name of the custom image you want to create or update.",
|
||||
"type": "non-empty-string",
|
||||
"required": true
|
||||
},
|
||||
"if": "snapshot-if",
|
||||
"version": {
|
||||
"description": "The desired major version updates upon a new custom image version creation.",
|
||||
"type": "non-empty-string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshot-if": {
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"needs",
|
||||
"strategy",
|
||||
"matrix"
|
||||
],
|
||||
"description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
|
||||
"string": {}
|
||||
},
|
||||
"runs-on": {
|
||||
"description": "Use `runs-on` to define the type of machine to run the job on.\n* The destination machine can be either a GitHub-hosted runner, larger runner, or a self-hosted runner.\n* You can target runners based on the labels assigned to them, or their group membership, or a combination of these.\n* You can provide `runs-on` as a single string or as an array of strings.\n* If you specify an array of strings, your workflow will execute on any runner that matches all of the specified `runs-on` values.\n* If you would like to run your workflow on multiple machines, use `jobs.<job_id>.strategy`.",
|
||||
"context": [
|
||||
@@ -2107,9 +2212,7 @@
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"description": "Use the `if` conditional to prevent a step from running unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
|
||||
"string": {
|
||||
"is-expression": true
|
||||
}
|
||||
"string": {}
|
||||
},
|
||||
"step-if-result": {
|
||||
"context": [
|
||||
@@ -2514,4 +2617,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {getWorkflowSchema} from "./workflow-schema";
|
||||
|
||||
describe("workflow-schema", () => {
|
||||
it("loads successfully from workflow-root-strict entry point", () => {
|
||||
const schema = getWorkflowSchema();
|
||||
expect(schema).toBeDefined();
|
||||
|
||||
// Verify entry point exists
|
||||
const rootDef = schema.getDefinition("workflow-root-strict");
|
||||
expect(rootDef).toBeDefined();
|
||||
});
|
||||
|
||||
it("has all referenced definitions reachable from workflow-root-strict", () => {
|
||||
const schema = getWorkflowSchema();
|
||||
const definitions = schema.definitions;
|
||||
|
||||
// Collect all type references from all definitions
|
||||
const referencedTypes = new Set<string>();
|
||||
const definedTypes = new Set<string>();
|
||||
|
||||
for (const name of Object.keys(definitions)) {
|
||||
definedTypes.add(name);
|
||||
collectReferences(definitions[name], referencedTypes);
|
||||
}
|
||||
|
||||
// Every referenced type should be defined
|
||||
const missingDefinitions: string[] = [];
|
||||
for (const ref of referencedTypes) {
|
||||
// Skip built-in types
|
||||
if (isBuiltInType(ref)) continue;
|
||||
|
||||
if (!definedTypes.has(ref)) {
|
||||
missingDefinitions.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingDefinitions).toEqual([]);
|
||||
});
|
||||
|
||||
it("can resolve key workflow definitions", () => {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
// These are critical definitions that must exist
|
||||
const criticalDefinitions = [
|
||||
"workflow-root-strict",
|
||||
"jobs",
|
||||
"steps",
|
||||
"runs-on",
|
||||
"step-uses",
|
||||
"job-env",
|
||||
"step-env",
|
||||
];
|
||||
|
||||
for (const defName of criticalDefinitions) {
|
||||
const def = schema.getDefinition(defName);
|
||||
expect(def).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function collectReferences(obj: unknown, refs: Set<string>): void {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
if (typeof item === "string") refs.add(item);
|
||||
else collectReferences(item, refs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (["type", "item-type", "loose-key-type", "loose-value-type"].includes(key)) {
|
||||
if (typeof value === "string") refs.add(value);
|
||||
} else if (key === "one-of" || key === "properties" || key === "mapping" || key === "sequence") {
|
||||
collectReferences(value, refs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBuiltInType(typeName: string): boolean {
|
||||
const builtIns = ["null", "boolean", "number", "string", "sequence", "mapping", "any"];
|
||||
return builtIns.includes(typeName);
|
||||
}
|
||||
@@ -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.optimized.min.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