Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 4 additions & 9 deletions actions/create-and-merge-pull-request/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 4 additions & 10 deletions actions/create-or-update-comment/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions actions/local-actions/ActionRuntime.js
Original file line number Diff line number Diff line change
@@ -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",
);
}
}
77 changes: 77 additions & 0 deletions actions/local-actions/LocalActionsManager.js
Original file line number Diff line number Diff line change
@@ -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";
}
}
165 changes: 165 additions & 0 deletions actions/local-actions/LocalActionsManager.test.js
Original file line number Diff line number Diff line change
@@ -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"),
);
});
Loading
Loading