Skip to content

Commit ba3fd31

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

14 files changed

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

0 commit comments

Comments
 (0)