Skip to content

Commit a7cef38

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

14 files changed

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

0 commit comments

Comments
 (0)