diff --git a/Makefile b/Makefile index 81f70a6..6cbd9d9 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 8aa37ba..7b139c3 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 94ffae9..cef4d60 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 6476672..670df10 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 0000000..7c3e907 --- /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 0000000..9fc5e34 --- /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 0000000..467985e --- /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 0000000..b698d85 --- /dev/null +++ b/actions/local-actions/README.md @@ -0,0 +1,38 @@ + + +#  GitHub Action: Local actions + +