From 35f80fd965c11eeec86b9a3aa691492052d8e300 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Fri, 10 Apr 2026 10:03:11 +0200 Subject: [PATCH] feat: add 'local-actions' action Signed-off-by: Emilien Escalle --- Makefile | 2 +- README.md | 1 + .../create-and-merge-pull-request/action.yml | 13 +- actions/create-or-update-comment/action.yml | 14 +- actions/local-actions/ActionRuntime.js | 62 +++++++ actions/local-actions/LocalActionsManager.js | 77 ++++++++ .../local-actions/LocalActionsManager.test.js | 165 ++++++++++++++++++ actions/local-actions/README.md | 38 ++++ actions/local-actions/action.yml | 25 +++ actions/local-actions/cleanup.js | 21 +++ actions/local-actions/index.js | 37 ++++ actions/local-actions/index.test.js | 146 ++++++++++++++++ actions/local-actions/package-lock.json | 16 ++ actions/local-actions/package.json | 16 ++ 14 files changed, 613 insertions(+), 20 deletions(-) create mode 100644 actions/local-actions/ActionRuntime.js create mode 100644 actions/local-actions/LocalActionsManager.js create mode 100644 actions/local-actions/LocalActionsManager.test.js create mode 100644 actions/local-actions/README.md create mode 100644 actions/local-actions/action.yml create mode 100644 actions/local-actions/cleanup.js create mode 100644 actions/local-actions/index.js create mode 100644 actions/local-actions/index.test.js create mode 100644 actions/local-actions/package-lock.json create mode 100644 actions/local-actions/package.json diff --git a/Makefile b/Makefile index 81f70a65..6cbd9d97 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test: ## Execute tests ci: ## Execute CI tasks $(MAKE) setup - $(MAKE) npm-audit-fix + $(MAKE) npm-audit-fix || true $(MAKE) lint-fix $(MAKE) test diff --git a/README.md b/README.md index 8aa37bab..7b139c3b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Opinionated GitHub Actions and reusable workflows for foundational continuous-in ### Matrix & workflow data helpers - [Get matrix outputs](actions/get-matrix-outputs/README.md) - aggregates outputs across matrix jobs for downstream steps. +- [Local actions](actions/local-actions/README.md) - exposes sibling local actions for a composite action and cleans them up automatically. - [Set matrix output](actions/set-matrix-output/README.md) - writes structured outputs that can be consumed by other matrix jobs. - [Local workflow actions](actions/local-workflow-actions/README.md) - loads reusable workflow actions from the current repository. diff --git a/actions/create-and-merge-pull-request/action.yml b/actions/create-and-merge-pull-request/action.yml index 94ffae95..cef4d600 100644 --- a/actions/create-and-merge-pull-request/action.yml +++ b/actions/create-and-merge-pull-request/action.yml @@ -32,17 +32,12 @@ inputs: runs: using: "composite" steps: - - shell: bash - # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 - run: mkdir -p ./self-actions/ && cp -r $GITHUB_ACTION_PATH/../* ./self-actions/ + - uses: ./../local-actions + with: + source-path: ${{ github.action_path }}/../.. - id: github-actions-bot-user - uses: ./self-actions/get-github-actions-bot-user - - - shell: bash - # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 - run: | - rm -fr ./self-actions + uses: ./../self-actions/get-github-actions-bot-user - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 id: create-pull-request diff --git a/actions/create-or-update-comment/action.yml b/actions/create-or-update-comment/action.yml index 64766725..670df10f 100644 --- a/actions/create-or-update-comment/action.yml +++ b/actions/create-or-update-comment/action.yml @@ -34,18 +34,12 @@ inputs: runs: using: "composite" steps: - - shell: bash - # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 - run: | - [ -d ./self-actions ] || (mkdir -p ./self-actions/ && cp -r $GITHUB_ACTION_PATH/../* ./self-actions/) + - uses: ./../local-actions + with: + source-path: ${{ github.action_path }}/../.. - id: get-issue-number - uses: ./self-actions/get-issue-number - - - shell: bash - # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 - run: | - rm -fr ./self-actions + uses: ./../self-actions/get-issue-number - uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 id: find-comment diff --git a/actions/local-actions/ActionRuntime.js b/actions/local-actions/ActionRuntime.js new file mode 100644 index 00000000..7c3e9079 --- /dev/null +++ b/actions/local-actions/ActionRuntime.js @@ -0,0 +1,62 @@ +import { appendFile } from "node:fs/promises"; +import process from "node:process"; +import { randomUUID } from "node:crypto"; + +export class ActionRuntime { + getInput(name, { required = false } = {}) { + const value = + process.env[`INPUT_${name.replaceAll(" ", "_").toUpperCase()}`] ?? ""; + + if (required && value.trim() === "") { + throw new Error(`Input required and not supplied: ${name}`); + } + + return value; + } + + async setOutput(name, value) { + await this.#writeCommandFile(process.env.GITHUB_OUTPUT, name, value); + } + + async saveState(name, value) { + await this.#writeCommandFile(process.env.GITHUB_STATE, name, value); + } + + getState(name) { + return process.env[`STATE_${name}`] ?? ""; + } + + getWorkspace() { + const workspacePath = process.env.GITHUB_WORKSPACE ?? ""; + + if (workspacePath.trim() === "") { + throw new Error("GITHUB_WORKSPACE is required."); + } + + return workspacePath; + } + + info(message) { + console.log(message); + } + + setFailed(error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`::error::${message}`); + process.exitCode = 1; + } + + async #writeCommandFile(filePath, name, value) { + if (!filePath) { + throw new Error(`Missing command file for ${name}.`); + } + + const stringValue = String(value); + const delimiter = `ghadelimiter_${randomUUID()}`; + await appendFile( + filePath, + `${name}<<${delimiter}\n${stringValue}\n${delimiter}\n`, + "utf8", + ); + } +} diff --git a/actions/local-actions/LocalActionsManager.js b/actions/local-actions/LocalActionsManager.js new file mode 100644 index 00000000..9fc5e34b --- /dev/null +++ b/actions/local-actions/LocalActionsManager.js @@ -0,0 +1,77 @@ +import { access, mkdir, rm, stat, symlink } from "node:fs/promises"; +import process from "node:process"; +import path from "node:path"; + +export class LocalActionsManager { + async prepare({ sourcePath, workspacePath }) { + const sourceDirectory = await this.resolveSourceDirectory({ sourcePath }); + const destinationPath = this.resolveDestinationPath({ workspacePath }); + + if (await this.#exists(destinationPath)) { + return { + created: false, + destinationPath, + }; + } + + await mkdir(path.dirname(destinationPath), { recursive: true }); + await symlink(sourceDirectory, destinationPath, this.#getSymlinkType()); + + return { + created: true, + destinationPath, + }; + } + + async cleanup({ created, destinationPath }) { + if (!created || !destinationPath) { + return false; + } + + await rm(destinationPath, { force: true, recursive: true }); + return true; + } + + resolveDestinationPath({ workspacePath }) { + if (!workspacePath?.trim()) { + throw new Error("Workspace path is required."); + } + + const normalizedWorkspacePath = path.resolve(workspacePath); + return path.resolve(normalizedWorkspacePath, "../self-actions"); + } + + async resolveSourceDirectory({ sourcePath }) { + return this.#resolveActionPath(sourcePath); + } + + async #resolveActionPath(sourcePath) { + if (!sourcePath?.trim()) { + throw new Error("Input source-path is required."); + } + + const actionPath = path.resolve(sourcePath); + if (!(await this.#exists(actionPath))) { + throw new Error(`Action path does not exist: ${actionPath}`); + } + + if (!(await stat(actionPath)).isDirectory()) { + throw new Error(`Action path must be a directory: ${actionPath}`); + } + + return actionPath; + } + + async #exists(targetPath) { + try { + await access(targetPath); + return true; + } catch { + return false; + } + } + + #getSymlinkType() { + return process.platform === "win32" ? "junction" : "dir"; + } +} diff --git a/actions/local-actions/LocalActionsManager.test.js b/actions/local-actions/LocalActionsManager.test.js new file mode 100644 index 00000000..467985ec --- /dev/null +++ b/actions/local-actions/LocalActionsManager.test.js @@ -0,0 +1,165 @@ +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { LocalActionsManager } from "./LocalActionsManager.js"; + +const createFixture = () => { + const sandboxDirectory = mkdtempSync( + path.join(os.tmpdir(), "local-actions-"), + ); + const workspaceDirectory = path.join(sandboxDirectory, "workspace"); + const actionsDirectory = path.join(workspaceDirectory, "actions"); + const currentActionPath = path.join( + actionsDirectory, + "create-or-update-comment", + ); + const siblingActionPath = path.join(actionsDirectory, "get-issue-number"); + + mkdirSync(workspaceDirectory, { recursive: true }); + mkdirSync(currentActionPath, { recursive: true }); + mkdirSync(siblingActionPath, { recursive: true }); + writeFileSync( + path.join(currentActionPath, "action.yml"), + "name: current\n", + "utf8", + ); + writeFileSync( + path.join(siblingActionPath, "action.yml"), + "name: sibling\n", + "utf8", + ); + + return { + actionsDirectory, + currentActionPath, + sandboxDirectory, + selfActionsPath: path.join(sandboxDirectory, "self-actions"), + workspaceDirectory, + teardown() { + rmSync(sandboxDirectory, { force: true, recursive: true }); + }, + }; +}; + +test("prepare creates a symlink to sibling actions in the destination", async () => { + const fixture = createFixture(); + const manager = new LocalActionsManager(); + + try { + const result = await manager.prepare({ + sourcePath: fixture.actionsDirectory, + workspacePath: fixture.workspaceDirectory, + }); + + assert.equal(result.created, true); + assert.equal(result.destinationPath, fixture.selfActionsPath); + assert.equal(lstatSync(fixture.selfActionsPath).isSymbolicLink(), true); + assert.equal( + realpathSync(fixture.selfActionsPath), + fixture.actionsDirectory, + ); + assert.equal( + readFileSync( + path.join(fixture.selfActionsPath, "get-issue-number", "action.yml"), + "utf8", + ), + "name: sibling\n", + ); + assert.equal( + readFileSync( + path.join( + fixture.selfActionsPath, + "create-or-update-comment", + "action.yml", + ), + "utf8", + ), + "name: current\n", + ); + assert.equal( + existsSync(path.join(fixture.selfActionsPath, "self-actions")), + false, + ); + } finally { + fixture.teardown(); + } +}); + +test("prepare reuses an existing destination without marking it for cleanup", async () => { + const fixture = createFixture(); + const manager = new LocalActionsManager(); + + try { + mkdirSync(fixture.selfActionsPath, { recursive: true }); + writeFileSync( + path.join(fixture.selfActionsPath, "marker.txt"), + "existing\n", + "utf8", + ); + + const result = await manager.prepare({ + sourcePath: fixture.actionsDirectory, + workspacePath: fixture.workspaceDirectory, + }); + + assert.equal(result.created, false); + assert.equal( + readFileSync(path.join(fixture.selfActionsPath, "marker.txt"), "utf8"), + "existing\n", + ); + } finally { + fixture.teardown(); + } +}); + +test("cleanup removes the destination only when it was created by the action", async () => { + const fixture = createFixture(); + const manager = new LocalActionsManager(); + + try { + await manager.prepare({ + sourcePath: fixture.actionsDirectory, + workspacePath: fixture.workspaceDirectory, + }); + + assert.equal( + await manager.cleanup({ + created: true, + destinationPath: fixture.selfActionsPath, + }), + true, + ); + assert.equal( + await manager.cleanup({ + created: false, + destinationPath: fixture.selfActionsPath, + }), + false, + ); + } finally { + fixture.teardown(); + } +}); + +test("resolveDestinationPath resolves to workspace parent self-actions", () => { + const manager = new LocalActionsManager(); + + assert.equal( + manager.resolveDestinationPath({ + workspacePath: "/tmp/workspace", + }), + path.resolve("/tmp/workspace", "../self-actions"), + ); +}); diff --git a/actions/local-actions/README.md b/actions/local-actions/README.md new file mode 100644 index 00000000..b698d85c --- /dev/null +++ b/actions/local-actions/README.md @@ -0,0 +1,38 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY29weSIgY29sb3I9ImJsdWUiPjxyZWN0IHg9IjkiIHk9IjkiIHdpZHRoPSIxMyIgaGVpZ2h0PSIxMyIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PHBhdGggZD0iTTUgMTVIM2EyIDIgMCAwIDEtMi0yVjNhMiAyIDAgMCAxIDItMmgxMGEyIDIgMCAwIDEgMiAydjIiPjwvcGF0aD48L3N2Zz4=) GitHub Action: Local actions + +
+ Local actions +
+ +--- + + + +## Overview + +Action to expose sibling local actions next to the current action directory. +It creates a symlink to the parent actions directory at `../self-actions` relative to `github.workspace` during the main step and removes it automatically in the post step. + +## Usage + +```yaml +- uses: ./../local-actions + with: + source-path: ${{ github.action_path }}/../.. + +- uses: ./../self-actions/get-issue-number +``` + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ----------------- | ------------------------------------------------------------------------------ | ------------ | ----------- | +| **`source-path`** | The actions root path. Pass `${{ github.action_path }}/../..` from the caller. | **true** | - | + +## Outputs + +| **Output** | **Description** | +| ---------- | ---------------------------------------------------- | +| **`path`** | The resolved destination path for the copied actions | diff --git a/actions/local-actions/action.yml b/actions/local-actions/action.yml new file mode 100644 index 00000000..89b57a16 --- /dev/null +++ b/actions/local-actions/action.yml @@ -0,0 +1,25 @@ +# FIXME: This is a workaround until this issue is resolved: https://github.com/actions/runner/issues/1348. +name: "Local actions" +description: | + Action to expose sibling local actions next to the current action directory. + It copies the parent actions directory into a configurable destination and cleans it up automatically in the post action. +author: hoverkraft +branding: + icon: copy + color: blue + +inputs: + source-path: + description: | + The actions root path that contains the sibling local actions. + Pass the caller actions root, typically by appending `/../..` to `github.action_path`. + required: true + +outputs: + path: + description: The resolved destination path for the copied local actions. + +runs: + using: node24 + main: index.js + post: cleanup.js diff --git a/actions/local-actions/cleanup.js b/actions/local-actions/cleanup.js new file mode 100644 index 00000000..472ecde7 --- /dev/null +++ b/actions/local-actions/cleanup.js @@ -0,0 +1,21 @@ +import { ActionRuntime } from "./ActionRuntime.js"; +import { LocalActionsManager } from "./LocalActionsManager.js"; + +const runtime = new ActionRuntime(); +const manager = new LocalActionsManager(); + +try { + const destinationPath = runtime.getState("local_actions_destination_path"); + const cleaned = await manager.cleanup({ + created: runtime.getState("local_actions_created") === "true", + destinationPath, + }); + + if (cleaned) { + runtime.info(`Removed local actions from ${destinationPath}.`); + } else { + runtime.info("Skipped local actions cleanup."); + } +} catch (error) { + runtime.setFailed(error); +} diff --git a/actions/local-actions/index.js b/actions/local-actions/index.js new file mode 100644 index 00000000..2504c04a --- /dev/null +++ b/actions/local-actions/index.js @@ -0,0 +1,37 @@ +import { ActionRuntime } from "./ActionRuntime.js"; +import { LocalActionsManager } from "./LocalActionsManager.js"; + +const runtime = new ActionRuntime(); +const manager = new LocalActionsManager(); + +try { + const sourcePath = runtime.getInput("source-path", { required: true }); + const workspacePath = runtime.getWorkspace(); + const sourceDirectory = await manager.resolveSourceDirectory({ sourcePath }); + const destinationPath = manager.resolveDestinationPath({ workspacePath }); + + runtime.info(`Resolved local actions source: ${sourceDirectory}.`); + runtime.info(`Resolved local actions destination: ${destinationPath}.`); + + const result = await manager.prepare({ + sourcePath, + workspacePath, + }); + + await runtime.setOutput("path", result.destinationPath); + await runtime.saveState("local_actions_created", String(result.created)); + await runtime.saveState( + "local_actions_destination_path", + result.destinationPath, + ); + + if (result.created) { + runtime.info(`Copied local actions to ${result.destinationPath}.`); + } else { + runtime.info( + `Local actions already available at ${result.destinationPath}.`, + ); + } +} catch (error) { + runtime.setFailed(error); +} diff --git a/actions/local-actions/index.test.js b/actions/local-actions/index.test.js new file mode 100644 index 00000000..3220ad98 --- /dev/null +++ b/actions/local-actions/index.test.js @@ -0,0 +1,146 @@ +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const packageDirectory = path.dirname(new URL(import.meta.url).pathname); + +const createFixture = () => { + const sandboxDirectory = mkdtempSync( + path.join(os.tmpdir(), "local-actions-script-"), + ); + const workspaceDirectory = path.join(sandboxDirectory, "workspace"); + const actionsDirectory = path.join(workspaceDirectory, "actions"); + const currentActionPath = path.join( + actionsDirectory, + "create-or-update-comment", + ); + const siblingActionPath = path.join(actionsDirectory, "get-issue-number"); + const outputFile = path.join(sandboxDirectory, "github-output.txt"); + const stateFile = path.join(sandboxDirectory, "github-state.txt"); + + mkdirSync(workspaceDirectory, { recursive: true }); + mkdirSync(currentActionPath, { recursive: true }); + mkdirSync(siblingActionPath, { recursive: true }); + writeFileSync( + path.join(currentActionPath, "action.yml"), + "name: current\n", + "utf8", + ); + writeFileSync( + path.join(siblingActionPath, "action.yml"), + "name: sibling\n", + "utf8", + ); + writeFileSync(outputFile, "", "utf8"); + writeFileSync(stateFile, "", "utf8"); + + return { + actionsDirectory, + currentActionPath, + outputFile, + sandboxDirectory, + selfActionsPath: path.join(sandboxDirectory, "self-actions"), + stateFile, + workspaceDirectory, + teardown() { + rmSync(sandboxDirectory, { force: true, recursive: true }); + }, + }; +}; + +const runNodeScript = (scriptPath, env) => + spawnSync(process.execPath, [scriptPath], { + cwd: packageDirectory, + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + }); + +test("index.js writes outputs and creates the local actions symlink", () => { + const fixture = createFixture(); + + try { + const result = runNodeScript(path.join(packageDirectory, "index.js"), { + GITHUB_WORKSPACE: fixture.workspaceDirectory, + GITHUB_OUTPUT: fixture.outputFile, + GITHUB_STATE: fixture.stateFile, + "INPUT_SOURCE-PATH": fixture.actionsDirectory, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match( + result.stdout, + new RegExp( + `Resolved local actions source: ${fixture.actionsDirectory.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.`, + ), + ); + assert.match( + result.stdout, + new RegExp( + `Resolved local actions destination: ${fixture.selfActionsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.`, + ), + ); + assert.equal(lstatSync(fixture.selfActionsPath).isSymbolicLink(), true); + assert.equal( + realpathSync(fixture.selfActionsPath), + fixture.actionsDirectory, + ); + assert.equal( + existsSync( + path.join(fixture.selfActionsPath, "get-issue-number", "action.yml"), + ), + true, + ); + assert.match( + readFileSync(fixture.outputFile, "utf8"), + /^path<<.+\n.*self-actions\n.+\n$/s, + ); + assert.match( + readFileSync(fixture.stateFile, "utf8"), + /^local_actions_created<<.+\ntrue\n.+\n/s, + ); + assert.match( + readFileSync(fixture.stateFile, "utf8"), + /^local_actions_created<<.+\ntrue\n.+\nlocal_actions_destination_path<<.+\n.*self-actions\n.+\n$/s, + ); + } finally { + fixture.teardown(); + } +}); + +test("cleanup.js removes the created destination from saved state", () => { + const fixture = createFixture(); + + try { + mkdirSync(fixture.selfActionsPath, { recursive: true }); + writeFileSync( + path.join(fixture.selfActionsPath, "marker.txt"), + "cleanup\n", + "utf8", + ); + + const result = runNodeScript(path.join(packageDirectory, "cleanup.js"), { + STATE_local_actions_created: "true", + STATE_local_actions_destination_path: fixture.selfActionsPath, + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(fixture.selfActionsPath), false); + } finally { + fixture.teardown(); + } +}); diff --git a/actions/local-actions/package-lock.json b/actions/local-actions/package-lock.json new file mode 100644 index 00000000..4fa6bfcd --- /dev/null +++ b/actions/local-actions/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "local-actions", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "local-actions", + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + } + } +} diff --git a/actions/local-actions/package.json b/actions/local-actions/package.json new file mode 100644 index 00000000..b33c00d1 --- /dev/null +++ b/actions/local-actions/package.json @@ -0,0 +1,16 @@ +{ + "name": "local-actions", + "version": "1.0.0", + "description": "Expose sibling local GitHub Actions with automatic post cleanup", + "scripts": { + "test": "node --test", + "test:watch": "node --test --watch", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "author": "hoverkraft", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=24.0.0" + } +}