Skip to content

Commit a05258c

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

13 files changed

Lines changed: 575 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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { existsSync, readdirSync, statSync } from "node:fs";
2+
import path from "node:path";
3+
4+
import * as io from "@actions/io";
5+
6+
export class LocalActionsManager {
7+
async prepare({ sourcePath, destination }) {
8+
const actionPath = this.#resolveActionPath(sourcePath);
9+
const sourceDirectory = path.dirname(actionPath);
10+
const destinationPath = this.resolveDestinationPath({
11+
actionPath,
12+
destination,
13+
});
14+
15+
if (existsSync(destinationPath)) {
16+
return {
17+
created: false,
18+
destinationPath,
19+
};
20+
}
21+
22+
const entries = readdirSync(sourceDirectory, { withFileTypes: true });
23+
await io.mkdirP(destinationPath);
24+
25+
for (const entry of entries) {
26+
const sourceEntryPath = path.join(sourceDirectory, entry.name);
27+
if (sourceEntryPath === destinationPath) {
28+
continue;
29+
}
30+
31+
await io.cp(sourceEntryPath, path.join(destinationPath, entry.name), {
32+
recursive: true,
33+
});
34+
}
35+
36+
return {
37+
created: true,
38+
destinationPath,
39+
};
40+
}
41+
42+
async cleanup({ created, destinationPath }) {
43+
if (!created || !destinationPath) {
44+
return false;
45+
}
46+
47+
await io.rmRF(destinationPath);
48+
return true;
49+
}
50+
51+
resolveDestinationPath({ actionPath, destination }) {
52+
if (!destination?.trim()) {
53+
throw new Error("Input destination is required.");
54+
}
55+
56+
const destinationPath = path.resolve(actionPath, destination);
57+
const sourceDirectory = path.dirname(actionPath);
58+
if (destinationPath === sourceDirectory) {
59+
throw new Error(
60+
"Destination path must not be the source actions directory.",
61+
);
62+
}
63+
64+
if (destinationPath === actionPath) {
65+
throw new Error(
66+
"Destination path must not be the current action directory.",
67+
);
68+
}
69+
70+
return destinationPath;
71+
}
72+
73+
#resolveActionPath(sourcePath) {
74+
if (!sourcePath?.trim()) {
75+
throw new Error("Input source-path is required.");
76+
}
77+
78+
const actionPath = path.resolve(sourcePath);
79+
if (!existsSync(actionPath)) {
80+
throw new Error(`Action path does not exist: ${actionPath}`);
81+
}
82+
83+
if (!statSync(actionPath).isDirectory()) {
84+
throw new Error(`Action path must be a directory: ${actionPath}`);
85+
}
86+
87+
return actionPath;
88+
}
89+
}
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 |

actions/local-actions/action.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: "Local actions"
2+
description: |
3+
Action to expose sibling local actions next to the current action directory.
4+
It copies the parent actions directory into a configurable destination and cleans it up automatically in the post action.
5+
This is a workaround until this issue is resolved: https://github.com/actions/runner/issues/1348.
6+
author: hoverkraft
7+
branding:
8+
icon: copy
9+
color: blue
10+
11+
inputs:
12+
source-path:
13+
description: |
14+
The current action path.
15+
Pass the caller action path, typically from the `github.action_path` context.
16+
required: true
17+
destination:
18+
description: |
19+
Destination path for the copied local actions.
20+
Relative paths are resolved from `source-path`.
21+
required: false
22+
default: "../self-actions"
23+
24+
outputs:
25+
path:
26+
description: The destination path input used for the copied local actions.
27+
28+
runs:
29+
using: node24
30+
main: index.js
31+
post: cleanup.js

actions/local-actions/cleanup.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as core from "@actions/core";
2+
3+
import { LocalActionsManager } from "./LocalActionsManager.js";
4+
5+
const manager = new LocalActionsManager();
6+
7+
try {
8+
const destinationPath = core.getState("local_actions_destination_path");
9+
const cleaned = await manager.cleanup({
10+
created: core.getState("local_actions_created") === "true",
11+
destinationPath,
12+
});
13+
14+
if (cleaned) {
15+
core.info(`Removed local actions from ${destinationPath}.`);
16+
} else {
17+
core.info("Skipped local actions cleanup.");
18+
}
19+
} catch (error) {
20+
core.setFailed(error instanceof Error ? error : String(error));
21+
}

0 commit comments

Comments
 (0)