Skip to content

Commit 58b86a8

Browse files
committed
feat: add 'local-actions' action
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
1 parent f1caac2 commit 58b86a8

14 files changed

Lines changed: 578 additions & 20 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ test: ## Execute tests
3131

3232
ci: ## Execute CI tasks
3333
$(MAKE) setup
34-
$(MAKE) npm-audit-fix
34+
$(MAKE) npm-audit-fix || true
3535
$(MAKE) lint-fix
3636
$(MAKE) test
3737

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Opinionated GitHub Actions and reusable workflows for foundational continuous-in
2929
### Matrix & workflow data helpers
3030

3131
- [Get matrix outputs](actions/get-matrix-outputs/README.md) - aggregates outputs across matrix jobs for downstream steps.
32+
- [Local actions](actions/local-actions/README.md) - exposes sibling local actions for a composite action and cleans them up automatically.
3233
- [Set matrix output](actions/set-matrix-output/README.md) - writes structured outputs that can be consumed by other matrix jobs.
3334
- [Local workflow actions](actions/local-workflow-actions/README.md) - loads reusable workflow actions from the current repository.
3435

actions/create-and-merge-pull-request/action.yml

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,12 @@ inputs:
3232
runs:
3333
using: "composite"
3434
steps:
35-
- shell: bash
36-
# FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684
37-
run: mkdir -p ./self-actions/ && cp -r $GITHUB_ACTION_PATH/../* ./self-actions/
35+
- uses: ./../local-actions
36+
with:
37+
source-path: ${{ github.action_path }}
3838

3939
- id: github-actions-bot-user
40-
uses: ./self-actions/get-github-actions-bot-user
41-
42-
- shell: bash
43-
# FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684
44-
run: |
45-
rm -fr ./self-actions
40+
uses: ./../self-actions/get-github-actions-bot-user
4641

4742
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
4843
id: create-pull-request

actions/create-or-update-comment/action.yml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,12 @@ inputs:
3434
runs:
3535
using: "composite"
3636
steps:
37-
- shell: bash
38-
# FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684
39-
run: |
40-
[ -d ./self-actions ] || (mkdir -p ./self-actions/ && cp -r $GITHUB_ACTION_PATH/../* ./self-actions/)
37+
- uses: ./../local-actions
38+
with:
39+
source-path: ${{ github.action_path }}
4140

4241
- id: get-issue-number
43-
uses: ./self-actions/get-issue-number
44-
45-
- shell: bash
46-
# FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684
47-
run: |
48-
rm -fr ./self-actions
42+
uses: ./../self-actions/get-issue-number
4943

5044
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
5145
id: find-comment
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { appendFile } from "node:fs/promises";
2+
import process from "node:process";
3+
import { randomUUID } from "node:crypto";
4+
5+
export class ActionRuntime {
6+
getInput(name, { required = false } = {}) {
7+
const value =
8+
process.env[`INPUT_${name.replaceAll(" ", "_").toUpperCase()}`] ?? "";
9+
10+
if (required && value.trim() === "") {
11+
throw new Error(`Input required and not supplied: ${name}`);
12+
}
13+
14+
return value;
15+
}
16+
17+
async setOutput(name, value) {
18+
await this.#writeCommandFile(process.env.GITHUB_OUTPUT, name, value);
19+
}
20+
21+
async saveState(name, value) {
22+
await this.#writeCommandFile(process.env.GITHUB_STATE, name, value);
23+
}
24+
25+
getState(name) {
26+
return process.env[`STATE_${name}`] ?? "";
27+
}
28+
29+
info(message) {
30+
console.log(message);
31+
}
32+
33+
setFailed(error) {
34+
const message = error instanceof Error ? error.message : String(error);
35+
console.error(`::error::${message}`);
36+
process.exitCode = 1;
37+
}
38+
39+
async #writeCommandFile(filePath, name, value) {
40+
if (!filePath) {
41+
throw new Error(`Missing command file for ${name}.`);
42+
}
43+
44+
const stringValue = String(value);
45+
const delimiter = `ghadelimiter_${randomUUID()}`;
46+
await appendFile(
47+
filePath,
48+
`${name}<<${delimiter}\n${stringValue}\n${delimiter}\n`,
49+
"utf8",
50+
);
51+
}
52+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
2+
import path from "node:path";
3+
4+
export class LocalActionsManager {
5+
async prepare({ sourcePath, destination }) {
6+
const actionPath = await this.#resolveActionPath(sourcePath);
7+
const sourceDirectory = path.dirname(actionPath);
8+
const destinationPath = this.resolveDestinationPath({
9+
actionPath,
10+
destination,
11+
});
12+
13+
if (await this.#exists(destinationPath)) {
14+
return {
15+
created: false,
16+
destinationPath,
17+
};
18+
}
19+
20+
const entryNames = await readdir(sourceDirectory);
21+
await mkdir(destinationPath, { recursive: true });
22+
23+
for (const entryName of entryNames) {
24+
const sourceEntryPath = path.join(sourceDirectory, entryName);
25+
if (sourceEntryPath === destinationPath) {
26+
continue;
27+
}
28+
29+
await cp(sourceEntryPath, path.join(destinationPath, entryName), {
30+
recursive: true,
31+
});
32+
}
33+
34+
return {
35+
created: true,
36+
destinationPath,
37+
};
38+
}
39+
40+
async cleanup({ created, destinationPath }) {
41+
if (!created || !destinationPath) {
42+
return false;
43+
}
44+
45+
await rm(destinationPath, { force: true, recursive: true });
46+
return true;
47+
}
48+
49+
resolveDestinationPath({ actionPath, destination }) {
50+
if (!destination?.trim()) {
51+
throw new Error("Input destination is required.");
52+
}
53+
54+
const destinationPath = path.resolve(actionPath, destination);
55+
const sourceDirectory = path.dirname(actionPath);
56+
if (destinationPath === sourceDirectory) {
57+
throw new Error(
58+
"Destination path must not be the source actions directory.",
59+
);
60+
}
61+
62+
if (destinationPath === actionPath) {
63+
throw new Error(
64+
"Destination path must not be the current action directory.",
65+
);
66+
}
67+
68+
return destinationPath;
69+
}
70+
71+
async #resolveActionPath(sourcePath) {
72+
if (!sourcePath?.trim()) {
73+
throw new Error("Input source-path is required.");
74+
}
75+
76+
const actionPath = path.resolve(sourcePath);
77+
if (!(await this.#exists(actionPath))) {
78+
throw new Error(`Action path does not exist: ${actionPath}`);
79+
}
80+
81+
if (!(await stat(actionPath)).isDirectory()) {
82+
throw new Error(`Action path must be a directory: ${actionPath}`);
83+
}
84+
85+
return actionPath;
86+
}
87+
88+
async #exists(targetPath) {
89+
try {
90+
await access(targetPath);
91+
return true;
92+
} catch {
93+
return false;
94+
}
95+
}
96+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
existsSync,
3+
mkdtempSync,
4+
mkdirSync,
5+
readFileSync,
6+
rmSync,
7+
writeFileSync,
8+
} from "node:fs";
9+
import os from "node:os";
10+
import path from "node:path";
11+
import test from "node:test";
12+
import assert from "node:assert/strict";
13+
14+
import { LocalActionsManager } from "./LocalActionsManager.js";
15+
16+
const createFixture = () => {
17+
const rootDirectory = mkdtempSync(path.join(os.tmpdir(), "local-actions-"));
18+
const actionsDirectory = path.join(rootDirectory, "actions");
19+
const currentActionPath = path.join(
20+
actionsDirectory,
21+
"create-or-update-comment",
22+
);
23+
const siblingActionPath = path.join(actionsDirectory, "get-issue-number");
24+
25+
mkdirSync(currentActionPath, { recursive: true });
26+
mkdirSync(siblingActionPath, { recursive: true });
27+
writeFileSync(
28+
path.join(currentActionPath, "action.yml"),
29+
"name: current\n",
30+
"utf8",
31+
);
32+
writeFileSync(
33+
path.join(siblingActionPath, "action.yml"),
34+
"name: sibling\n",
35+
"utf8",
36+
);
37+
38+
return {
39+
actionsDirectory,
40+
currentActionPath,
41+
rootDirectory,
42+
selfActionsPath: path.join(actionsDirectory, "self-actions"),
43+
teardown() {
44+
rmSync(rootDirectory, { force: true, recursive: true });
45+
},
46+
};
47+
};
48+
49+
test("prepare copies sibling actions into the destination directory", async () => {
50+
const fixture = createFixture();
51+
const manager = new LocalActionsManager();
52+
53+
try {
54+
const result = await manager.prepare({
55+
destination: "../self-actions",
56+
sourcePath: fixture.currentActionPath,
57+
});
58+
59+
assert.equal(result.created, true);
60+
assert.equal(result.destinationPath, fixture.selfActionsPath);
61+
assert.equal(
62+
readFileSync(
63+
path.join(fixture.selfActionsPath, "get-issue-number", "action.yml"),
64+
"utf8",
65+
),
66+
"name: sibling\n",
67+
);
68+
assert.equal(
69+
readFileSync(
70+
path.join(
71+
fixture.selfActionsPath,
72+
"create-or-update-comment",
73+
"action.yml",
74+
),
75+
"utf8",
76+
),
77+
"name: current\n",
78+
);
79+
assert.equal(
80+
existsSync(path.join(fixture.selfActionsPath, "self-actions")),
81+
false,
82+
);
83+
} finally {
84+
fixture.teardown();
85+
}
86+
});
87+
88+
test("prepare reuses an existing destination without marking it for cleanup", async () => {
89+
const fixture = createFixture();
90+
const manager = new LocalActionsManager();
91+
92+
try {
93+
mkdirSync(fixture.selfActionsPath, { recursive: true });
94+
writeFileSync(
95+
path.join(fixture.selfActionsPath, "marker.txt"),
96+
"existing\n",
97+
"utf8",
98+
);
99+
100+
const result = await manager.prepare({
101+
destination: "../self-actions",
102+
sourcePath: fixture.currentActionPath,
103+
});
104+
105+
assert.equal(result.created, false);
106+
assert.equal(
107+
readFileSync(path.join(fixture.selfActionsPath, "marker.txt"), "utf8"),
108+
"existing\n",
109+
);
110+
} finally {
111+
fixture.teardown();
112+
}
113+
});
114+
115+
test("cleanup removes the destination only when it was created by the action", async () => {
116+
const fixture = createFixture();
117+
const manager = new LocalActionsManager();
118+
119+
try {
120+
await manager.prepare({
121+
destination: "../self-actions",
122+
sourcePath: fixture.currentActionPath,
123+
});
124+
125+
assert.equal(
126+
await manager.cleanup({
127+
created: true,
128+
destinationPath: fixture.selfActionsPath,
129+
}),
130+
true,
131+
);
132+
assert.equal(
133+
await manager.cleanup({
134+
created: false,
135+
destinationPath: fixture.selfActionsPath,
136+
}),
137+
false,
138+
);
139+
} finally {
140+
fixture.teardown();
141+
}
142+
});

actions/local-actions/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!-- header:start -->
2+
3+
# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY29weSIgY29sb3I9ImJsdWUiPjxyZWN0IHg9IjkiIHk9IjkiIHdpZHRoPSIxMyIgaGVpZ2h0PSIxMyIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PHBhdGggZD0iTTUgMTVIM2EyIDIgMCAwIDEtMi0yVjNhMiAyIDAgMCAxIDItMmgxMGEyIDIgMCAwIDEgMiAydjIiPjwvcGF0aD48L3N2Zz4=) GitHub Action: Local actions
4+
5+
<div align="center">
6+
<img src="../../.github/logo.svg" width="60px" align="center" alt="Local actions" />
7+
</div>
8+
9+
---
10+
11+
<!-- header:end -->
12+
13+
## Overview
14+
15+
Action to expose sibling local actions next to the current action directory.
16+
It copies the parent actions directory into a configurable destination during the main step and removes it automatically in the post step.
17+
18+
## Usage
19+
20+
```yaml
21+
- uses: ./../local-actions
22+
with:
23+
source-path: ${{ github.action_path }}
24+
destination: ../self-actions
25+
26+
- uses: ./../self-actions/get-issue-number
27+
```
28+
29+
## Inputs
30+
31+
| **Input** | **Description** | **Required** | **Default** |
32+
| ----------------- | --------------------------------------------------------------------------- | ------------ | ----------------- |
33+
| **`source-path`** | The current action path. Pass `${{ github.action_path }}` from the caller. | **true** | - |
34+
| **`destination`** | Destination path for the copied local actions, resolved from `source-path`. | **false** | `../self-actions` |
35+
36+
## Outputs
37+
38+
| **Output** | **Description** |
39+
| ---------- | ------------------------------------------------------ |
40+
| **`path`** | The destination path input used for the copied actions |

0 commit comments

Comments
 (0)