Compare commits
246 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5abd234cbf | |||
| 742b36d6b7 | |||
| 8507419ebf | |||
| 952dc89b78 | |||
| 2934e36944 | |||
| 8d2c24d7f5 | |||
| 4181cb3c90 | |||
| 78ea3ba17f | |||
| 4cf3365c68 | |||
| 1a63ee9de6 | |||
| 108b8c2766 | |||
| e20dbae803 | |||
| 69b383af3d | |||
| 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 | |||
| 751cb5a940 | |||
| a13e5cd088 | |||
| 1f3436c3ca | |||
| 880d3e4109 | |||
| 09fd00ed88 | |||
| 435a10d9b6 | |||
| 311a948ff0 | |||
| b0fd29ab60 | |||
| ccf95ef540 | |||
| e597a0c800 | |||
| 80c99e6e38 | |||
| 655d268694 | |||
| 756ce20db2 | |||
| 04b9c0c333 | |||
| ffef418dbc | |||
| e2ec264801 | |||
| ea15cac4e0 | |||
| 81db06000a | |||
| f0a24df8db | |||
| 7c0bffb677 | |||
| 6fedfd7fa4 | |||
| 8725c3c1c6 | |||
| 977d0ea9cd | |||
| 48247b8730 | |||
| bdee101604 | |||
| 7a41cd9e66 | |||
| 0d97e79d94 | |||
| 50b08a3a22 | |||
| f02e9593c2 | |||
| 3a8c29c2df | |||
| e6e3bb41e2 | |||
| b147158840 | |||
| 1b970c131f | |||
| 83bddd3332 | |||
| 53e3f1755d | |||
| 0751d266c2 | |||
| 4f4d671d85 | |||
| af7626066f | |||
| da50e32283 | |||
| f22ec34cdf | |||
| 950407cc05 | |||
| 04f923e2dc | |||
| 50bd1ab3b1 | |||
| 879aceaab3 | |||
| 5aa45f9482 | |||
| 02075a6585 | |||
| e9ca4c3e91 | |||
| 7c18d8fae8 | |||
| 657b14fd19 | |||
| c4ff28c60e | |||
| 9f3c3a8291 | |||
| 908852d57c | |||
| bf04ee63c7 | |||
| 795dd67915 | |||
| f809d5f89b | |||
| e42b020521 | |||
| acdfbcc609 | |||
| 5dbaa884db | |||
| 67d9f06795 | |||
| dec1281c4c | |||
| 4d64772250 | |||
| b200bedbce | |||
| 62cf97a1fd | |||
| c9aeb8d597 | |||
| 26d8080e56 | |||
| a1d07a6ffe | |||
| e6ce85f61f | |||
| c9afb14da5 | |||
| fe696132cf | |||
| 026f4e3ece | |||
| 098e785c13 | |||
| b0c2dec02f | |||
| def4fb41a9 | |||
| 84335c7203 | |||
| 7e062aa16b | |||
| b67105b9b4 | |||
| 1b823ebe67 | |||
| 4280a967a8 | |||
| ded93b55e7 | |||
| f966106367 | |||
| aaa71be634 | |||
| 818a321069 | |||
| c4d2f35a55 | |||
| 6513b0d15d | |||
| 207cfa12c0 | |||
| 28ab3928fd | |||
| afbe42bffe | |||
| a324b8b9dc | |||
| 4f7d03ed0c | |||
| 78acb30a9c | |||
| 4ddbbc9db7 | |||
| 3ea2cf1829 | |||
| 2c30f2f45f | |||
| cf2d9cd0b9 | |||
| 8f2f59092e | |||
| 5de89b0f8e | |||
| f4afa48ea4 | |||
| af5dd4b91e | |||
| 12d28370dc | |||
| 833b6fcac5 | |||
| cd7fabeb7f | |||
| 26da52bdf8 | |||
| 31aa95fb10 | |||
| b912482163 | |||
| 41436c6570 | |||
| 468b68840b | |||
| 57a77551b0 | |||
| a34a500176 | |||
| d7ae6f88f1 | |||
| 588c457cea | |||
| 82985934af | |||
| cb3ec583e0 | |||
| 7c3b116b19 | |||
| 4a6134be6c | |||
| 0080226132 | |||
| 896f780991 | |||
| 63b170f2a6 | |||
| 94451fa8f2 | |||
| 41e05b8ad1 | |||
| 5362fb1841 | |||
| 4e1f7cd9ac | |||
| 5c785ab41b | |||
| 9d246960f3 | |||
| eefd820cc5 | |||
| 58712f4d46 | |||
| 0bd67083ff | |||
| 40c20d5504 | |||
| c6cde72b37 | |||
| d47636092a | |||
| e292f8ca51 | |||
| 8f4080074b | |||
| b04e5db100 | |||
| 2795997f4c | |||
| 413ae51185 | |||
| 8bc0c5636e | |||
| 58bf3b35cc | |||
| 124ee84d1f | |||
| c4d478d459 | |||
| 9945ec321b | |||
| 5cb4007629 | |||
| 07fa29649e | |||
| 7bb2962bb0 | |||
| 3904c64796 | |||
| 48ad5e5251 | |||
| 317c4fcd63 | |||
| bf97052855 | |||
| dd8930fd74 | |||
| 2449e5cea1 | |||
| dba3cf5d96 | |||
| 804f83828f | |||
| cf2fd6332f | |||
| 51649f27f8 | |||
| 74db91e276 | |||
| a1d81c730f | |||
| 18d1bd9734 | |||
| fc6a1d3e0c | |||
| 6df34a78ce | |||
| aee5c2b919 | |||
| 8cdfe810db | |||
| 17a680df41 | |||
| 11d3fc25ee | |||
| d2783ed733 | |||
| 6d0f74e38b | |||
| 2ecbeafacb | |||
| a4d3fb1a3e | |||
| 83cac82450 | |||
| fc2bacfcdc | |||
| 7d8a7c11a6 | |||
| e37d7620d6 | |||
| 6e8cbc3e8c | |||
| d0916938ce | |||
| 488879804f | |||
| 736dd1a66c | |||
| d58deaf097 | |||
| f4a32c43cf | |||
| 5aed7594cf | |||
| 81094dc942 | |||
| f29fffce7e | |||
| 8fe871750e | |||
| 709d0d73c6 | |||
| febac16edd | |||
| 1ffef93f4c | |||
| 41b8fa9231 | |||
| 053accfafc | |||
| fe25433a45 | |||
| 0ee008991d | |||
| cf4dce7f71 | |||
| 4b479b0296 | |||
| 53f5a4ce69 | |||
| d08fed3cf5 | |||
| d5ef2f1539 | |||
| fb962e6c47 | |||
| e3db56d6ed | |||
| 1727735bd4 |
+1
-1
@@ -1 +1 @@
|
||||
* @actions/actions-experience
|
||||
* @actions/actions-vscode-reviewers
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directories:
|
||||
- "/"
|
||||
- "/languageservice"
|
||||
- "/languageserver"
|
||||
- "expressions"
|
||||
- "browser-playground"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,4 +1,6 @@
|
||||
name: Build & Test
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,18 +12,55 @@ 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
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
name: Create release PR
|
||||
|
||||
run-name: Create release PR for v${{ github.event.inputs.version }}
|
||||
run-name: Create release PR for new ${{ github.event.inputs.version }} version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
description: "Version to bump `package.json` to (format: x.y.z)"
|
||||
type: choice
|
||||
description: "What type of release is this"
|
||||
options:
|
||||
- "major"
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
jobs:
|
||||
create-release-pr:
|
||||
@@ -20,9 +25,9 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "16"
|
||||
|
||||
@@ -31,21 +36,27 @@ jobs:
|
||||
git config --global user.email "github-actions@github.com"
|
||||
git config --global user.name "GitHub Actions"
|
||||
|
||||
git checkout -b release/${{ inputs.version }}
|
||||
NEW_VERSION=$(./script/workflows/increment-version.sh ${{ inputs.version }})
|
||||
|
||||
npx lerna version ${{ inputs.version }} --yes --no-push --no-git-tag-version --force-publish
|
||||
git checkout -b release/$NEW_VERSION
|
||||
|
||||
npx lerna version $NEW_VERSION --yes --no-push --no-git-tag-version --force-publish
|
||||
|
||||
git add **/package.json package-lock.json lerna.json
|
||||
git commit -m "Release extension version ${{ inputs.version }}"
|
||||
git commit -m "Release extension version $NEW_VERSION"
|
||||
|
||||
git push --set-upstream origin release/${{ inputs.version }}
|
||||
git push --set-upstream origin release/$NEW_VERSION
|
||||
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Create PR
|
||||
run: |
|
||||
LAST_PR=$(gh pr list --repo ${{ github.repository }} --limit 1 --state merged --search "Release version" --json number | jq -r '.[0].number')
|
||||
./script/workflows/generate-release-notes.sh $LAST_PR ${{ env.new_version }}
|
||||
gh pr create \
|
||||
--title "Release version ${{ inputs.version }}" \
|
||||
--body "Release version ${{ inputs.version }}" \
|
||||
--title "Release version ${{ env.new_version }}" \
|
||||
--body-file releasenotes.md \
|
||||
--base main \
|
||||
--head release/${{ inputs.version }}
|
||||
--head release/${{ env.new_version }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check if version has changed
|
||||
id: check-version
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const version = '${{ inputs.version }}' || require('./lerna.json').version;
|
||||
@@ -65,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");
|
||||
|
||||
+10
-2
@@ -1,5 +1,13 @@
|
||||
*/node_modules
|
||||
*/dist
|
||||
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
# Intermediate JSON for size comparison (generated by update-webhooks --all)
|
||||
*.all.json
|
||||
*.drop.json
|
||||
*.strip.json
|
||||
+10
-2
@@ -8,6 +8,8 @@ Hi there! We're thrilled that you'd like to contribute to this project. Your hel
|
||||
|
||||
We accept pull requests for bug fixes and features where we've discussed the approach in an issue and given the go-ahead for a community member to work on it. We'd also love to hear about ideas for new features as issues.
|
||||
|
||||
We track issues on our project board [here](https://github.com/orgs/github/projects/9557/views/1).
|
||||
|
||||
Please do:
|
||||
|
||||
* Check existing issues to verify that the [bug][bug issues] or [feature request][feature request issues] has not already been submitted.
|
||||
@@ -21,7 +23,7 @@ Please avoid:
|
||||
|
||||
* Opening pull requests for issues marked `needs-design`, `needs-investigation`, or `blocked`.
|
||||
|
||||
Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md).
|
||||
Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE).
|
||||
|
||||
Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms.
|
||||
|
||||
@@ -60,4 +62,10 @@ Please also look at the `README.md` files for each package for additional notes
|
||||
|
||||
- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/)
|
||||
- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/)
|
||||
- [GitHub Help](https://help.github.com)
|
||||
- [GitHub Help](https://help.github.com)
|
||||
|
||||
|
||||
[bug issues]: https://github.com/actions/languageservices/labels/bug
|
||||
[feature request issues]: https://github.com/actions/languageservices/labels/enhancement
|
||||
[hw]: https://github.com/actions/languageservices/labels/help%20wanted
|
||||
[gfi]: https://github.com/actions/languageservices/labels/good%20first%20issue
|
||||
|
||||
@@ -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,197 @@
|
||||
# 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
|
||||
2. **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/objects.json` | Deduplicated shared object definitions referenced by webhooks |
|
||||
| `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` and `objects.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. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
|
||||
5. **Deduplicates** shared object definitions into `objects.json`
|
||||
6. 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/script/webhooks/index.ts`:
|
||||
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
|
||||
- Add to `DROPPED_EVENTS` 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"
|
||||
```
|
||||
|
||||
## CI Verification
|
||||
|
||||
CI verifies that generated source files are up-to-date:
|
||||
|
||||
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.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_EVENTS` in `script/webhooks/index.ts` for the full list.
|
||||
|
||||
## Stripped Fields
|
||||
|
||||
Unused fields are stripped to reduce bundle size. For example:
|
||||
|
||||
```json
|
||||
// Before (from webhooks.all.json)
|
||||
{
|
||||
"type": "object",
|
||||
"name": "issue",
|
||||
"in": "body",
|
||||
"description": "The issue itself.",
|
||||
"isRequired": true,
|
||||
"childParamsGroups": [...]
|
||||
}
|
||||
|
||||
// After (webhooks.json)
|
||||
{
|
||||
"name": "issue",
|
||||
"description": "The issue itself.",
|
||||
"childParamsGroups": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
|
||||
|
||||
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
|
||||
|
||||
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
|
||||
|
||||
## Schema Synchronization
|
||||
|
||||
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
|
||||
|
||||
### When the Test Fails
|
||||
|
||||
If the schema-sync test fails, you'll see an error like:
|
||||
|
||||
```
|
||||
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
|
||||
```
|
||||
|
||||
**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) to verify the activity type is a valid workflow trigger:
|
||||
- Find the event section (e.g., "pull_request")
|
||||
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
|
||||
- If the type is listed there, it's a valid workflow trigger
|
||||
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
|
||||
|
||||
2. If it IS a valid workflow trigger:
|
||||
- Edit `workflow-parser/src/workflow-v1.0.json`
|
||||
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
|
||||
- Add the new activity type to `allowed-values`
|
||||
- Update the `description` in `<event>-activity` to list all types
|
||||
- Run `npm test` to regenerate the minified JSON
|
||||
|
||||
3. If it is NOT a valid workflow trigger (webhook-only):
|
||||
- Edit `workflow-parser/src/schema-sync.test.ts`
|
||||
- Add the type to `WEBHOOK_ONLY` for that event
|
||||
|
||||
### Known Discrepancies
|
||||
|
||||
The test tracks several types of known discrepancies:
|
||||
|
||||
| Category | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
|
||||
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
|
||||
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
|
||||
|
||||
### Bidirectional Checking
|
||||
|
||||
The test checks both directions:
|
||||
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
|
||||
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.25",
|
||||
"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/**/*"
|
||||
|
||||
@@ -32,7 +32,7 @@ export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||
return this.eval(this.n);
|
||||
}
|
||||
|
||||
private eval(n: Expr): data.ExpressionData {
|
||||
protected eval(n: Expr): data.ExpressionData {
|
||||
return n.accept(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.25",
|
||||
"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.0",
|
||||
"@actions/workflow-parser": "^0.3.0",
|
||||
"@octokit/rest": "^19.0.7",
|
||||
"@actions/languageservice": "^0.3.25",
|
||||
"@actions/workflow-parser": "^0.3.25",
|
||||
"@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/**/*"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
|
||||
export function getClient(token: string, userAgent?: string): Octokit {
|
||||
export function getClient(token: string, userAgent?: string, apiUrl?: string): Octokit {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
userAgent: userAgent || `GitHub Actions Language Server`
|
||||
userAgent: userAgent || `GitHub Actions Language Server`,
|
||||
baseUrl: apiUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function initConnection(connection: Connection) {
|
||||
const options = params.initializationOptions as InitializationOptions;
|
||||
|
||||
if (options.sessionToken) {
|
||||
client = getClient(options.sessionToken, options.userAgent);
|
||||
client = getClient(options.sessionToken, options.userAgent, options.gitHubApiUrl);
|
||||
}
|
||||
|
||||
if (options.repos) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
|
||||
describe("contextProviders", () => {
|
||||
const mockCache = new TTLCache();
|
||||
const mockRepo: RepositoryContext = {
|
||||
id: 123,
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
organizationOwned: true,
|
||||
workspaceUri: "file:///workspace"
|
||||
};
|
||||
const mockWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when client is undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const defaultContext = new DescriptionDictionary();
|
||||
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
|
||||
|
||||
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBe(defaultContext);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return undefined for other contexts like steps", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when both client and repo are undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,18 @@ export function contextProviders(
|
||||
cache: TTLCache
|
||||
): ContextProviderConfig {
|
||||
if (!repo || !client) {
|
||||
return {getContext: () => Promise.resolve(undefined)};
|
||||
// When GitHub client/repo is unavailable, return an incomplete dictionary
|
||||
// to avoid false "Context access might be invalid" warnings
|
||||
return {
|
||||
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
|
||||
if (name === "secrets" || name === "vars") {
|
||||
const context = defaultContext || new DescriptionDictionary();
|
||||
context.complete = false;
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getContext = async (
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function getSecrets(
|
||||
}
|
||||
|
||||
const eventsConfig = workflowContext?.template?.events;
|
||||
|
||||
if (eventsConfig?.workflow_call) {
|
||||
// Unpredictable secrets may be passed in via a workflow_call trigger
|
||||
secretsContext.complete = false;
|
||||
@@ -38,6 +39,7 @@ export async function getSecrets(
|
||||
}
|
||||
|
||||
let environmentName: string | undefined;
|
||||
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
environmentName = workflowContext.job.environment.value;
|
||||
@@ -46,10 +48,17 @@ export async function getSecrets(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic environment, in those situations we
|
||||
// want to make sure we skip doing secret validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +125,7 @@ async function getRemoteSecrets(
|
||||
environmentSecrets:
|
||||
(environmentName &&
|
||||
(await cache.get(`${repo.owner}/${repo.name}/secrets/environment/${environmentName}`, undefined, () =>
|
||||
fetchEnvironmentSecrets(octokit, repo.id, environmentName)
|
||||
fetchEnvironmentSecrets(octokit, repo.owner, repo.name, environmentName)
|
||||
))) ||
|
||||
[],
|
||||
orgSecrets: await cache.get(`${repo.owner}/secrets`, undefined, () => fetchOrganizationSecrets(octokit, repo))
|
||||
@@ -142,14 +151,16 @@ async function fetchSecrets(octokit: Octokit, owner: string, name: string): Prom
|
||||
|
||||
async function fetchEnvironmentSecrets(
|
||||
octokit: Octokit,
|
||||
repositoryId: number,
|
||||
owner: string,
|
||||
name: string,
|
||||
environmentName: string
|
||||
): Promise<StringData[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listEnvironmentSecrets,
|
||||
{
|
||||
repository_id: repositoryId,
|
||||
owner,
|
||||
repo: name,
|
||||
environment_name: environmentName,
|
||||
per_page: 100
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
@@ -63,6 +63,43 @@ it("returns default context when job is undefined", async () => {
|
||||
expect(stepsContext).toEqual(defaultContext);
|
||||
});
|
||||
|
||||
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const workflowContext = await createWorkflowContext(workflow, "build");
|
||||
const defaultContext = getDefaultStepsContext(workflowContext);
|
||||
|
||||
const stepsContext = await getStepsContext(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
defaultContext,
|
||||
workflowContext
|
||||
);
|
||||
|
||||
// Get the step context
|
||||
const stepContext = stepsContext?.get("cache-primes");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
|
||||
// Get the outputs - should be a dictionary, not null
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
|
||||
// Outputs should be marked incomplete to allow dynamic outputs
|
||||
const outputsDict = outputs as DescriptionDictionary;
|
||||
expect(outputsDict.complete).toBe(false);
|
||||
|
||||
// Known outputs from action.yml should be present
|
||||
expect(outputsDict.get("cache-hit")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds action outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
@@ -83,29 +120,34 @@ it("adds action outputs", async () => {
|
||||
);
|
||||
expect(stepsContext).toBeDefined();
|
||||
|
||||
// Create expected outputs dict with complete = false
|
||||
// (actions can have dynamic outputs beyond what's declared in action.yml)
|
||||
const expectedOutputs = new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
});
|
||||
expectedOutputs.complete = false;
|
||||
|
||||
expect(stepsContext).toEqual(
|
||||
new DescriptionDictionary({
|
||||
key: "cache-primes",
|
||||
value: new DescriptionDictionary(
|
||||
{
|
||||
key: "outputs",
|
||||
value: new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
})
|
||||
value: expectedOutputs
|
||||
},
|
||||
{
|
||||
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`."
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -58,6 +58,8 @@ export async function getStepsContext(
|
||||
continue;
|
||||
}
|
||||
const outputsDict = new DescriptionDictionary();
|
||||
// Actions can have dynamic outputs beyond what's declared in action.yml
|
||||
outputsDict.complete = false;
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
outputsDict.add(key, new data.StringData(value.description), value.description);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {Pair} from "@actions/expressions/data/expressiondata";
|
||||
import {StringData} from "@actions/expressions/data/index";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {warn} from "@actions/languageservice/log";
|
||||
import {log, warn} from "@actions/languageservice/log";
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RequestError} from "@octokit/request-error";
|
||||
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
@@ -25,6 +26,8 @@ export async function getVariables(
|
||||
return secretsContext;
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
|
||||
let environmentName: string | undefined;
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
@@ -34,58 +37,71 @@ export async function getVariables(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic environment, in those situations we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
try {
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
|
||||
// Build combined map of variables
|
||||
const variablesMap = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: data.StringData;
|
||||
description?: string;
|
||||
}
|
||||
>();
|
||||
// Build combined map of variables
|
||||
const variablesMap = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: data.StringData;
|
||||
description?: string;
|
||||
}
|
||||
>();
|
||||
|
||||
variables.organizationVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Organization variable`
|
||||
})
|
||||
);
|
||||
variables.organizationVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Organization variable`
|
||||
})
|
||||
);
|
||||
|
||||
// Override org variables with repo variables
|
||||
variables.repoVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Repository variable`
|
||||
})
|
||||
);
|
||||
// Override org variables with repo variables
|
||||
variables.repoVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Repository variable`
|
||||
})
|
||||
);
|
||||
|
||||
// Override repo variables with environment veriables (if defined)
|
||||
variables.environmentVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Variable for environment \`${environmentName || ""}\``
|
||||
})
|
||||
);
|
||||
// Override repo variables with environment veriables (if defined)
|
||||
variables.environmentVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Variable for environment \`${environmentName || ""}\``
|
||||
})
|
||||
);
|
||||
|
||||
// Sort variables by key and add to context
|
||||
Array.from(variablesMap.values())
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description));
|
||||
// Sort variables by key and add to context
|
||||
Array.from(variablesMap.values())
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description));
|
||||
|
||||
return variablesContext;
|
||||
return variablesContext;
|
||||
} catch (e) {
|
||||
if (!(e instanceof RequestError)) throw e;
|
||||
if (e.name == "HttpError" && e.status == 404) {
|
||||
log("Failure to request variables. Ignore if you're using GitHub Enterprise Server below version 3.8");
|
||||
return variablesContext;
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemoteVariables(
|
||||
@@ -106,7 +122,7 @@ export async function getRemoteVariables(
|
||||
environmentVariables:
|
||||
(environmentName &&
|
||||
(await cache.get(`${repo.owner}/${repo.name}/vars/environment/${environmentName}`, undefined, () =>
|
||||
fetchEnvironmentVariables(octokit, repo.id, environmentName)
|
||||
fetchEnvironmentVariables(octokit, repo.owner, repo.name, environmentName)
|
||||
))) ||
|
||||
[],
|
||||
organizationVariables: await cache.get(`${repo.owner}/vars`, undefined, () =>
|
||||
@@ -137,14 +153,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
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import path from "path";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
|
||||
export function getFileProvider(
|
||||
client: Octokit | undefined,
|
||||
@@ -31,7 +31,10 @@ export function getFileProvider(
|
||||
throw new Error("Local file references are not supported with this configuration");
|
||||
}
|
||||
|
||||
const file = await readFile(path.join(workspace, ref.path));
|
||||
const workspaceURI = vscodeURI.URI.parse(workspace);
|
||||
const refURI = vscodeURI.Utils.joinPath(workspaceURI, ref.path);
|
||||
const file = await readFile(refURI.toString());
|
||||
|
||||
if (!file) {
|
||||
throw new Error(`File not found: ${ref.path}`);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface InitializationOptions {
|
||||
* Desired log level
|
||||
*/
|
||||
logLevel?: LogLevel;
|
||||
|
||||
/**
|
||||
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
||||
*/
|
||||
gitHubApiUrl?: string;
|
||||
}
|
||||
|
||||
export interface RepositoryContext {
|
||||
|
||||
@@ -16,7 +16,7 @@ inputs:
|
||||
description: 'Repository name with owner. For example, actions/checkout'
|
||||
deprecationMessage: 'Use repository instead'
|
||||
runs:
|
||||
using: node16
|
||||
using: node24
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ inputs:
|
||||
description: Repository name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node16
|
||||
using: node24
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
@@ -231,7 +231,7 @@ inputs:
|
||||
description: 📦 Repository 📦 name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node16
|
||||
using: node24
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.25",
|
||||
"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/objects.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/index.ts",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.0",
|
||||
"@actions/workflow-parser": "^0.3.0",
|
||||
"@actions/expressions": "^0.3.25",
|
||||
"@actions/workflow-parser": "^0.3.25",
|
||||
"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,12 +1,191 @@
|
||||
import {promises as fs} from "fs";
|
||||
import Webhook from "./webhook.js";
|
||||
|
||||
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json" assert {type: "json"};
|
||||
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json";
|
||||
import {deduplicateWebhooks} from "./deduplicate.js";
|
||||
const schema = schemaImport as any;
|
||||
|
||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
||||
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
|
||||
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
|
||||
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
|
||||
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
|
||||
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
|
||||
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
|
||||
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
|
||||
|
||||
// Parse --all flag
|
||||
const generateAll = process.argv.includes("--all");
|
||||
|
||||
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
const DROPPED_EVENTS = new Set([
|
||||
"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"
|
||||
]);
|
||||
|
||||
// Events to keep - valid workflow triggers
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
const KEPT_EVENTS = new Set([
|
||||
"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"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fields to strip from the JSON data.
|
||||
*
|
||||
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
|
||||
* Example event action object before stripping:
|
||||
* {
|
||||
* "description": "This event is triggered when...", // <-- stripped
|
||||
* "summary": "A brief summary", // <-- stripped
|
||||
* "availability": ["repository"], // <-- stripped
|
||||
* "category": "issues", // <-- stripped
|
||||
* "action": "opened", // kept
|
||||
* "bodyParameters": [...] // kept
|
||||
* }
|
||||
*
|
||||
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
|
||||
* Example bodyParameter object before stripping:
|
||||
* {
|
||||
* "type": "object", // <-- stripped
|
||||
* "name": "changes", // kept (used for property names)
|
||||
* "in": "body", // <-- stripped
|
||||
* "description": "The changes that were made.", // kept (used for hover docs)
|
||||
* "isRequired": true, // <-- stripped
|
||||
* "enum": ["a", "b"], // <-- stripped
|
||||
* "default": "a", // <-- stripped
|
||||
* "childParamsGroups": [ // kept (used for nested properties)
|
||||
* {
|
||||
* "type": "string", // <-- stripped (recursive)
|
||||
* "name": "from", // kept
|
||||
* "isRequired": true // <-- stripped (recursive)
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
|
||||
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
|
||||
|
||||
/**
|
||||
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
|
||||
*/
|
||||
function stripBodyParam(param: any): any {
|
||||
if (typeof param !== "object" || param === null) {
|
||||
return param;
|
||||
}
|
||||
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(param)) {
|
||||
if (BODY_PARAM_FIELDS.includes(key)) {
|
||||
continue; // Strip this field
|
||||
}
|
||||
if (key === "childParamsGroups" && Array.isArray(value)) {
|
||||
result[key] = value.map(stripBodyParam);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unused fields from event action data.
|
||||
*/
|
||||
function stripEventActionFields(action: any): any {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(action)) {
|
||||
if (EVENT_ACTION_FIELDS.includes(key)) {
|
||||
continue; // Strip this field
|
||||
}
|
||||
if (key === "bodyParameters" && Array.isArray(value)) {
|
||||
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unused fields from all webhooks.
|
||||
* Structure: { eventName: { actionName: { ...fields } } }
|
||||
*/
|
||||
function stripFields(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] = stripEventActionFields(actionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
||||
if (!rawWebhooks) {
|
||||
@@ -20,11 +199,51 @@ for (const webhook of Object.values(rawWebhooks)) {
|
||||
|
||||
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_EVENTS or KEPT_EVENTS in:");
|
||||
console.error(" languageservice/script/webhooks/index.ts");
|
||||
console.error("");
|
||||
console.error(" 3. See docs/json-data-files.md for more details.");
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -33,7 +252,59 @@ for (const webhook of webhooks) {
|
||||
}
|
||||
}
|
||||
|
||||
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
|
||||
// Strip fields before deduplication
|
||||
const strippedWebhooks = stripFields(categorizedWebhooks);
|
||||
|
||||
// Deduplicate after dropping and stripping
|
||||
const objectsArray = deduplicateWebhooks(strippedWebhooks);
|
||||
|
||||
// Write optimized output
|
||||
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
|
||||
|
||||
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
|
||||
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
|
||||
|
||||
// Optionally generate intermediate versions for size comparison
|
||||
if (generateAll) {
|
||||
// Helper to deep clone
|
||||
function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// Build full webhooks (no drop, no strip) from fresh data
|
||||
const fullWebhooks: Record<string, Record<string, any>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
const w = clone(webhook);
|
||||
if (!w.action) w.action = "default";
|
||||
fullWebhooks[w.category] ||= {};
|
||||
fullWebhooks[w.category][w.action] = w;
|
||||
}
|
||||
|
||||
// Generate all version (no drop, no strip)
|
||||
const allWebhooks = clone(fullWebhooks);
|
||||
const allObjects = deduplicateWebhooks(allWebhooks);
|
||||
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
|
||||
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
|
||||
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
|
||||
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
|
||||
|
||||
// Generate drop-only version (drop events, no strip)
|
||||
const dropWebhooks = clone(fullWebhooks);
|
||||
for (const event of DROPPED_EVENTS) {
|
||||
delete dropWebhooks[event];
|
||||
}
|
||||
const dropObjects = deduplicateWebhooks(dropWebhooks);
|
||||
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
|
||||
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
|
||||
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
|
||||
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
|
||||
|
||||
// Generate strip-only version (strip fields, no drop)
|
||||
const stripWebhooks = stripFields(clone(fullWebhooks));
|
||||
const stripObjects = deduplicateWebhooks(stripWebhooks);
|
||||
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
|
||||
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
|
||||
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
|
||||
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.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
|
||||
});
|
||||
@@ -299,7 +299,16 @@ jobs:
|
||||
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"arch",
|
||||
"debug",
|
||||
"environment",
|
||||
"name",
|
||||
"os",
|
||||
"temp",
|
||||
"tool_cache",
|
||||
"workspace"
|
||||
]);
|
||||
});
|
||||
|
||||
describe("job if", () => {
|
||||
@@ -861,7 +870,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("strategy context", () => {
|
||||
it("strategy is not suggested when outside of a matrix job", async () => {
|
||||
it("strategy is suggested even when no strategy defined", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -875,7 +884,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
expect(result.map(x => x.label)).toContain("strategy");
|
||||
});
|
||||
|
||||
it("strategy is suggested within a matrix job", async () => {
|
||||
@@ -922,7 +931,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("matrix context", () => {
|
||||
it("matrix is not suggested when outside of a matrix job", async () => {
|
||||
it("matrix is suggested even when no strategy defined", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -936,7 +945,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
expect(result.map(x => x.label)).toContain("matrix");
|
||||
});
|
||||
|
||||
it("matrix is suggested within a matrix job", async () => {
|
||||
@@ -1123,10 +1132,12 @@ jobs:
|
||||
"github",
|
||||
"inputs",
|
||||
"job",
|
||||
"matrix",
|
||||
"needs",
|
||||
"runner",
|
||||
"secrets",
|
||||
"steps",
|
||||
"strategy",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
@@ -1268,7 +1279,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
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {complete} from "./complete";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("Issue #81 - multi-line if expression completion", () => {
|
||||
it("should complete in block scalar if with | (exact position)", async () => {
|
||||
// Exact reproduction from issue - cursor after "github." in block scalar
|
||||
const input = `on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 5 (0-indexed) = " github.", character 13 = after the dot
|
||||
const pos = {line: 5, character: 13};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
expect(result.map(x => x.label)).toContain("actor");
|
||||
});
|
||||
|
||||
it("should complete in block scalar if with > (exact position)", async () => {
|
||||
const input = `on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: >
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
const pos = {line: 5, character: 13};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete in block scalar with multiple lines", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete step if in block scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
if: |
|
||||
github.
|
||||
`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
|
||||
const pos = {line: 7, character: 15};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete in block scalar with ${{ expression markers", async () => {
|
||||
// This case works because transform() skips lines with ${{
|
||||
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
\${{
|
||||
github.ref == 'refs/heads/main' ||
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
|
||||
const pos = {line: 6, character: 15};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("ref");
|
||||
expect(result.map(x => x.label)).toContain("ref_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for getOffsetInContent", () => {
|
||||
it("should complete in single-line if (not block scalar)", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete on third content line of block scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete when block scalar has empty first line", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
|
||||
|
||||
@@ -4,8 +4,8 @@ import {complete} from "./complete";
|
||||
import {registerLogger} from "./log";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
import {TestLogger} from "./test-utils/logger";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(8);
|
||||
expect(result.length).toEqual(9);
|
||||
expect(result[0].label).toEqual("concurrency");
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(20);
|
||||
expect(result.length).toEqual(21);
|
||||
});
|
||||
|
||||
it("string definition completion in sequence", async () => {
|
||||
@@ -243,7 +243,7 @@ jobs:
|
||||
runs-|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(20);
|
||||
expect(result).toHaveLength(21);
|
||||
});
|
||||
|
||||
it("job key with comment afterwards", async () => {
|
||||
@@ -254,7 +254,7 @@ jobs:
|
||||
#`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(20);
|
||||
expect(result).toHaveLength(21);
|
||||
});
|
||||
|
||||
it("job key with other values afterwards", async () => {
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
concurrency: 'group-name'`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(19);
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("step key without space after colon", async () => {
|
||||
@@ -335,7 +335,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).toHaveLength(16);
|
||||
expect(result).toHaveLength(17);
|
||||
});
|
||||
|
||||
it("complete from behind a colon will replace it", async () => {
|
||||
@@ -348,7 +348,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).toHaveLength(16);
|
||||
expect(result).toHaveLength(17);
|
||||
const textEdit = result[0].textEdit as TextEdit;
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 5, character: 4},
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
expect(result.map(e => e.label)).toContain("runs-on");
|
||||
|
||||
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
|
||||
expect(textEdit.newText).toEqual("runs-on");
|
||||
expect(textEdit.newText).toEqual("runs-on: ");
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 3, character: 4},
|
||||
end: {line: 3, character: 10}
|
||||
@@ -421,7 +421,7 @@ jobs:
|
||||
expect(result.map(e => e.label)).toContain("runs-on");
|
||||
|
||||
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
|
||||
expect(textEdit.newText).toEqual("runs-on");
|
||||
expect(textEdit.newText).toEqual("runs-on: ");
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 3, character: 4},
|
||||
end: {line: 3, character: 4}
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
});
|
||||
|
||||
it("custom indentation", async () => {
|
||||
@@ -471,7 +471,50 @@ jobs:
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a new line and indentation for mapping keys when the key is given", async () => {
|
||||
const input = "concurrency: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
|
||||
"\n cancel-in-progress: "
|
||||
]);
|
||||
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
|
||||
});
|
||||
|
||||
it("does not add new line if no key in line", async () => {
|
||||
const input = "run-n|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
||||
});
|
||||
|
||||
it("adds new line for nested mapping", async () => {
|
||||
const input = "on:\n workflow_dispatch: in|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
|
||||
});
|
||||
|
||||
it("adds : for one-of", async () => {
|
||||
const input = "on:\n check_run:\n ty|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
|
||||
});
|
||||
|
||||
it("does not add : for one-of in key mode", async () => {
|
||||
const input = "on:\n check_run: ty|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
@@ -19,12 +20,11 @@ import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {guessIndentation} from "./utils/indentation-guesser";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {getRelCharOffset} from "./utils/rel-char-pos";
|
||||
import {isPlaceholder, transform} from "./utils/transform";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
import {definitionValues} from "./value-providers/definition";
|
||||
import {DefinitionValueMode, definitionValues} from "./value-providers/definition";
|
||||
|
||||
export function getExpressionInput(input: string, pos: number): string {
|
||||
// Find start marker around the cursor position
|
||||
@@ -180,7 +180,7 @@ async function getValues(
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = definitionValues(def, indentation);
|
||||
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
|
||||
@@ -238,12 +238,12 @@ function getExpressionCompletionItems(
|
||||
currentInput = stringToken.source || stringToken.value;
|
||||
}
|
||||
|
||||
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
|
||||
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[relCharOffset])
|
||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
);
|
||||
} catch (e) {
|
||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||
@@ -274,3 +274,50 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
|
||||
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a document position to an offset within the token's content string.
|
||||
*/
|
||||
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
|
||||
if (range.start.line === range.end.line) {
|
||||
// Single-line example:
|
||||
// if: github.ref == 'main'
|
||||
// ^8 ^15 (cursor)
|
||||
// currentInput = "github.ref == 'main'"
|
||||
// offset = 15 - 8 = 7
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
|
||||
// Multi-line example:
|
||||
// if: | <- line 3 (range.start.line)
|
||||
// first line <- line 4, content line 0
|
||||
// second line <- line 5, content line 1
|
||||
// github. <- line 6, content line 2, cursor at index 11
|
||||
// ^11 (cursor)
|
||||
//
|
||||
// currentInput = " first line\n second line\n github."
|
||||
// ^0 ^15 ^32 ^43
|
||||
|
||||
// Line index within content.
|
||||
// From the example:
|
||||
// lineIndexWithinContent = pos.line - range.start.line - 1
|
||||
// = 6 - 3 - 1 = 2
|
||||
const lineIndexWithinContent = pos.line - range.start.line - 1;
|
||||
|
||||
// Length of content before current line.
|
||||
// From the example:
|
||||
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
|
||||
// => 31 + 1 = 32 (after second iteration)
|
||||
let lengthOfContentBeforeCurrentLine = 0;
|
||||
for (let i = 0; i < lineIndexWithinContent; i++) {
|
||||
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
|
||||
}
|
||||
|
||||
// Final offset within content.
|
||||
// From the example:
|
||||
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
|
||||
// = 32 + 11 = 43
|
||||
return lengthOfContentBeforeCurrentLine + pos.character;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getContext, Mode} from "./default";
|
||||
|
||||
describe("getContext", () => {
|
||||
const emptyWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when no contextProviderConfig is provided", () => {
|
||||
it("should mark secrets context as incomplete", async () => {
|
||||
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext).toBeDefined();
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark vars context as incomplete", async () => {
|
||||
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext).toBeDefined();
|
||||
expect(varsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should not mark other contexts as incomplete", async () => {
|
||||
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const envContext = result.get("env") as DescriptionDictionary;
|
||||
const githubContext = result.get("github") as DescriptionDictionary;
|
||||
|
||||
// These contexts are derived from the workflow file, so they can be complete
|
||||
expect(envContext).toBeDefined();
|
||||
expect(envContext.complete).toBe(true);
|
||||
expect(githubContext).toBeDefined();
|
||||
expect(githubContext.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when contextProviderConfig returns a value", () => {
|
||||
it("should use the provided context for secrets", async () => {
|
||||
const providedContext = new DescriptionDictionary();
|
||||
providedContext.complete = true; // Provider fetched from API, so it's complete
|
||||
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets");
|
||||
expect(secretsContext).toBe(providedContext);
|
||||
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
|
||||
});
|
||||
|
||||
it("should use the provided context for vars", async () => {
|
||||
const providedContext = new DescriptionDictionary();
|
||||
providedContext.complete = true;
|
||||
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars");
|
||||
expect(varsContext).toBe(providedContext);
|
||||
expect((varsContext as DescriptionDictionary).complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when contextProviderConfig returns undefined", () => {
|
||||
it("should mark secrets as incomplete", async () => {
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark vars as incomplete", async () => {
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,15 +32,24 @@ export async function getContext(
|
||||
): Promise<DescriptionDictionary> {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
const filteredNames = filterContextNames(names, workflowContext);
|
||||
for (const contextName of filteredNames) {
|
||||
// All context names are valid - strategy and matrix are always available
|
||||
// (with default values when no strategy block is defined)
|
||||
for (const contextName of names) {
|
||||
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
|
||||
if (value.kind === Kind.Null) {
|
||||
context.add(contextName, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
|
||||
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||
if (remoteValue) {
|
||||
value = remoteValue;
|
||||
} else if (contextName === "secrets" || contextName === "vars") {
|
||||
// Without a context provider to fetch remote secrets/vars, we can't know
|
||||
// what values exist, so mark the context as incomplete to avoid false
|
||||
// "Context access might be invalid" warnings
|
||||
value.complete = false;
|
||||
}
|
||||
|
||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||
}
|
||||
@@ -74,11 +83,14 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
os: "Linux",
|
||||
arch: "X64",
|
||||
debug: "1",
|
||||
environment: "github-hosted",
|
||||
name: "GitHub Actions 2",
|
||||
os: "Linux",
|
||||
temp: "/home/runner/work/_temp",
|
||||
tool_cache: "/opt/hostedtoolcache",
|
||||
temp: "/home/runner/work/_temp"
|
||||
workspace: "/home/runner/work/repo"
|
||||
});
|
||||
|
||||
case "secrets":
|
||||
@@ -103,18 +115,3 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
|
||||
return contextNames.filter(name => {
|
||||
switch (name) {
|
||||
case "matrix":
|
||||
case "strategy":
|
||||
return hasStrategy(workflowContext);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function hasStrategy(workflowContext: WorkflowContext): boolean {
|
||||
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
|
||||
}
|
||||
|
||||
@@ -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,30 @@
|
||||
"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 [`ACTIONS_STEP_DEBUG`](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."
|
||||
},
|
||||
"environment": {
|
||||
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
|
||||
},
|
||||
"workspace": {
|
||||
"description": "The runner-specific working directory path for the job."
|
||||
}
|
||||
},
|
||||
"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,102 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
|
||||
import webhookObjects from "./objects.json";
|
||||
import webhooks from "./webhooks.json";
|
||||
import webhookObjects from "./objects.min.json";
|
||||
import webhooks from "./webhooks.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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
|
||||
import {getInputsContext} from "./inputs";
|
||||
|
||||
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-cwontext
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
const keys = [
|
||||
"action",
|
||||
"action_path",
|
||||
@@ -16,6 +16,7 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
"action_repository",
|
||||
"action_status",
|
||||
"actor",
|
||||
"actor_id",
|
||||
"api_url",
|
||||
"base_ref",
|
||||
"env",
|
||||
@@ -25,13 +26,16 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
"graphql_url",
|
||||
"head_ref",
|
||||
"job",
|
||||
"job_workflow_sha",
|
||||
"path",
|
||||
"ref",
|
||||
"ref_name",
|
||||
"ref_protected",
|
||||
"ref_type",
|
||||
"path",
|
||||
"repository",
|
||||
"repository_id",
|
||||
"repository_owner",
|
||||
"repository_owner_id",
|
||||
"repositoryUrl",
|
||||
"retention_days",
|
||||
"run_id",
|
||||
@@ -43,6 +47,8 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
"token",
|
||||
"triggering_actor",
|
||||
"workflow",
|
||||
"workflow_ref",
|
||||
"workflow_sha",
|
||||
"workspace"
|
||||
];
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("strategy not defined", () => {
|
||||
@@ -73,7 +73,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("strategy is not a mapping token", () => {
|
||||
@@ -81,7 +81,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeDefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("matrix is not defined", () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
return new DescriptionDictionary();
|
||||
// No strategy defined - matrix is null at runtime (not empty object)
|
||||
return new data.Null();
|
||||
}
|
||||
|
||||
const matrix = strategy.find("matrix");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getStepsContext} from "./steps";
|
||||
|
||||
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
|
||||
return {
|
||||
job: {
|
||||
steps: stepIds.map(id => ({id}))
|
||||
},
|
||||
step: currentStepId ? {id: currentStepId} : undefined
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("steps context", () => {
|
||||
it("returns empty dictionary when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty dictionary when no steps", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("includes steps with user-defined ids", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes generated step ids (starting with __)", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("__generated")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes current step and later steps", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeUndefined();
|
||||
expect(context.get("step-c")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("step outputs", () => {
|
||||
it("outputs is a dictionary, not null", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
});
|
||||
|
||||
it("outputs is marked incomplete to allow dynamic outputs", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a") as DescriptionDictionary;
|
||||
const outputs = stepContext.get("outputs") as DescriptionDictionary;
|
||||
|
||||
// Outputs should be incomplete since we can't know what outputs a step will produce
|
||||
expect(outputs.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
|
||||
// Step outputs are dynamic - actions can generate outputs based on their inputs
|
||||
const outputs = new DescriptionDictionary();
|
||||
outputs.complete = false;
|
||||
d.add("outputs", outputs, getDescription("steps", "outputs"));
|
||||
|
||||
// Can be "success", "failure", "cancelled", or "skipped"
|
||||
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {data} from "@actions/expressions";
|
||||
import {Job} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getStrategyContext} from "./strategy";
|
||||
|
||||
function stringToToken(value: string) {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function boolToToken(value: boolean) {
|
||||
return new BooleanToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function numberToToken(value: number) {
|
||||
return new NumberToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function contextFromStrategy(strategy?: TemplateToken) {
|
||||
return {
|
||||
job: {
|
||||
strategy: strategy
|
||||
}
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("strategy context", () => {
|
||||
describe("no strategy defined", () => {
|
||||
it("returns defaults when job is undefined", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is undefined", () => {
|
||||
const job = {} as Job;
|
||||
const workflowContext = {job} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is not a mapping", () => {
|
||||
const workflowContext = contextFromStrategy(stringToToken("hello"));
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy defined with partial properties", () => {
|
||||
it("uses specified fail-fast, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("uses specified max-parallel, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(5));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
|
||||
});
|
||||
|
||||
it("only has matrix defined, all strategy properties use defaults", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
const matrix = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("matrix"), matrix);
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy with all properties defined", () => {
|
||||
it("uses all specified values", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(3));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
// job-index and job-total are runtime values, not specified in YAML
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,24 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {scalarToData} from "../utils/scalar-to-data";
|
||||
|
||||
// Default strategy values when no strategy block is defined
|
||||
const DEFAULT_STRATEGY = {
|
||||
"fail-fast": new data.BooleanData(true),
|
||||
"job-index": new data.NumberData(0),
|
||||
"job-total": new data.NumberData(1),
|
||||
"max-parallel": new data.NumberData(1)
|
||||
};
|
||||
|
||||
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
||||
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
// No strategy defined - return defaults that match runtime behavior
|
||||
return new DescriptionDictionary(
|
||||
...keys.map(key => {
|
||||
return {key, value: new data.Null()};
|
||||
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -31,7 +40,8 @@ export function getStrategyContext(workflowContext: WorkflowContext): Descriptio
|
||||
|
||||
for (const key of keys) {
|
||||
if (!strategyContext.get(key)) {
|
||||
strategyContext.add(key, new data.Null());
|
||||
// Use default value for missing properties
|
||||
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {Evaluator, ExpressionEvaluationError, data} from "@actions/expressions";
|
||||
import {Expr, Logical} from "@actions/expressions/ast";
|
||||
import {ExpressionData} from "@actions/expressions/data/expressiondata";
|
||||
import {TokenType} from "@actions/expressions/lexer";
|
||||
import {falsy, truthy} from "@actions/expressions/result";
|
||||
import {AccessError} from "./error-dictionary";
|
||||
|
||||
export type ValidationError = {
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
};
|
||||
|
||||
export class ValidationEvaluator extends Evaluator {
|
||||
public readonly errors: ValidationError[] = [];
|
||||
|
||||
public validate() {
|
||||
super.evaluate();
|
||||
}
|
||||
|
||||
protected override eval(n: Expr): ExpressionData {
|
||||
try {
|
||||
return super.eval(n);
|
||||
} catch (e) {
|
||||
// Record error
|
||||
if (e instanceof AccessError) {
|
||||
this.errors.push({
|
||||
message: `Context access might be invalid: ${e.keyName}`,
|
||||
severity: "warning"
|
||||
});
|
||||
} else if (e instanceof ExpressionEvaluationError) {
|
||||
this.errors.push({
|
||||
message: `Expression might be invalid: ${e.message}`,
|
||||
severity: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return null but continue with the validation
|
||||
return new data.Null();
|
||||
}
|
||||
|
||||
override visitLogical(logical: Logical): ExpressionData {
|
||||
let result: data.ExpressionData | undefined;
|
||||
|
||||
for (const arg of logical.args) {
|
||||
const r = this.eval(arg);
|
||||
|
||||
// Simulate short-circuit behavior but continue to evalute all arguments for validation purposes
|
||||
if (
|
||||
!result &&
|
||||
((logical.operator.type === TokenType.AND && falsy(r)) || (logical.operator.type === TokenType.OR && truthy(r)))
|
||||
) {
|
||||
result = r;
|
||||
}
|
||||
}
|
||||
|
||||
// result is always assigned before we return here
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
import {Position} from "vscode-languageserver-textdocument";
|
||||
import {mapRange} from "./range";
|
||||
|
||||
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
if (range.start.line !== range.end.line) {
|
||||
const lines = currentInput.split("\n");
|
||||
const lineDiff = pos.line - range.start.line - 1;
|
||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
||||
return linesBeforeCusor.join("\n").length + pos.character + 1;
|
||||
} else {
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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 concurrency deadlock", () => {
|
||||
describe("should error on matching concurrency groups", () => {
|
||||
it("simple string match", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
|
||||
// Workflow-level warning
|
||||
expect(concurrencyErrors[0]).toMatchObject({
|
||||
message: "Concurrency group 'test' is also used by job 'job1'. This will cause a deadlock.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
|
||||
// Job-level warning
|
||||
expect(concurrencyErrors[1]).toMatchObject({
|
||||
message: "Concurrency group 'test' is also defined at the workflow level. This will cause a deadlock.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
});
|
||||
|
||||
it("workflow mapping form, job string form", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: my-group
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: my-group
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
expect(concurrencyErrors[0].message).toContain("my-group");
|
||||
expect(concurrencyErrors[0].message).toContain("deploy");
|
||||
});
|
||||
|
||||
it("workflow string form, job mapping form", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: deploy-group
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-group
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
expect(concurrencyErrors[0].message).toContain("deploy-group");
|
||||
});
|
||||
|
||||
it("both mapping forms", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: shared
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: shared
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("multiple jobs with matching concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: shared
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: shared
|
||||
steps:
|
||||
- run: echo hi
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: shared
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
// Should have 2 warnings per job (workflow + job) = 4 total, but workflow is only warned once per match
|
||||
// Actually: 1 workflow warning per matching job + 1 job warning per matching job = 4 total
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should not warn", () => {
|
||||
it("different concurrency groups", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: workflow-group
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: job-group
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("workflow concurrency is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: \${{ github.ref }}
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("job concurrency is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: \${{ github.ref }}
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no workflow-level concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no job-level concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("case sensitive - different case is different group", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: Test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("workflow concurrency group in mapping is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: \${{ github.ref }}
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,52 @@ jobs:
|
||||
]);
|
||||
});
|
||||
|
||||
it("access invalid context field in short-circuited expression", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on: push
|
||||
run-name: name-\${{ github.does-not-exist || github.does-not-exist2 }}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Context access might be invalid: does-not-exist",
|
||||
range: {
|
||||
end: {
|
||||
character: 69,
|
||||
line: 1
|
||||
},
|
||||
start: {
|
||||
character: 15,
|
||||
line: 1
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Warning
|
||||
},
|
||||
{
|
||||
message: "Context access might be invalid: does-not-exist2",
|
||||
range: {
|
||||
end: {
|
||||
character: 69,
|
||||
line: 1
|
||||
},
|
||||
start: {
|
||||
character: 15,
|
||||
line: 1
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Warning
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("partial skip access invalid context on incomplete", async () => {
|
||||
const contextProviderConfig: ContextProviderConfig = {
|
||||
getContext: (context: string) => {
|
||||
@@ -589,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 }}
|
||||
`;
|
||||
@@ -608,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 }}
|
||||
`;
|
||||
@@ -635,7 +681,8 @@ jobs:
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toEqual([]);
|
||||
// Strategy context is always available with default values
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("invalid strategy property", async () => {
|
||||
@@ -950,22 +997,8 @@ jobs:
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Context access might be invalid: matrix",
|
||||
range: {
|
||||
end: {
|
||||
character: 36,
|
||||
line: 8
|
||||
},
|
||||
start: {
|
||||
character: 18,
|
||||
line: 8
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Warning
|
||||
}
|
||||
]);
|
||||
// Matrix is null when no strategy is defined, accessing properties on null is valid
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("basic matrix", async () => {
|
||||
@@ -1459,4 +1492,216 @@ 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 runner.environment context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.environment == 'github-hosted'
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.debug context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.debug == '1'
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.workspace context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.workspace != ''
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Test validation behavior when no context providers are configured.
|
||||
*
|
||||
* When contextProviderConfig is not provided (or returns incomplete data),
|
||||
* we should skip validation for secrets/vars rather than showing false
|
||||
* positive "Context access might be invalid" warnings.
|
||||
*
|
||||
* This is important for offline/disconnected scenarios where API calls
|
||||
* to fetch secrets/vars are not possible.
|
||||
*/
|
||||
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("validation without context providers", () => {
|
||||
describe("secrets context", () => {
|
||||
it("should not warn on secrets.GITHUB_TOKEN", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on custom secrets when no provider configured", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
API_KEY: \${{ secrets.MY_API_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on secrets with environment", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
API_KEY: \${{ secrets.API_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vars context", () => {
|
||||
it("should not warn on vars when no provider configured", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "\${{ vars.ENVIRONMENT }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on vars with environment", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: echo "\${{ vars.API_URL }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on vars with fallback pattern", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "\${{ vars.OPTIONAL_VAR || 'default-value' }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined secrets and vars", () => {
|
||||
it("should not warn on workflow using both secrets and vars", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: |
|
||||
echo "Deploying to \${{ vars.API_URL }}"
|
||||
echo "Using region \${{ vars.AWS_REGION }}"
|
||||
env:
|
||||
API_KEY: \${{ secrets.API_KEY }}
|
||||
AWS_SECRET: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
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.
|
||||
@@ -295,4 +385,31 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow_dispatch", () => {
|
||||
it("allows empty string in choice options", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugin-name:
|
||||
description: Specific plugin to build
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- foo
|
||||
- bar
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+581
-25
@@ -1,7 +1,9 @@
|
||||
import {Evaluator, ExpressionEvaluationError, Lexer, Parser} from "@actions/expressions";
|
||||
import {Expr} from "@actions/expressions/ast";
|
||||
import {isBasicExpression, isString, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
|
||||
import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isMapping, 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";
|
||||
@@ -13,9 +15,10 @@ import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
|
||||
import {ActionMetadata, ActionReference} from "./action";
|
||||
import {ContextProviderConfig} from "./context-providers/config";
|
||||
import {getContext, Mode} from "./context-providers/default";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
|
||||
import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary";
|
||||
import {Mode, getContext} from "./context-providers/default";
|
||||
import {WorkflowContext, getWorkflowContext} from "./context/workflow-context";
|
||||
import {wrapDictionary} from "./expression-validation/error-dictionary";
|
||||
import {ValidationEvaluator} from "./expression-validation/evaluator";
|
||||
import {validatorFunctions} from "./expression-validation/functions";
|
||||
import {error} from "./log";
|
||||
import {findToken} from "./utils/find-token";
|
||||
@@ -25,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;
|
||||
@@ -103,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) {
|
||||
@@ -146,6 +209,9 @@ async function additionalValidations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate concurrency deadlock between workflow and job levels
|
||||
validateConcurrencyDeadlock(diagnostics, template);
|
||||
}
|
||||
|
||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
||||
@@ -162,6 +228,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,
|
||||
@@ -178,17 +595,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 {
|
||||
@@ -202,28 +701,85 @@ async function validateExpression(
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
||||
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
||||
|
||||
const e = new Evaluator(expr, wrapDictionary(context), validatorFunctions);
|
||||
e.evaluate();
|
||||
const e = new ValidationEvaluator(expr, wrapDictionary(context), validatorFunctions);
|
||||
e.validate();
|
||||
|
||||
// Any invalid context access would've thrown an error via the `ErrorDictionary`, for now we don't have to check the actual
|
||||
// result of the evaluation.
|
||||
} catch (e) {
|
||||
if (e instanceof AccessError) {
|
||||
diagnostics.push(
|
||||
...e.errors.map(e => ({
|
||||
message: e.message,
|
||||
range: mapRange(expression.range),
|
||||
severity: e.severity === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that workflow-level and job-level concurrency groups don't match,
|
||||
* which would cause a deadlock at runtime.
|
||||
*/
|
||||
function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
|
||||
const workflowGroup = getStaticConcurrencyGroup(template.concurrency);
|
||||
if (!workflowGroup) {
|
||||
return; // No workflow-level concurrency or it's an expression
|
||||
}
|
||||
|
||||
for (const job of template.jobs || []) {
|
||||
if (!job.concurrency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jobGroup = getStaticConcurrencyGroup(job.concurrency);
|
||||
if (!jobGroup) {
|
||||
continue; // Job concurrency is an expression
|
||||
}
|
||||
|
||||
if (workflowGroup.value === jobGroup.value) {
|
||||
// Error on workflow-level concurrency
|
||||
if (template.concurrency.range) {
|
||||
diagnostics.push({
|
||||
message: `Context access might be invalid: ${e.keyName}`,
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(expression.range)
|
||||
message: `Concurrency group '${workflowGroup.value}' is also used by job '${job.id.value}'. This will cause a deadlock.`,
|
||||
range: mapRange(template.concurrency.range),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
} else if (e instanceof ExpressionEvaluationError) {
|
||||
}
|
||||
|
||||
// Error on job-level concurrency
|
||||
if (job.concurrency.range) {
|
||||
diagnostics.push({
|
||||
message: `Expression might be invalid: ${e.message}`,
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(expression.range)
|
||||
message: `Concurrency group '${jobGroup.value}' is also defined at the workflow level. This will cause a deadlock.`,
|
||||
range: mapRange(job.concurrency.range),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the static concurrency group name from a concurrency token.
|
||||
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||
*/
|
||||
function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToken | undefined {
|
||||
if (!token || token.isExpression) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Simple string form: concurrency: "test"
|
||||
if (isString(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Mapping form: concurrency: { group: "test", cancel-in-progress: true }
|
||||
if (isMapping(token)) {
|
||||
for (const pair of token) {
|
||||
if (isString(pair.key) && pair.key.value === "group" && isString(pair.value) && !pair.value.isExpression) {
|
||||
return pair.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("YAML anchors and aliases", () => {
|
||||
it("should handle anchors and aliases in env", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: &env
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *env
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple aliases to the same anchor", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
env: &shared
|
||||
SHARED: true
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job3:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in matrix strategy", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: &matrix-include
|
||||
- os: ubuntu-latest
|
||||
node: 18
|
||||
- os: windows-latest
|
||||
node: 20
|
||||
steps:
|
||||
- run: exit 0
|
||||
test2:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: *matrix-include
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in steps", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &checkout
|
||||
uses: actions/checkout@v4
|
||||
- run: npm test
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- *checkout
|
||||
- run: npm run deploy
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle scalar anchors", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: &runner ubuntu-latest
|
||||
steps:
|
||||
- run: exit 0
|
||||
test:
|
||||
runs-on: *runner
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should work without anchors (control test)", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle circular aliases without hanging", async () => {
|
||||
// This is an invalid use case (alias referencing parent) but should not hang
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: &myenv
|
||||
FOO: bar
|
||||
nested: *myenv
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
// Should complete without hanging - circular portion is silently ignored
|
||||
// which may cause downstream validation errors, but that's acceptable
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle undefined alias references", async () => {
|
||||
// Reference to non-existent anchor - yaml library should report error
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: *nonexistent
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -9,15 +9,30 @@ import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-sch
|
||||
import {Value} from "./config";
|
||||
import {stringsToValues} from "./strings-to-values";
|
||||
|
||||
export function definitionValues(def: Definition, indentation: string): Value[] {
|
||||
export enum DefinitionValueMode {
|
||||
/**
|
||||
* We're getting completion options for a parent token
|
||||
* foo:
|
||||
* ba|
|
||||
*/
|
||||
Parent,
|
||||
|
||||
/**
|
||||
* We're getting completion options for a key token. For example:
|
||||
* foo: |
|
||||
*/
|
||||
Key
|
||||
}
|
||||
|
||||
export function definitionValues(def: Definition, indentation: string, mode: DefinitionValueMode): Value[] {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
if (def instanceof MappingDefinition) {
|
||||
return mappingValues(def, schema.definitions, indentation);
|
||||
return mappingValues(def, schema.definitions, indentation, mode);
|
||||
}
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
return oneOfValues(def, schema.definitions, indentation);
|
||||
return oneOfValues(def, schema.definitions, indentation, mode);
|
||||
}
|
||||
|
||||
if (def instanceof BooleanDefinition) {
|
||||
@@ -36,7 +51,7 @@ export function definitionValues(def: Definition, indentation: string): Value[]
|
||||
if (def instanceof SequenceDefinition) {
|
||||
const itemDef = schema.getDefinition(def.itemType);
|
||||
if (itemDef) {
|
||||
return definitionValues(itemDef, indentation);
|
||||
return definitionValues(itemDef, indentation, mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +61,8 @@ export function definitionValues(def: Definition, indentation: string): Value[]
|
||||
function mappingValues(
|
||||
mappingDefinition: MappingDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode
|
||||
): Value[] {
|
||||
const properties: Value[] = [];
|
||||
for (const [key, value] of Object.entries(mappingDefinition.properties)) {
|
||||
@@ -60,15 +76,36 @@ function mappingValues(
|
||||
if (typeDef) {
|
||||
switch (typeDef.definitionType) {
|
||||
case DefinitionType.Sequence:
|
||||
insertText = `${key}:\n${indentation}- `;
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}:\n${indentation}${indentation}- `;
|
||||
} else {
|
||||
insertText = `${key}:\n${indentation}- `;
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.Mapping:
|
||||
insertText = `${key}:\n${indentation}`;
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}:\n${indentation}${indentation}`;
|
||||
} else {
|
||||
insertText = `${key}:\n${indentation}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.OneOf:
|
||||
// No special insertText in this case
|
||||
if (mode == DefinitionValueMode.Parent) {
|
||||
insertText = `${key}: `;
|
||||
} else {
|
||||
// No special insertText in this case
|
||||
}
|
||||
break;
|
||||
|
||||
case DefinitionType.String:
|
||||
case DefinitionType.Boolean:
|
||||
if (mode == DefinitionValueMode.Key) {
|
||||
insertText = `\n${indentation}${key}: `;
|
||||
} else {
|
||||
insertText = `${key}: `;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -89,11 +126,12 @@ function mappingValues(
|
||||
function oneOfValues(
|
||||
oneOfDefinition: OneOfDefinition,
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode
|
||||
): Value[] {
|
||||
const values: Value[] = [];
|
||||
for (const key of oneOfDefinition.oneOf) {
|
||||
values.push(...definitionValues(definitions[key], indentation));
|
||||
values.push(...definitionValues(definitions[key], indentation, mode));
|
||||
}
|
||||
return distinctValues(values);
|
||||
}
|
||||
|
||||
+8
-3
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.3.0"
|
||||
}
|
||||
"packages": [
|
||||
"expressions",
|
||||
"workflow-parser",
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.25"
|
||||
}
|
||||
Generated
+4156
-3041
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# this script is used to generate release notes for a given release
|
||||
# first argument is the pull request id for the last release
|
||||
# the second is the new release number
|
||||
|
||||
# the script then grabs every pull request merged since that pull request
|
||||
# and outputs a string of release notes
|
||||
|
||||
# get the new release number
|
||||
NEW_RELEASE=$2
|
||||
|
||||
echo "Generating release notes for $NEW_RELEASE"
|
||||
|
||||
# get the last release pull request id
|
||||
LAST_RELEASE_PR=$1
|
||||
|
||||
|
||||
|
||||
#get when the last release was merged
|
||||
LAST_RELEASE_MERGED_AT=$(gh pr view $LAST_RELEASE_PR --repo actions/languageservices --json mergedAt | jq -r '.mergedAt')
|
||||
|
||||
CHANGELIST=$(gh pr list --repo actions/languageservices --base main --state merged --json title --search "merged:>$LAST_RELEASE_MERGED_AT -label:no-release")
|
||||
|
||||
# store the release notes in a variable so we can use it later
|
||||
|
||||
echo "Release $NEW_RELEASE" >> releasenotes.md
|
||||
|
||||
echo $CHANGELIST | jq -r '.[].title' | while read line; do
|
||||
echo " - $line" >> releasenotes.md
|
||||
done
|
||||
|
||||
echo " "
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION=$(cat lerna.json | jq -r '.version')
|
||||
|
||||
MAJOR=$(echo $VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $VERSION | cut -d. -f3)
|
||||
|
||||
if [ "$1" == "major" ]; then
|
||||
MAJOR=$((MAJOR+1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [ "$1" == "minor" ]; then
|
||||
MINOR=$((MINOR+1))
|
||||
PATCH=0
|
||||
elif [ "$1" == "patch" ]; then
|
||||
PATCH=$((PATCH+1))
|
||||
else
|
||||
echo "Invalid version type. Use 'major', 'minor' or 'patch'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
echo $NEW_VERSION
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
);
|
||||
```
|
||||
|
||||
`convertWorkflowTemplate` then takes that intermediate representation and converts it to a [`WorkflowTemplate`](./src/workflow-template.ts) object, which is a more convenient representation for working with workflows.
|
||||
`convertWorkflowTemplate` then takes that intermediate representation and converts it to a [`WorkflowTemplate`](./src/model/workflow-template.ts) object, which is a more convenient representation for working with workflows.
|
||||
|
||||
```typescript
|
||||
const workflowTemplate = await convertWorkflowTemplate(result.context, result.value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.25",
|
||||
"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,22 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.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.0",
|
||||
"@actions/expressions": "^0.3.25",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -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";
|
||||
@@ -59,20 +61,28 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
|
||||
|
||||
// All other events are defined as mappings. During schema validation we already ensure that events
|
||||
// receive only known keys, so here we can focus on the values and whether they are valid.
|
||||
|
||||
const eventToken = item.value.assertMapping(`event ${eventName}`);
|
||||
if (eventName === "workflow_call") {
|
||||
result.workflow_call = convertEventWorkflowCall(context, eventToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === "workflow_dispatch") {
|
||||
result.workflow_dispatch = convertEventWorkflowDispatchInputs(context, eventToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
result[eventName] = {
|
||||
...convertPatternFilter("branches", eventToken),
|
||||
...convertPatternFilter("tags", eventToken),
|
||||
...convertPatternFilter("paths", eventToken),
|
||||
...convertFilter("types", eventToken),
|
||||
...convertFilter("workflows", eventToken),
|
||||
// workflow_call and workflow_dispatch share input parsing
|
||||
...convertEventWorkflowDispatchInputs(context, eventToken),
|
||||
...convertEventWorkflowCall(context, eventToken)
|
||||
...convertFilter("versions", eventToken),
|
||||
...convertFilter("names", eventToken),
|
||||
...convertFilter("workflows", eventToken)
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -114,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;
|
||||
@@ -148,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";
|
||||
@@ -50,9 +51,9 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
|
||||
let id: StringToken | undefined;
|
||||
let name: ScalarToken | undefined;
|
||||
let uses: StringToken | undefined;
|
||||
let continueOnError: boolean | 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,8 +78,15 @@ 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":
|
||||
continueOnError = item.value.assertBoolean("steps item continue-on-error").value;
|
||||
if (!item.value.isExpression) {
|
||||
continueOnError = item.value.assertBoolean("steps item continue-on-error").value;
|
||||
} else {
|
||||
continueOnError = item.value.assertScalar("steps item continue-on-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,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
|
||||
@@ -97,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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {MappingToken, TemplateToken} from "../../templates/tokens";
|
||||
import {isMapping} from "../../templates/tokens/type-guards";
|
||||
import {SecretConfig, WorkflowCallConfig} from "../workflow-template";
|
||||
import {SecretConfig, WorkflowCallConfig, InputConfig, InputType} from "../workflow-template";
|
||||
import {convertStringList} from "./string-list";
|
||||
import {ScalarToken} from "../../templates/tokens/scalar-token";
|
||||
|
||||
export function convertEventWorkflowCall(context: TemplateContext, token: MappingToken): WorkflowCallConfig {
|
||||
const result: WorkflowCallConfig = {};
|
||||
@@ -11,7 +13,7 @@ export function convertEventWorkflowCall(context: TemplateContext, token: Mappin
|
||||
|
||||
switch (key.value) {
|
||||
case "inputs":
|
||||
// Ignore, these are handled by convertEventWorkflowDispatchInputs
|
||||
result.inputs = convertWorkflowInputs(context, item.value.assertMapping("workflow dispatch inputs"));
|
||||
break;
|
||||
|
||||
case "secrets":
|
||||
@@ -27,6 +29,94 @@ export function convertEventWorkflowCall(context: TemplateContext, token: Mappin
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertWorkflowInputs(
|
||||
context: TemplateContext,
|
||||
token: MappingToken
|
||||
): {
|
||||
[inputName: string]: InputConfig;
|
||||
} {
|
||||
const result: {[inputName: string]: InputConfig} = {};
|
||||
|
||||
for (const item of token) {
|
||||
const inputName = item.key.assertString("input name");
|
||||
const inputMapping = item.value.assertMapping("input configuration");
|
||||
|
||||
result[inputName.value] = convertWorkflowInput(context, inputMapping);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertWorkflowInput(context: TemplateContext, token: MappingToken): InputConfig {
|
||||
const result: InputConfig = {
|
||||
type: InputType.string // Default to string
|
||||
};
|
||||
|
||||
let defaultValue: undefined | ScalarToken;
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("workflow dispatch input key");
|
||||
|
||||
switch (key.value) {
|
||||
case "description":
|
||||
result.description = item.value.assertString("input description").value;
|
||||
break;
|
||||
|
||||
case "required":
|
||||
result.required = item.value.assertBoolean("input required").value;
|
||||
break;
|
||||
|
||||
case "default":
|
||||
defaultValue = item.value.assertScalar("input default");
|
||||
break;
|
||||
|
||||
case "type":
|
||||
result.type = InputType[item.value.assertString("input type").value as keyof typeof InputType];
|
||||
break;
|
||||
|
||||
case "options":
|
||||
result.options = convertStringList("input options", item.value.assertSequence("input options"));
|
||||
break;
|
||||
|
||||
default:
|
||||
context.error(item.key, `Invalid key '${key.value}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate default value
|
||||
if (defaultValue !== undefined && !defaultValue.isExpression) {
|
||||
try {
|
||||
switch (result.type) {
|
||||
case InputType.boolean:
|
||||
result.default = defaultValue.assertBoolean("input default").value;
|
||||
|
||||
break;
|
||||
|
||||
case InputType.string:
|
||||
case InputType.choice:
|
||||
case InputType.environment:
|
||||
result.default = defaultValue.assertString("input default").value;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
context.error(defaultValue, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate `options` for `choice` type
|
||||
if (result.type === InputType.choice) {
|
||||
if (result.options === undefined || result.options.length === 0) {
|
||||
context.error(token, "Missing 'options' for choice input");
|
||||
}
|
||||
} else {
|
||||
if (result.options !== undefined) {
|
||||
context.error(token, "Input type is not 'choice', but 'options' is defined");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertWorkflowCallSecrets(
|
||||
context: TemplateContext,
|
||||
token: MappingToken
|
||||
|
||||
@@ -41,6 +41,7 @@ export type BaseJob = {
|
||||
concurrency?: TemplateToken;
|
||||
strategy?: TemplateToken;
|
||||
outputs?: MappingToken;
|
||||
snapshot?: TemplateToken;
|
||||
};
|
||||
|
||||
// `job-factory` in the schema
|
||||
@@ -86,7 +87,7 @@ type BaseStep = {
|
||||
id: string;
|
||||
name?: ScalarToken;
|
||||
if: BasicExpressionToken;
|
||||
"continue-on-error"?: boolean;
|
||||
"continue-on-error"?: boolean | ScalarToken;
|
||||
env?: MappingToken;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
@@ -158,7 +168,7 @@ export type WorkflowDispatchConfig = {
|
||||
};
|
||||
|
||||
export type WorkflowCallConfig = {
|
||||
inputs?: {[inputName: string]: InputConfig};
|
||||
inputs?: {[inputName: string]: InputConfig & {default?: string | boolean | number | ScalarToken}};
|
||||
secrets?: {[secretName: string]: SecretConfig};
|
||||
// TODO - these are supported in C# and Go but not in TS yet
|
||||
// outputs: { [outputName: string]: OutputConfig }
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
/**
|
||||
* This test ensures that activity types in workflow-v1.0.json stay in sync with
|
||||
* the webhooks.json file from the languageservice package.
|
||||
*
|
||||
* When this test fails, it means new activity types were added to webhooks.json
|
||||
* that need to be handled. See docs/json-data-files.md for detailed instructions.
|
||||
*
|
||||
* Quick reference for fixing failures:
|
||||
* 1. Check https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
* Find the event and look at its "Activity types" table to see if the type is a valid workflow trigger.
|
||||
* 2. If the activity type IS a valid workflow trigger:
|
||||
* → Add it to the corresponding *-activity-type definition in workflow-v1.0.json
|
||||
* 3. If the activity type is webhook-only (not in workflow docs):
|
||||
* → Add it to the WEBHOOK_ONLY list below
|
||||
* 4. If there's a naming difference between webhook and schema:
|
||||
* → Add it to the NAME_MAPPINGS list below
|
||||
* 5. If the schema has a type not in webhooks.json:
|
||||
* → Add it to the SCHEMA_ONLY list below
|
||||
*/
|
||||
|
||||
describe("schema-sync", () => {
|
||||
// Activity types that exist in webhooks.json but are intentionally NOT
|
||||
// supported as workflow triggers. These will be ignored when checking
|
||||
// webhooks → schema direction.
|
||||
const WEBHOOK_ONLY: Record<string, string[]> = {
|
||||
// check_suite: requested and rerequested are webhook-only, not valid workflow triggers
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#check_suite
|
||||
check_suite: ["requested", "rerequested"],
|
||||
|
||||
// registry_package: "default" is a webhook concept, not a workflow trigger type
|
||||
registry_package: ["default"]
|
||||
};
|
||||
|
||||
// Activity types that exist in workflow schema but are intentionally NOT
|
||||
// in webhooks.json (schema-only types). These will be ignored when checking
|
||||
// schema → webhooks direction.
|
||||
const SCHEMA_ONLY: Record<string, string[]> = {
|
||||
// registry_package: "updated" is a valid workflow trigger per GitHub docs
|
||||
// but doesn't exist in webhooks.json (webhooks only has "published" and "default")
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#registry_package
|
||||
registry_package: ["updated"]
|
||||
};
|
||||
|
||||
// Known naming differences between webhooks.json and workflow-v1.0.json.
|
||||
// Key: event name, Value: { webhook: "webhookName", schema: "schemaName" }
|
||||
// These are treated as equivalent when comparing in both directions.
|
||||
const NAME_MAPPINGS: Record<string, Array<{webhook: string; schema: string}>> = {
|
||||
// project_column: webhooks.json uses "edited" but workflow triggers use "updated"
|
||||
// This is a known naming difference - they represent the same action
|
||||
project_column: [{webhook: "edited", schema: "updated"}]
|
||||
};
|
||||
|
||||
it("activity types in workflow-v1.0.json match webhooks.json", () => {
|
||||
// Load webhooks.json (relative path from the test runner CWD which is the package root)
|
||||
const webhooksPath = "../languageservice/src/context-providers/events/webhooks.json";
|
||||
const webhooks = JSON.parse(fs.readFileSync(webhooksPath, "utf-8")) as Record<string, Record<string, unknown>>;
|
||||
|
||||
// Load workflow-v1.0.json
|
||||
const schemaPath = "./src/workflow-v1.0.json";
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as {
|
||||
definitions: Record<string, {"allowed-values"?: string[]; description?: string}>;
|
||||
};
|
||||
|
||||
const mismatches: string[] = [];
|
||||
|
||||
// Build mapping helpers for each event
|
||||
const getWebhookToSchemaMapping = (eventName: string): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
for (const mapping of NAME_MAPPINGS[eventName] || []) {
|
||||
map.set(mapping.webhook, mapping.schema);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const getSchemaToWebhookMapping = (eventName: string): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
for (const mapping of NAME_MAPPINGS[eventName] || []) {
|
||||
map.set(mapping.schema, mapping.webhook);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
// Check both directions for each event
|
||||
for (const [eventName, eventData] of Object.entries(webhooks)) {
|
||||
const webhookTypes = Object.keys(eventData);
|
||||
if (webhookTypes.length === 0) continue;
|
||||
|
||||
const schemaTypeName = `${eventName.replace(/_/g, "-")}-activity-type`;
|
||||
const schemaDef = schema.definitions[schemaTypeName];
|
||||
|
||||
// If there's no activity type definition in the schema, this event
|
||||
// doesn't support activity types in workflows (e.g., push, pull)
|
||||
if (!schemaDef || !schemaDef["allowed-values"]) continue;
|
||||
|
||||
const schemaTypes = new Set(schemaDef["allowed-values"]);
|
||||
const webhookOnly = new Set(WEBHOOK_ONLY[eventName] || []);
|
||||
const schemaOnly = new Set(SCHEMA_ONLY[eventName] || []);
|
||||
const webhookToSchema = getWebhookToSchemaMapping(eventName);
|
||||
const schemaToWebhook = getSchemaToWebhookMapping(eventName);
|
||||
|
||||
// Direction 1: webhooks → schema
|
||||
// Check that each webhook type exists in schema (or has a mapping, or is webhook-only)
|
||||
for (const webhookType of webhookTypes) {
|
||||
if (webhookOnly.has(webhookType)) continue;
|
||||
|
||||
const mappedSchemaType = webhookToSchema.get(webhookType);
|
||||
if (mappedSchemaType) {
|
||||
// Has a mapping - check the mapped name exists in schema
|
||||
if (!schemaTypes.has(mappedSchemaType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": webhook type "${webhookType}" maps to "${mappedSchemaType}" but "${mappedSchemaType}" not found in schema`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No mapping - check the type exists directly
|
||||
if (!schemaTypes.has(webhookType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": missing activity type "${webhookType}" in workflow-v1.0.json (exists in webhooks.json)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direction 2: schema → webhooks
|
||||
// Check that each schema type exists in webhooks (or has a mapping, or is schema-only)
|
||||
const webhookTypesSet = new Set(webhookTypes);
|
||||
for (const schemaType of schemaTypes) {
|
||||
if (schemaOnly.has(schemaType)) continue;
|
||||
|
||||
const mappedWebhookType = schemaToWebhook.get(schemaType);
|
||||
if (mappedWebhookType) {
|
||||
// Has a mapping - check the mapped name exists in webhooks
|
||||
if (!webhookTypesSet.has(mappedWebhookType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": schema type "${schemaType}" maps to "${mappedWebhookType}" but "${mappedWebhookType}" not found in webhooks.json`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No mapping - check the type exists directly
|
||||
if (!webhookTypesSet.has(schemaType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": extra activity type "${schemaType}" in workflow-v1.0.json (not in webhooks.json)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the description mentions all allowed values
|
||||
const activityDefName = `${eventName.replace(/_/g, "-")}-activity`;
|
||||
const activityDef = schema.definitions[activityDefName];
|
||||
if (activityDef?.description) {
|
||||
for (const schemaType of schemaTypes) {
|
||||
if (!activityDef.description.includes(`\`${schemaType}\``)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": description in "${activityDefName}" is missing activity type \`${schemaType}\``
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
const errorMessage = [
|
||||
"Activity type mismatches found between webhooks.json and workflow-v1.0.json:",
|
||||
"",
|
||||
...mismatches,
|
||||
"",
|
||||
"To fix these mismatches:",
|
||||
"1. Check GitHub docs: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows",
|
||||
"2. Verify the activity type is valid for workflow triggers",
|
||||
"3. Update the *-activity-type definition in workflow-parser/src/workflow-v1.0.json",
|
||||
"4. Update the description to list all supported activity types",
|
||||
"5. If there's a naming difference, add it to NAME_MAPPINGS in schema-sync.test.ts",
|
||||
"6. If the type is webhook-only, add it to WEBHOOK_ONLY",
|
||||
"7. If the type is schema-only, add it to SCHEMA_ONLY"
|
||||
].join("\n");
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
@@ -576,7 +625,9 @@
|
||||
"merge-group-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"types": "merge-group-activity"
|
||||
"types": "merge-group-activity",
|
||||
"branches": "event-branches",
|
||||
"branches-ignore": "event-branches-ignore"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -805,7 +856,7 @@
|
||||
}
|
||||
},
|
||||
"pull-request-activity": {
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"one-of": [
|
||||
"pull-request-activity-type",
|
||||
"pull-request-activity-types"
|
||||
@@ -828,9 +879,13 @@
|
||||
"reopened",
|
||||
"synchronize",
|
||||
"converted_to_draft",
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"ready_for_review",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
@@ -953,7 +1008,7 @@
|
||||
}
|
||||
},
|
||||
"pull-request-target-activity": {
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"one-of": [
|
||||
"pull-request-target-activity-type",
|
||||
"pull-request-target-activity-types"
|
||||
@@ -976,9 +1031,13 @@
|
||||
"reopened",
|
||||
"synchronize",
|
||||
"converted_to_draft",
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"ready_for_review",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
@@ -1181,7 +1240,7 @@
|
||||
]
|
||||
},
|
||||
"workflow-run-activity": {
|
||||
"description": "The types of workflow run activity that trigger the workflow. Suupported activity types: `completed`, `requested`, `in_progress`.",
|
||||
"description": "The types of workflow run activity that trigger the workflow. Supported activity types: `completed`, `requested`, `in_progress`.",
|
||||
"one-of": [
|
||||
"workflow-run-activity-type",
|
||||
"workflow-run-activity-types"
|
||||
@@ -1213,6 +1272,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": [
|
||||
@@ -1241,6 +1307,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": {
|
||||
@@ -1474,7 +1547,7 @@
|
||||
},
|
||||
"default": "workflow-dispatch-input-default",
|
||||
"options": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"type": "sequence-of-string",
|
||||
"description": "The options of the dropdown list, if the type is a choice."
|
||||
}
|
||||
}
|
||||
@@ -1513,6 +1586,14 @@
|
||||
"type": "permission-level-any",
|
||||
"description": "Actions workflows, workflow runs, and artifacts."
|
||||
},
|
||||
"artifact-metadata": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Storage and deployment records for build artifacts."
|
||||
},
|
||||
"attestations": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Artifact attestations."
|
||||
},
|
||||
"checks": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Check runs and check suites."
|
||||
@@ -1537,6 +1618,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."
|
||||
@@ -1694,7 +1779,8 @@
|
||||
"concurrency": "job-concurrency",
|
||||
"outputs": "job-outputs",
|
||||
"defaults": "job-defaults",
|
||||
"steps": "steps"
|
||||
"steps": "steps",
|
||||
"snapshot": "snapshot"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1764,9 +1850,7 @@
|
||||
"cancelled(0,0)",
|
||||
"success(0,MAX)"
|
||||
],
|
||||
"string": {
|
||||
"is-expression": true
|
||||
}
|
||||
"string": {}
|
||||
},
|
||||
"job-if-result": {
|
||||
"context": [
|
||||
@@ -1838,6 +1922,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": [
|
||||
@@ -2101,9 +2220,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": [
|
||||
@@ -2310,6 +2427,11 @@
|
||||
"item-type": "non-empty-string"
|
||||
}
|
||||
},
|
||||
"sequence-of-string": {
|
||||
"sequence": {
|
||||
"item-type": "string"
|
||||
}
|
||||
},
|
||||
"boolean-needs-context": {
|
||||
"context": [
|
||||
"github",
|
||||
@@ -2487,7 +2609,7 @@
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
},
|
||||
"description": "Use `shell` to override the default shell settings in the runner's operating system. You can use built-in shell keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the comands specified in `run`."
|
||||
"description": "Use `shell` to override the default shell settings in the runner's operating system. You can use built-in shell keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the commands specified in `run`."
|
||||
},
|
||||
"working-directory": {
|
||||
"string": {
|
||||
@@ -2508,4 +2630,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {JSONObjectReader} from "../templates/json-object-reader";
|
||||
import {TemplateSchema} from "../templates/schema";
|
||||
import WorkflowSchema from "../workflow-v1.0.json" assert {type: "json"};
|
||||
import WorkflowSchema from "../workflow-v1.0.min.json";
|
||||
|
||||
let schema: TemplateSchema;
|
||||
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
|
||||
import {
|
||||
isAlias,
|
||||
isCollection,
|
||||
isDocument,
|
||||
isMap,
|
||||
isPair,
|
||||
isScalar,
|
||||
isSeq,
|
||||
LineCounter,
|
||||
parseDocument,
|
||||
Scalar
|
||||
} from "yaml";
|
||||
import type {Document} from "yaml";
|
||||
import type {LinePos} from "yaml/dist/errors";
|
||||
import type {NodeBase} from "yaml/dist/nodes/Node";
|
||||
import {ObjectReader} from "../templates/object-reader";
|
||||
@@ -22,30 +34,31 @@ export type YamlError = {
|
||||
export class YamlObjectReader implements ObjectReader {
|
||||
private readonly _generator: Generator<ParseEvent>;
|
||||
private _current!: IteratorResult<ParseEvent>;
|
||||
private readonly doc: Document;
|
||||
private fileId?: number;
|
||||
private lineCounter = new LineCounter();
|
||||
|
||||
public errors: YamlError[] = [];
|
||||
|
||||
constructor(fileId: number | undefined, content: string) {
|
||||
const doc = parseDocument(content, {
|
||||
this.doc = parseDocument(content, {
|
||||
lineCounter: this.lineCounter,
|
||||
keepSourceTokens: true,
|
||||
uniqueKeys: false // Uniqueness is validated by the template reader
|
||||
});
|
||||
for (const err of doc.errors) {
|
||||
for (const err of this.doc.errors) {
|
||||
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
|
||||
}
|
||||
this._generator = this.getNodes(doc);
|
||||
this._generator = this.getNodes(this.doc, new Set());
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
private *getNodes(node: unknown): Generator<ParseEvent, void> {
|
||||
private *getNodes(node: unknown, aliasResolutionStack: Set<unknown>): Generator<ParseEvent, void> {
|
||||
let range = this.getRange(node as NodeBase | undefined);
|
||||
|
||||
if (isDocument(node)) {
|
||||
yield new ParseEvent(EventType.DocumentStart);
|
||||
for (const item of this.getNodes(node.contents)) {
|
||||
for (const item of this.getNodes(node.contents, new Set())) {
|
||||
yield item;
|
||||
}
|
||||
yield new ParseEvent(EventType.DocumentEnd);
|
||||
@@ -59,7 +72,7 @@ export class YamlObjectReader implements ObjectReader {
|
||||
}
|
||||
|
||||
for (const item of node.items) {
|
||||
for (const child of this.getNodes(item)) {
|
||||
for (const child of this.getNodes(item, aliasResolutionStack)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
@@ -74,12 +87,32 @@ export class YamlObjectReader implements ObjectReader {
|
||||
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
|
||||
}
|
||||
|
||||
// Handle YAML aliases - resolve to the anchored value
|
||||
if (isAlias(node)) {
|
||||
const resolved = node.resolve(this.doc);
|
||||
if (resolved) {
|
||||
// Prevent infinite recursion from circular aliases
|
||||
if (aliasResolutionStack.has(resolved)) {
|
||||
// Silently ignore circular reference - the missing content will cause
|
||||
// downstream validation errors which is acceptable for this edge case
|
||||
return;
|
||||
}
|
||||
// Track this node in the alias resolution stack
|
||||
const newStack = new Set(aliasResolutionStack);
|
||||
newStack.add(resolved);
|
||||
// Yield the resolved node's contents
|
||||
yield* this.getNodes(resolved, newStack);
|
||||
}
|
||||
// If unresolved, the yaml library already reports an error
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPair(node)) {
|
||||
const scalarKey = node.key as Scalar;
|
||||
range = this.getRange(scalarKey);
|
||||
const key = scalarKey.value as string;
|
||||
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
|
||||
for (const child of this.getNodes(node.value)) {
|
||||
for (const child of this.getNodes(node.value, aliasResolutionStack)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -72,7 +72,13 @@ on:
|
||||
- edited
|
||||
- deleted
|
||||
merge_group:
|
||||
types: checks_requested
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
branches-ignore:
|
||||
- develop
|
||||
types:
|
||||
- checks_requested
|
||||
milestone:
|
||||
types:
|
||||
- created
|
||||
@@ -114,6 +120,8 @@ on:
|
||||
- unassigned
|
||||
- labeled
|
||||
- unlabeled
|
||||
- milestoned
|
||||
- demilestoned
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
@@ -123,6 +131,8 @@ on:
|
||||
- ready_for_review
|
||||
- locked
|
||||
- unlocked
|
||||
- enqueued
|
||||
- dequeued
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
- auto_merge_enabled
|
||||
@@ -154,6 +164,8 @@ on:
|
||||
- unassigned
|
||||
- labeled
|
||||
- unlabeled
|
||||
- milestoned
|
||||
- demilestoned
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
@@ -163,6 +175,8 @@ on:
|
||||
- ready_for_review
|
||||
- locked
|
||||
- unlocked
|
||||
- enqueued
|
||||
- dequeued
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
- auto_merge_enabled
|
||||
@@ -313,6 +327,13 @@ jobs:
|
||||
]
|
||||
},
|
||||
"merge_group": {
|
||||
"branches": [
|
||||
"master",
|
||||
"main"
|
||||
],
|
||||
"branches-ignore": [
|
||||
"develop"
|
||||
],
|
||||
"types": [
|
||||
"checks_requested"
|
||||
]
|
||||
@@ -373,6 +394,8 @@ jobs:
|
||||
"unassigned",
|
||||
"labeled",
|
||||
"unlabeled",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"opened",
|
||||
"edited",
|
||||
"closed",
|
||||
@@ -382,6 +405,8 @@ jobs:
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
@@ -428,6 +453,8 @@ jobs:
|
||||
"unassigned",
|
||||
"labeled",
|
||||
"unlabeled",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"opened",
|
||||
"edited",
|
||||
"closed",
|
||||
@@ -437,6 +464,8 @@ jobs:
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user