diff --git a/.github/workflows/check-for-non-releasable-actions.yaml b/.github/workflows/check-for-non-releasable-actions.yaml index 29e1e4b6e..7b8419681 100644 --- a/.github/workflows/check-for-non-releasable-actions.yaml +++ b/.github/workflows/check-for-non-releasable-actions.yaml @@ -24,32 +24,10 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: sparse-checkout: | + ./.github/workflows ./actions + ./internal ./release-please-config.json - name: Check for non-releasable actions - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const fs = require('fs/promises'); - const releasePleaseConfig = JSON.parse(await fs.readFile('release-please-config.json', 'utf-8')); - - const configuredPackageNames = new Set(Object.keys(releasePleaseConfig.packages)); - const packageNames = new Set(); - - const folders = await fs.readdir('actions', { withFileTypes: true }); - for (const folder of folders) { - if (folder.isDirectory()) { - packageNames.add('actions/' + folder.name); - } - } - - const missingConfigurations = [...packageNames].filter(pkg => !configuredPackageNames.has(pkg)); - - if (missingConfigurations.length > 0) { - console.log('The following actions are missing from the release-please-config.json file and thus won\'t be automatically released:'); - console.log(missingConfigurations.join('\n')); - console.log('Please add them in release-please-config.json!'); - } else { - console.log('All actions are releasable!'); - } + uses: ./internal/check-for-non-releasable-actions diff --git a/.github/workflows/test-lint-pr-title.yml b/.github/workflows/test-typescript-action.yml similarity index 56% rename from .github/workflows/test-lint-pr-title.yml rename to .github/workflows/test-typescript-action.yml index de2ccfff8..cfb8c88cc 100644 --- a/.github/workflows/test-lint-pr-title.yml +++ b/.github/workflows/test-typescript-action.yml @@ -4,13 +4,15 @@ on: branches: - main paths: - - .github/workflows/test-lint-pr-title.yml + - .github/workflows/test-typescript-action.yml - actions/lint-pr-title/** + - internal/check-for-non-releasable-actions/** pull_request: paths: - - .github/workflows/test-lint-pr-title.yml + - .github/workflows/test-typescript-action.yml - actions/lint-pr-title/** + - internal/check-for-non-releasable-actions/** types: - edited - opened @@ -20,12 +22,20 @@ on: merge_group: jobs: - build-lint-pr-title: + build-lint-test: runs-on: ubuntu-latest + strategy: + matrix: + directory: + - actions/lint-pr-title + - internal/check-for-non-releasable-actions + + name: Build, lint, and test `${{ matrix.directory }}` + defaults: run: - working-directory: actions/lint-pr-title + working-directory: ${{ matrix.directory }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -33,9 +43,10 @@ jobs: - name: Install bun package manager uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 with: - bun-version-file: "actions/lint-pr-title/package.json" + bun-version-file: "${{ matrix.directory }}/package.json" - name: Install lint-pr-title dependencies + working-directory: ${{ github.workspace }} run: bun install --frozen-lockfile - name: Lint diff --git a/.gitignore b/.gitignore index 9f11b755a..a72cb4f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ .idea/ + +# JS / Bun bits +**/node_modules/ +**/.*.bun-build + +# Test files +**/coverage/ + +# Generated build output +**/dist/ + +# Don't commit any log files +**/*.log diff --git a/actions/lint-pr-title/.gitignore b/actions/lint-pr-title/.gitignore deleted file mode 100644 index 479ff5ee3..000000000 --- a/actions/lint-pr-title/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -node_modules/ - -coverage/ -dist/ - -.*.bun-build - -# Editor -.idea diff --git a/actions/lint-pr-title/bun.lockb b/actions/lint-pr-title/bun.lockb deleted file mode 100755 index c8c450f63..000000000 Binary files a/actions/lint-pr-title/bun.lockb and /dev/null differ diff --git a/actions/lint-pr-title/eslint.config.mjs b/actions/lint-pr-title/eslint.config.mjs index a9ab0bac7..1ead49f68 100644 --- a/actions/lint-pr-title/eslint.config.mjs +++ b/actions/lint-pr-title/eslint.config.mjs @@ -1,40 +1,3 @@ -import eslint from "@eslint/js"; -import eslintPluginJest from "eslint-plugin-jest"; -import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; +import config from "@grafana/eslint-config-shared-workflows"; -export default tseslint.config( - js.configs.recommended, - eslint.configs.recommended, - ...tseslint.configs.strictTypeChecked, - eslintPluginPrettierRecommended, - { - // Allow unused vars if they start with an underscore - rules: { - "@typescript-eslint/no-unused-vars": [ - "error", - { - varsIgnorePattern: "^_", - argsIgnorePattern: "^_", - }, - ], - }, - languageOptions: { - parserOptions: { - projectService: true, - }, - }, - }, - { - files: ["**/*.js", "**/*.mjs"], - ...tseslint.configs.disableTypeChecked, - }, - { - files: ["test/**/*.ts"], - ...eslintPluginJest.configs["flat/recommended"], - }, - { - ignores: ["coverage/", "dist/", "node_modules/"], - }, -); +export default config; diff --git a/actions/lint-pr-title/package.json b/actions/lint-pr-title/package.json index 0ccd07162..9c4364272 100644 --- a/actions/lint-pr-title/package.json +++ b/actions/lint-pr-title/package.json @@ -44,7 +44,8 @@ "eslint-plugin-prettier": "5.2.1", "prettier": "3.4.1", "typescript": "5.7.2", - "typescript-eslint": "8.16.0" + "typescript-eslint": "8.16.0", + "@grafana/eslint-config-shared-workflows": "workspace:*" }, "packageManager": "bun@1.1.27" } diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 000000000..ed3730ba5 Binary files /dev/null and b/bun.lockb differ diff --git a/internal/check-for-non-releasable-actions/README.md b/internal/check-for-non-releasable-actions/README.md new file mode 100644 index 000000000..eca1d2071 --- /dev/null +++ b/internal/check-for-non-releasable-actions/README.md @@ -0,0 +1,15 @@ +# check-for-non-releasable-actions + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run main.ts +``` + +This project was created using `bun init` in bun v1.1.36. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/internal/check-for-non-releasable-actions/action.yml b/internal/check-for-non-releasable-actions/action.yml new file mode 100644 index 000000000..dd8aab9d0 --- /dev/null +++ b/internal/check-for-non-releasable-actions/action.yml @@ -0,0 +1,24 @@ +name: Check for actions and reusable workflows without a release +description: Check for actions and reusable workflows that don't have a release-please configuration. + +runs: + using: "composite" + steps: + - name: Install bun package manager + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 + with: + bun-version-file: actions/lint-pr-title/package.json + + - name: Install dependencies + shell: sh + working-directory: ${{ github.action_path }} + run: | + bun install --frozen-lockfile --production + + - name: Lint PR title + shell: sh + working-directory: ${{ github.action_path }} + env: + NODE_ENV: "production" + run: | + bun run --cwd ../.. internal/check-for-non-releasable-actions/index.ts diff --git a/internal/check-for-non-releasable-actions/eslint.config.mjs b/internal/check-for-non-releasable-actions/eslint.config.mjs new file mode 100644 index 000000000..1ead49f68 --- /dev/null +++ b/internal/check-for-non-releasable-actions/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@grafana/eslint-config-shared-workflows"; + +export default config; diff --git a/internal/check-for-non-releasable-actions/filesystem.ts b/internal/check-for-non-releasable-actions/filesystem.ts new file mode 100644 index 000000000..d7314d48a --- /dev/null +++ b/internal/check-for-non-releasable-actions/filesystem.ts @@ -0,0 +1,35 @@ +import { readFile, readdir } from "fs/promises"; + +/** + * A representation of a directory entry, which can be either a file or a + * directory. This is a subset of the `fs.Dirent` interface containing just the + * parts we need. + */ +export interface DirectoryEntry { + name: string; + isDirectory: () => boolean; + isFile: () => boolean; +} + +/** + * Abstraction of the filesystem for reading directories and files. This is to + * allow for easier testing by providing an in-memory filesystem implementation. + */ +export interface FileSystem { + readDirectory: (path: string) => Promise; + readFile: (path: string) => Promise; +} + +/** + * Implementation of the filesystem using Node.js's built-in `fs` module, used + * in production. + */ +export class NodeFileSystem implements FileSystem { + async readDirectory(path: string): Promise { + return readdir(path, { withFileTypes: true }); + } + + async readFile(path: string): Promise { + return readFile(path, "utf-8"); + } +} diff --git a/internal/check-for-non-releasable-actions/index.ts b/internal/check-for-non-releasable-actions/index.ts new file mode 100644 index 000000000..8356ac25c --- /dev/null +++ b/internal/check-for-non-releasable-actions/index.ts @@ -0,0 +1,6 @@ +import { NodeFileSystem } from "./filesystem"; +import { main } from "./main"; +import config from "../../release-please-config.json" assert { type: "json" }; + +const fs = new NodeFileSystem(); +process.exit(await main(fs, config)); diff --git a/internal/check-for-non-releasable-actions/main.test.ts b/internal/check-for-non-releasable-actions/main.test.ts new file mode 100644 index 000000000..40578211f --- /dev/null +++ b/internal/check-for-non-releasable-actions/main.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from "bun:test"; +import { type DirectoryEntry, type FileSystem } from "./filesystem"; + +import { ReleaseConfigChecker } from "./main"; + +export type FileSystemStructure = { + [key: string]: string | FileSystemStructure; +}; + +/** + * An in-memory implementation of the filesystem for testing purposes. + */ +export class InMemoryFileSystem implements FileSystem { + constructor(private readonly structure: FileSystemStructure = {}) {} + + private getEntry(path: string): string | FileSystemStructure | undefined { + if (path === "" || path === ".") { + return this.structure; + } + + let current: FileSystemStructure | string = this.structure; + for (const part of path.split("/")) { + if (typeof current === "string" || !(part in current)) { + return undefined; + } + + current = current[part]; + } + + return current; + } + + readDirectory(path: string): Promise { + const node = this.getEntry(path); + + if (typeof node === "string" || node === undefined) { + throw new Error(`Directory not found: ${path}`); + } + + const res = Object.entries(node).map(([name, content]) => ({ + name, + isDirectory: () => typeof content === "object", + isFile: () => typeof content === "string", + })); + + return Promise.resolve(res); + } + + readFile(path: string): Promise { + const content = this.getEntry(path); + + if (typeof content !== "string") { + throw new Error(`File not found: ${path}`); + } + + return Promise.resolve(content); + } +} + +describe("processActionEntries", () => { + const checker = new ReleaseConfigChecker(new InMemoryFileSystem()); + + it.each<{ + name: string; + entries: { name: string; isDirectory: () => boolean }[]; + expected: Set; + }>([ + { + name: "filters and transforms directory entries", + entries: [ + { name: "action1", isDirectory: () => true }, + { name: "not-dir", isDirectory: () => false }, + { name: "action2", isDirectory: () => true }, + ], + expected: new Set(["actions/action1", "actions/action2"]), + }, + { + name: "handles empty entries", + entries: [], + expected: new Set(), + }, + ])("$name", ({ entries, expected }) => { + const result = checker.processActionEntries( + entries.map((entry) => ({ ...entry, isFile: () => false })), + ); + expect(result).toEqual(expected); + }); +}); + +describe("isWorkflowReusable", () => { + const checker = new ReleaseConfigChecker(new InMemoryFileSystem()); + + it.each([ + { + name: "identifies reusable workflow", + template: ` + on: + workflow_call: + jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo test + `, + expected: true, + }, + { + name: "identifies non-reusable workflow", + template: ` + on: + push: + jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo test + `, + expected: false, + }, + ])("$name", async ({ template, expected }) => { + const workflowTemplate = await checker.parse(template); + expect(checker.isWorkflowReusable(workflowTemplate)).toBe(expected); + }); +}); + +describe("ReleaseConfigChecker integration tests", () => { + it("finds missing configurations in a complete repository", async () => { + const fs = new InMemoryFileSystem({ + actions: { + "test-action": { + "action.yml": "name: Test Action", + }, + "configured-action": { + "action.yml": "name: Configured Action", + }, + }, + ".github": { + workflows: { + "reusable.yml": ` + on: + workflow_call: + jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo test + `, + "normal.yml": ` + on: + push: + jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo test + `, + }, + }, + }); + + const config = { + packages: { + "actions/configured-action": {}, + ".github/workflows/normal.yml": {}, + }, + }; + + const checker = new ReleaseConfigChecker(fs, config); + const missingConfigs = await checker.check(); + + expect(missingConfigs).toContain("actions/test-action"); + expect(missingConfigs).toContain(".github/workflows/reusable.yml"); + expect(missingConfigs).not.toContain("actions/configured-action"); + expect(missingConfigs).not.toContain(".github/workflows/normal.yml"); + }); +}); diff --git a/internal/check-for-non-releasable-actions/main.ts b/internal/check-for-non-releasable-actions/main.ts new file mode 100644 index 000000000..7a997c1a2 --- /dev/null +++ b/internal/check-for-non-releasable-actions/main.ts @@ -0,0 +1,199 @@ +import { join } from "path"; +import { z } from "zod"; +import { + convertWorkflowTemplate, + parseWorkflow, + type WorkflowTemplate, +} from "@actions/workflow-parser"; +import { error, info, debug as verbose } from "@actions/core"; +import type { FileSystem, DirectoryEntry } from "./filesystem"; + +/* + * Define the schema for validating release-please configuration + */ +const ReleasePleaseConfig = z.object({ + packages: z.record(z.string(), z.unknown()), +}); + +/** + * Check that all GitHub Actions and reusable workflows in a repository + * are properly configured for release in release-please-config.json + * + * @param fs The filesystem to use for reading files and directories + * @param config The parsed release-please configuration object + */ +export class ReleaseConfigChecker { + constructor( + private fs: FileSystem, + private config: unknown = {}, + ) {} + + /** + * Process directory entries to identify action packages + * + * @param entries Array of directory entries from the actions directory + * @returns A Set of package paths relative to the repository root + */ + processActionEntries(entries: DirectoryEntry[]): Set { + return new Set( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join("actions", entry.name)), + ); + } + + /** + * Determine if a workflow template represents a reusable workflow + * + * @param workflowTemplate The parsed workflow template + * @returns true if the workflow has a workflow_call event trigger + */ + isWorkflowReusable(workflowTemplate: WorkflowTemplate): boolean { + return workflowTemplate.events.workflow_call !== undefined; + } + + /** + * Compare configured packages against discovered packages to find missing ones + * + * @param configuredPackages Set of packages configured in release-please-config.json + * @param discoveredPackages Set of packages found in the repository + * @returns Array of package paths that are missing from the configuration + */ + findMissingConfigurations( + configuredPackages: Set, + discoveredPackages: Set, + ): string[] { + return Array.from(discoveredPackages.difference(configuredPackages)); + } + + /** + * Parse a workflow file content into a WorkflowTemplate + * + * @param content The raw YAML content of the workflow file + * @returns Promise resolving to the parsed WorkflowTemplate + * @throws Error if parsing fails + */ + parse(content: string): Promise { + const { context, value } = parseWorkflow( + { name: "inline", content }, + { error, info, verbose }, + ); + + if (value === undefined) { + throw new Error("Failed to parse workflow"); + } + + return convertWorkflowTemplate(context, value); + } + + /** + * Scan the actions directory to find all action packages + * + * @returns Promise resolving to a Set of action package paths + */ + async getActionPackages(): Promise> { + verbose("Scanning actions directory for packages..."); + + const entries = await this.fs.readDirectory("actions"); + verbose(`Found ${entries.length.toString()} items in actions directory`); + + const packages = this.processActionEntries(entries); + info(`Found ${packages.size.toString()} action packages`); + + return packages; + } + + /** + * Scan the workflows directory to find all reusable workflows + * + * @returns Promise resolving to a Set of reusable workflow paths + */ + async getReusableWorkflows(): Promise> { + verbose("Scanning .github/workflows directory for reusable workflows..."); + const packages = new Set(); + + const files = await this.fs.readDirectory(join(".github", "workflows")); + verbose(`Found ${files.length.toString()} files in workflows directory`); + + for (const file of files) { + if (!file.isFile() || !/\.ya?ml$/.test(file.name)) { + verbose(`Skipping ${file.name} - not a YAML file`); + continue; + } + + const filePath = join(".github", "workflows", file.name); + const content = await this.fs.readFile(filePath); + + try { + const template = await this.parse(content); + if (this.isWorkflowReusable(template)) { + packages.add(filePath); + verbose(`Added reusable workflow: ${filePath}`); + } + } catch (e) { + error( + `Failed to parse workflow ${filePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + throw e; + } + } + + info(`Found ${packages.size.toString()} reusable workflows`); + return packages; + } + + /** + * Find all missing configurations + * + * @returns Promise resolving to an array of paths missing from the configuration + * @throws Error if config is invalid or filesystem operations fail + */ + async check(): Promise { + verbose("Checking for missing configurations..."); + + const parsedConfig = ReleasePleaseConfig.parse(this.config); + const configuredPackages = new Set(Object.keys(parsedConfig.packages)); + verbose( + `Found ${configuredPackages.size.toString()} configured packages in release-please-config.json`, + ); + + const [actionPackages, reusableWorkflows] = await Promise.all([ + this.getActionPackages(), + this.getReusableWorkflows(), + ]); + + return this.findMissingConfigurations( + configuredPackages, + new Set([...actionPackages, ...reusableWorkflows]), + ); + } +} + +/** + * Run the checker and handle the result + * + * @param fs The filesystem to use for reading files and directories + * @param config The parsed release-please configuration object + * @returns Promise resolving to 0 if all packages are configured, 1 otherwise + */ +export async function main(fs: FileSystem, config: unknown): Promise { + try { + const checker = new ReleaseConfigChecker(fs, config); + const missingConfigs = await checker.check(); + + if (missingConfigs.length === 0) { + info("All items are releasable!"); + return 0; + } + + error( + `Found ${missingConfigs.length.toString()} items missing from release-please-config.json:\n` + + `${missingConfigs.join("\n")}\n` + + `Please add them to release-please-config.json!`, + ); + return 1; + } catch (e) { + error(`Fatal error: ${e instanceof Error ? e.message : String(e)}`); + return 1; + } +} diff --git a/internal/check-for-non-releasable-actions/package.json b/internal/check-for-non-releasable-actions/package.json new file mode 100644 index 000000000..10bd6320d --- /dev/null +++ b/internal/check-for-non-releasable-actions/package.json @@ -0,0 +1,22 @@ +{ + "name": "check-for-non-releasable-actions", + "module": "index.ts", + "type": "module", + "scripts": { + "run": "bun run --cwd ../.. internal/check-for-non-releasable-actions/index.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "bun test" + }, + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/workflow-parser": "^0.3.13", + "zod": "3.23.8" + }, + "devDependencies": { + "@grafana/eslint-config-shared-workflows": "workspace:*", + "@types/bun": "1.1.14", + "typescript": "5.7.2" + }, + "packageManager": "bun@1.1.38" +} diff --git a/internal/eslint-config-shared-workflows/eslint.config.mjs b/internal/eslint-config-shared-workflows/eslint.config.mjs new file mode 100644 index 000000000..f09e1a363 --- /dev/null +++ b/internal/eslint-config-shared-workflows/eslint.config.mjs @@ -0,0 +1,43 @@ +import eslint from "@eslint/js"; +import eslintPluginJest from "eslint-plugin-jest"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import eslintPluginPromise from "eslint-plugin-promise"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + js.configs.recommended, + eslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + eslintPluginPrettierRecommended, + eslintPluginPromise.configs["flat/recommended"], + { + // Allow unused vars if they start with an underscore + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + }, + ], + }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: "../..", + }, + }, + }, + { + files: ["**/*.js", "**/*.mjs"], + ...tseslint.configs.disableTypeChecked, + }, + { + files: ["**/test/**/*.ts"], + ...eslintPluginJest.configs["flat/recommended"], + }, + { + ignores: ["**/coverage/", "**/dist/", "**/node_modules/"], + }, +); diff --git a/internal/eslint-config-shared-workflows/package.json b/internal/eslint-config-shared-workflows/package.json new file mode 100644 index 000000000..fc3c4bdf7 --- /dev/null +++ b/internal/eslint-config-shared-workflows/package.json @@ -0,0 +1,23 @@ +{ + "name": "@grafana/eslint-config-shared-workflows", + "version": "0.0.1", + "main": "eslint.config.mjs", + "license": "BSD-3-Clause", + "private": true, + "dependencies": { + "@eslint/js": "9.16.0", + "@types/eslint__js": "8.42.3", + "@typescript-eslint/eslint-plugin": "8.16.0", + "@typescript-eslint/parser": "8.16.0", + "eslint": "9.16.0", + "eslint-config-prettier": "9.1.0", + "eslint-config-standard": "17.1.0", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-jest": "28.9.0", + "eslint-plugin-prettier": "5.2.1", + "eslint-plugin-promise": "7.2.1", + "prettier": "3.4.1", + "typescript": "5.7.2", + "typescript-eslint": "8.16.0" + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..995ad6ae7 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "shared-workflows", + "version": "0.0.1", + "private": true, + "workspaces": [ + "internal/*", + "actions/lint-pr-title" + ] +} diff --git a/actions/lint-pr-title/tsconfig.json b/tsconfig.json similarity index 65% rename from actions/lint-pr-title/tsconfig.json rename to tsconfig.json index 6737d7e45..239e9426b 100644 --- a/actions/lint-pr-title/tsconfig.json +++ b/tsconfig.json @@ -5,17 +5,23 @@ "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "module": "es2022", + "lib": ["esnext"], + "module": "preserve", "moduleResolution": "Bundler", "newLine": "lf", + "noEmit": true, + "noFallthroughCasesInSwitch": true, "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, "outDir": "./dist", "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "strictNullChecks": true, - "target": "es2022" + "target": "es2024", + "verbatimModuleSyntax": true }, "include": ["**/*.ts"] }