From 651314b9078e6bbdfb37aacae91636c1e8b07794 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 9 Nov 2026 23:40:44 -0600 Subject: [PATCH] chore: Add a "validate actions" script to validate workflow/action YAML in CI - W-24800910 --- .github/workflows/validateActions.yml | 19 + CONTRIBUTING.md | 1 + package-lock.json | 665 ++++++++++++++++++++++++++ package.json | 6 +- scripts/validateActions.mts | 96 ++++ scripts/validateActions.test.mts | 158 ++++++ 6 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/validateActions.yml create mode 100644 package-lock.json create mode 100644 scripts/validateActions.mts create mode 100644 scripts/validateActions.test.mts diff --git a/.github/workflows/validateActions.yml b/.github/workflows/validateActions.yml new file mode 100644 index 00000000..53fd2d6c --- /dev/null +++ b/.github/workflows/validateActions.yml @@ -0,0 +1,19 @@ +name: Validate actions + +on: pull_request + +permissions: + contents: read + +jobs: + validate-actions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm run check:actions + - run: npm run test:actions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e9e1607..03a78551 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ Agreement. You can do so by going to https://cla.salesforce.com/sign-cla. ## Pull Requests +- Run `npm ci`, then `npm run check:actions` (Node.js 24+) to validate workflow/action YAML and reusable-workflow inputs. Self-references, including `salesforcecli/github-workflows/...@main`, are checked against this checkout. Run `npm run test:actions` for validator regression tests. Both checks run on PRs. - Develop features and bug fixes in _topic_ branches off main, or forks. - _Topic_ branches can live in forks (external contributors) or within this repository (internal contributors). - When creating _topic_ branches in this repository please prefix with `/`. For example: `mb/refactor-tests`. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..91433d8e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,665 @@ +{ + "name": "@salesforcecli/github-workflows", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@salesforcecli/github-workflows", + "version": "1.0.0", + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "^5.3.0", + "semver": "^7.6.3", + "simple-git": "^3.27.0", + "tsx": "^4.19.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@action-validator/core": "^0.6.0", + "@types/node": "^22.10.2", + "@types/semver": "^7.5.8", + "yaml": "^2.9.1" + } + }, + "node_modules/@action-validator/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@action-validator/core/-/core-0.6.0.tgz", + "integrity": "sha512-tPglwCr8Mm8SWzwnVewwFmqRx91F0WvMsM0BRAqH4CLalyGndm53Xvp+UcUSzswpk1wkjIDYI7RyEhWMLyPkig==", + "dev": true, + "license": "GPL-3.0-only" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.4.tgz", + "integrity": "sha512-EtMX6XjRWSastG2SdkmSQByPtJ9dx/NIjnHbpQPCYt62j4RmSx5rgLTGpw0YCjF5h191gZOmBfheOT23cRSFdw==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index cb3bd364..d47d6a94 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "Shared GitHub Actions workflows and scripts for Salesforce CLI projects", "type": "module", "scripts": { + "check:actions": "node scripts/validateActions.mts", + "test:actions": "node --test scripts/validateActions.test.mts", "ext-change-detector": "tsx .github/scripts/ext-change-detector.ts" }, "keywords": [ @@ -21,7 +23,9 @@ "zod": "^3.24.1" }, "devDependencies": { + "@action-validator/core": "^0.6.0", "@types/node": "^22.10.2", - "@types/semver": "^7.5.8" + "@types/semver": "^7.5.8", + "yaml": "^2.9.1" } } diff --git a/scripts/validateActions.mts b/scripts/validateActions.mts new file mode 100644 index 00000000..79dffe87 --- /dev/null +++ b/scripts/validateActions.mts @@ -0,0 +1,96 @@ +import { type ValidationError, validateAction, validateWorkflow } from '@action-validator/core'; +import { globSync, readFileSync } from 'node:fs'; +import { parse } from 'yaml'; + +type Workflow = { + on: string | string[] | { workflow_call?: { inputs?: Record } | null }; + jobs: Record }>; +}; + +// The bundled schema requires secrets..required; current SchemaStore does not. +// Remove this exception when @action-validator/core updates its bundled schema. +// https://json.schemastore.org/github-workflow.json +const schemaExceptions = [ + { code: 'required', path: /^\/on\/workflow_call\/secrets\/[^/]+\/required$/ }, +]; + +const filterSchemaErrors = (errors: ValidationError[]): ValidationError[] => + errors.flatMap((error) => { + if ('states' in error && error.states?.length) { + const states = error.states.map((state) => ({ ...state, errors: filterSchemaErrors(state.errors) })); + // A one_of is satisfied when a branch has no remaining errors. + return states.some((state) => state.errors.length === 0) ? [] : [{ ...error, states }]; + } + return schemaExceptions.some(({ code, path }) => error.code === code && 'path' in error && path.test(error.path)) + ? [] + : [error]; + }); + +const formatSchemaErrors = (errors: ValidationError[]): string[] => + errors.flatMap((error) => + 'states' in error && error.states?.length + ? error.states.flatMap((state) => formatSchemaErrors(state.errors)) + : [`${'path' in error ? error.path : ''}: ${error.detail ?? error.title}`], + ); + +const errors: string[] = []; +const workflows = new Map(); +const workflowFiles = globSync('.github/workflows/*.{yml,yaml}').sort(); +const actionFiles = globSync('.github/actions/**/action.{yml,yaml}').sort(); + +for (const [files, validate] of [[workflowFiles, validateWorkflow], [actionFiles, validateAction]] as const) { + for (const file of files) { + try { + const source = readFileSync(file, 'utf8'); + const schemaErrors = filterSchemaErrors(validate(source).errors); + errors.push(...formatSchemaErrors(schemaErrors).map((message) => `${file}${message}`)); + if (validate === validateWorkflow && schemaErrors.length === 0) { + workflows.set(file, parse(source) as Workflow); + } + } catch (error) { + errors.push(`${file}: ${error instanceof Error ? error.message : String(error)}`); + } + } +} + +for (const [file, workflow] of workflows) { + for (const [jobId, job] of Object.entries(workflow.jobs)) { + if (!job.uses) continue; + // Check self-references against this checkout, not the published ref, so PRs + // validate changes to callers and callees together without network requests. + const calledFile = job.uses.startsWith('./.github/workflows/') + ? job.uses.slice(2) + : /^salesforcecli\/github-workflows\/(\.github\/workflows\/[^@]+)@.+$/.exec(job.uses)?.[1]; + if (!calledFile) continue; + + const calledWorkflow = workflows.get(calledFile); + const jobPath = `${file}/jobs/${jobId}`; + if (!calledWorkflow) { + errors.push(`${jobPath}: called workflow ${calledFile} is missing or invalid`); + continue; + } + + const on = calledWorkflow.on; + const callable = typeof on === 'string' + ? on === 'workflow_call' + : Array.isArray(on) + ? on.includes('workflow_call') + : Object.hasOwn(on, 'workflow_call'); + if (!callable) { + errors.push(`${jobPath}: ${calledFile} does not declare on.workflow_call`); + continue; + } + + const inputs = typeof on === 'object' && !Array.isArray(on) ? on.workflow_call?.inputs ?? {} : {}; + // GitHub validates inputs even when the calling job's if condition is false. + for (const input of Object.keys(job.with ?? {})) { + if (!Object.hasOwn(inputs, input)) { + errors.push(`${jobPath}/with/${input}: input is not defined by ${calledFile}`); + } + } + } +} + +for (const error of errors) console.error(error); +console.log(`Validated ${workflowFiles.length} workflows and ${actionFiles.length} actions; ${errors.length} errors.`); +process.exitCode = errors.length > 0 ? 1 : 0; diff --git a/scripts/validateActions.test.mts b/scripts/validateActions.test.mts new file mode 100644 index 00000000..8188869c --- /dev/null +++ b/scripts/validateActions.test.mts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(new URL('./validateActions.mts', import.meta.url)); +const callee = `on: + workflow_call: + inputs: + githubTag: + type: string +jobs: + publish: + runs-on: ubuntu-latest + steps: + - run: echo publish +`; +const caller = (uses: string, inputs = 'githubTag: latest') => `on: workflow_dispatch +jobs: + call: + if: \${{ false }} + uses: ${uses} + with: + ${inputs} +`; +const action = `name: Test action +description: Test action validation +runs: + using: composite + steps: + - shell: bash + run: echo test +`; + +const validate = (files: Record) => { + const cwd = mkdtempSync(join(tmpdir(), 'validate-actions-')); + try { + for (const [file, source] of Object.entries(files)) { + const path = join(cwd, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, source); + } + const result = spawnSync(process.execPath, [script], { cwd, encoding: 'utf8' }); + assert.ifError(result.error); + assert.equal(result.signal, null, result.stderr); + return result; + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}; + +test('validates .yml/.yaml workflows and nested actions', () => { + const result = validate({ + '.github/workflows/caller.yml': caller('./.github/workflows/callee.yaml'), + '.github/workflows/callee.yaml': callee, + '.github/actions/example/action.yml': action, + '.github/actions/nested/example/action.yaml': action, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Validated 2 workflows and 2 actions; 0 errors/); +}); + +for (const uses of [ + './.github/workflows/ctcOpen.yml', + 'salesforcecli/github-workflows/.github/workflows/ctcOpen.yml@main', +]) { + test(`rejects the #205 inputs even with if: false (${uses})`, () => { + const result = validate({ + '.github/workflows/npmPublish.yml': caller(uses, [ + 'githubTag: latest', + 'package-manager: npm', + 'package-manager-version: "10"', + 'cache-dependency-path: package-lock.json', + ].join('\n ')), + '.github/workflows/ctcOpen.yml': callee, + }); + assert.equal(result.status, 1); + for (const input of ['package-manager', 'package-manager-version', 'cache-dependency-path']) { + assert.ok(result.stderr.includes(`npmPublish.yml/jobs/call/with/${input}: input is not defined by .github/workflows/ctcOpen.yml`)); + } + }); +} + +test('removing a callee input fails unchanged repo-qualified callers', () => { + const files = { + '.github/workflows/caller.yml': caller('salesforcecli/github-workflows/.github/workflows/ctcClose.yml@main'), + '.github/workflows/ctcClose.yml': callee, + }; + assert.equal(validate(files).status, 0); + const result = validate({ + ...files, + '.github/workflows/ctcClose.yml': callee.replace(' inputs:\n githubTag:\n type: string\n', ''), + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /with\/githubTag: input is not defined/); +}); + +for (const on of ['workflow_call', '[workflow_call]', '\n workflow_call:']) { + test(`rejects inputs for inputless workflow_call (${on.trim()})`, () => { + const result = validate({ + '.github/workflows/caller.yml': caller('./.github/workflows/callee.yml'), + '.github/workflows/callee.yml': callee.replace(/on:[\s\S]*?jobs:/, `on: ${on}\njobs:`), + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /with\/githubTag: input is not defined/); + }); +} + +test('rejects missing and non-reusable called workflows', () => { + const files = { '.github/workflows/caller.yml': caller('./.github/workflows/callee.yml') }; + const missing = validate(files); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /callee.yml is missing or invalid/); + const nonReusable = validate({ + ...files, + '.github/workflows/callee.yml': callee.replace(/on:[\s\S]*?jobs:/, 'on: push\njobs:'), + }); + assert.equal(nonReusable.status, 1); + assert.match(nonReusable.stderr, /does not declare on.workflow_call/); +}); + +test('allows only the known optional-secret schema gap', () => { + const withSecret = callee.replace(' inputs:', ' secrets:\n TOKEN:\n description: Optional token\n inputs:'); + const valid = validate({ '.github/workflows/callee.yml': withSecret }); + assert.equal(valid.status, 0, valid.stderr); + for (const invalid of [ + withSecret.replace('type: string', 'type: invalid'), + withSecret.replace('description: Optional token', 'description: Optional token\n unexpected: true'), + withSecret.replace('description: Optional token', 'description: Optional token\n required: invalid'), + ]) { + const result = validate({ '.github/workflows/callee.yml': invalid }); + assert.equal(result.status, 1, result.stdout); + assert.match(result.stderr, /callee.yml/); + } +}); + +for (const [file, source] of Object.entries({ + '.github/workflows/invalid.yml': callee.replace('runs-on: ubuntu-latest', 'runs-on: 42'), + '.github/workflows/invalid.yaml': 'on: [', + '.github/actions/example/action.yml': action.replace('using: composite', 'using: invalid'), + '.github/actions/nested/example/action.yaml': action.replace(' run: echo test\n', ''), +})) { + test(`rejects invalid YAML/schema: ${file}`, () => { + const result = validate({ [file]: source }); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes(file), result.stderr); + }); +} + +test('does not resolve reusable workflows in other repositories', () => { + const result = validate({ + '.github/workflows/caller.yml': caller('another/repository/.github/workflows/publish.yml@main'), + }); + assert.equal(result.status, 0, result.stderr); +});