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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "always"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ Examples:

```yaml
steps:
- uses: elastic/github-actions/my-action@v1.0.0
- uses: elastic/github-actions/litellm-token@v2.1.1
```

```yaml
steps:
- uses: elastic/github-actions/my-action@abc123def4567890abc123def4567890abc123de # v1.0.0
- uses: elastic/github-actions/litellm-token@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v2.1.1
```

The `@<ref>` portion is always a repository ref. That means a tag, branch, or SHA points to a
Expand All @@ -34,8 +34,8 @@ that commit.
Because refs are repository-wide, action usage looks path-scoped but versioning is commit-scoped.
In practice, this means:

- `elastic/github-actions/my-action@v1.0.0` uses the `my-action/` directory from
the repo tag `v1.0.0`
- `elastic/github-actions/litellm-token@v2.1.1` uses the `litellm-token/` directory from
the repo tag `v2.1.1`
- `elastic/github-actions/another-action@v3.0.0` would use the `another-action/`
directory from the repo tag `v3.0.0`
- SHA pinning is supported and recommended when consumers want an immutable reference
Expand All @@ -47,12 +47,12 @@ jobs:
first_job:
runs-on: ubuntu-latest
steps:
- uses: elastic/github-actions/action-one@abc123
- uses: elastic/github-actions/litellm-token@abc123 # v3.1.0

second_job:
runs-on: ubuntu-latest
steps:
- uses: elastic/github-actions/action-two@def456
- uses: elastic/github-actions/action-two@def456 # v3.0.0
```

## Development
Expand Down Expand Up @@ -80,8 +80,12 @@ my-action/
action.yml
src/
index.ts
pre.ts # optional
post.ts # optional
dist/
index.js
pre.js # generated when src/pre.ts exists
post.js # generated when src/post.ts exists
licenses.txt
```

Expand All @@ -94,6 +98,8 @@ description: Example action layout for this repository
runs:
using: node24
main: dist/index.js
pre: dist/pre.js
post: dist/post.js
```

Example usage after release:
Expand All @@ -117,7 +123,25 @@ artifact:
CI installs dependencies with `pnpm install --frozen-lockfile`, so changes that require lockfile
updates must include an updated `pnpm-lock.yaml`.

The build treats any **top-level directory** that contains an `action.yml` as an action and builds it with `@vercel/ncc`. `src/index.ts` is the required entrypoint.
The build treats any **top-level directory** that contains an `action.yml` as an action and builds it with `@vercel/ncc`.

Build assumptions for root-managed actions:

- `src/index.ts` is required and always builds to `dist/index.js`
- `src/pre.ts` is optional and, when present, builds to `dist/pre.js`
- `src/post.ts` is optional and, when present, builds to `dist/post.js`
- `pnpm build` deletes each action's existing `dist/` directory before rebuilding generated output
- `dist/licenses.txt` is generated from the main bundle build

If an action declares JavaScript lifecycle hooks in `action.yml`, they should follow the same output convention:

```yaml
runs:
using: node24
main: dist/index.js
pre: dist/pre.js
post: dist/post.js
```

### Release tags and floating majors

Expand Down
75 changes: 63 additions & 12 deletions scripts/build-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:fs');

const { getActionDirs, runBuildActions } = await import('./build-actions.ts');
const { getActionDirs, runBuildActions } = await import('./build-actions');

const rootDir = '/repo';
const log = vi.fn();
const error = vi.fn();

function seedFs(files: Record<string, string>): void {
vol.fromJSON(files, rootDir);
Expand Down Expand Up @@ -36,14 +38,11 @@ describe('getActionDirs', () => {

describe('runBuildActions', () => {
it('returns an error when ncc is unavailable', () => {
const error = vi.fn();

expect(runBuildActions({ rootDir, error })).toBe(1);
expect(error).toHaveBeenCalledWith('ncc is not installed. Run pnpm install before building actions.');
});

it('skips building when no root-managed actions exist', () => {
const log = vi.fn();
seedFs({
'/repo/node_modules/@vercel/ncc/dist/ncc/cli.js': '',
});
Expand All @@ -52,12 +51,13 @@ describe('runBuildActions', () => {
expect(log).toHaveBeenCalledWith('No root-managed actions found. Skipping build.');
});

it('builds actions with ncc', () => {
const log = vi.fn();
it('builds the main action bundle', () => {
const actionDir = path.join(rootDir, 'my-action');
const spawn = vi.fn((_, __, options: { cwd: string }) => {
const spawn = vi.fn((_, args: string[], options: { cwd: string }) => {
const outputDir = path.join(options.cwd, args[args.indexOf('--out') + 1]!);
seedFs({
[`${options.cwd}/dist/index.js`]: 'module.exports = {};',
[path.join(outputDir, 'index.js')]: 'module.exports = "main";',
[path.join(outputDir, 'licenses.txt')]: 'license text',
});

return { status: 0 };
Expand All @@ -70,14 +70,65 @@ describe('runBuildActions', () => {
});

expect(runBuildActions({ rootDir, log, spawn })).toBe(0);
expect(log).toHaveBeenCalledWith('Building my-action');
expect(spawn).toHaveBeenCalledOnce();
expect(vol.existsSync(path.join(actionDir, 'dist', 'index.js'))).toBe(true);
expect(vol.readFileSync(path.join(actionDir, 'dist', 'index.js'), 'utf8')).toContain('main');
expect(vol.readFileSync(path.join(actionDir, 'dist', 'licenses.txt'), 'utf8')).toContain('license text');
});

it('fails when an action is missing src/index.ts', () => {
const error = vi.fn();
it('builds optional pre and post bundles to flat dist files', () => {
const actionDir = path.join(rootDir, 'my-action');
const spawn = vi.fn((_, args: string[], options: { cwd: string }) => {
const outputDir = path.join(options.cwd, args[args.indexOf('--out') + 1]!);
const entryFile = args[2];
const writes: Record<string, string> = {
[path.join(outputDir, 'index.js')]: `module.exports = ${JSON.stringify(entryFile)};`,
};

if (args.includes('--license')) {
writes[path.join(outputDir, 'licenses.txt')] = 'license text';
}

seedFs(writes);
return { status: 0 };
});

seedFs({
'/repo/node_modules/@vercel/ncc/dist/ncc/cli.js': '',
'/repo/my-action/action.yml': '',
'/repo/my-action/src/index.ts': 'export {};',
'/repo/my-action/src/pre.ts': 'export {};',
'/repo/my-action/src/post.ts': 'export {};',
'/repo/my-action/dist/pre.js': 'stale pre',
'/repo/my-action/dist/post.js': 'stale post',
});

expect(runBuildActions({ rootDir, log, spawn })).toBe(0);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.any(String),
expect.arrayContaining(['src/index.ts', '--out', 'dist', '--license', 'licenses.txt']),
expect.objectContaining({ cwd: actionDir }),
);
expect(spawn).toHaveBeenNthCalledWith(
2,
expect.any(String),
expect.arrayContaining(['src/pre.ts', '--out', path.join('dist', 'pre')]),
expect.objectContaining({ cwd: actionDir }),
);
expect(spawn).toHaveBeenNthCalledWith(
3,
expect.any(String),
expect.arrayContaining(['src/post.ts', '--out', path.join('dist', 'post')]),
expect.objectContaining({ cwd: actionDir }),
);
expect(vol.readFileSync(path.join(actionDir, 'dist', 'pre.js'), 'utf8')).toContain('src/pre.ts');
expect(vol.readFileSync(path.join(actionDir, 'dist', 'post.js'), 'utf8')).toContain('src/post.ts');
expect(vol.existsSync(path.join(actionDir, 'dist', 'pre'))).toBe(false);
expect(vol.existsSync(path.join(actionDir, 'dist', 'post'))).toBe(false);
});

it('fails when an action is missing src/index.ts', () => {
seedFs({
'/repo/node_modules/@vercel/ncc/dist/ncc/cli.js': '',
'/repo/my-action/action.yml': '',
Expand Down
62 changes: 56 additions & 6 deletions scripts/build-actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import { existsSync, readdirSync, renameSync, rmSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
Expand All @@ -22,6 +22,12 @@ type RunBuildActionsOptions = {
error?: (message: string) => void;
};

type HookBuild = {
sourceFile: string;
outputDir: string;
finalFile: string;
};

export function getActionDirs(rootDir: string): string[] {
return readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
Expand Down Expand Up @@ -51,17 +57,31 @@ export function runBuildActions({
}

for (const actionDir of actionDirs) {
const entryFile = path.join(actionDir, 'src', 'index.ts');
const relativeActionDir = path.relative(rootDir, actionDir);
const mainSourceFile = path.join(actionDir, 'src', 'index.ts');
const hookBuilds: HookBuild[] = [
{
sourceFile: path.join(actionDir, 'src', 'pre.ts'),
outputDir: path.join(actionDir, 'dist', 'pre'),
finalFile: path.join(actionDir, 'dist', 'pre.js'),
},
{
sourceFile: path.join(actionDir, 'src', 'post.ts'),
outputDir: path.join(actionDir, 'dist', 'post'),
finalFile: path.join(actionDir, 'dist', 'post.js'),
},
];

if (!existsSync(entryFile)) {
if (!existsSync(mainSourceFile)) {
error(`${relativeActionDir} is missing src/index.ts.`);
return 1;
}

log(`Building ${relativeActionDir}`);

const result = spawn(
rmSync(path.join(actionDir, 'dist'), { recursive: true, force: true });

const mainResult = spawn(
process.execPath,
[
nccCliPath,
Expand All @@ -80,8 +100,38 @@ export function runBuildActions({
},
);

if (result.status !== 0) {
return result.status ?? 1;
if (mainResult.status !== 0) {
return mainResult.status ?? 1;
}

for (const hookBuild of hookBuilds) {
if (!existsSync(hookBuild.sourceFile)) {
continue;
}

const result = spawn(
process.execPath,
[
nccCliPath,
'build',
path.relative(actionDir, hookBuild.sourceFile),
'--out',
path.relative(actionDir, hookBuild.outputDir),
'--target',
'es2022',
],
{
cwd: actionDir,
stdio: 'inherit',
},
);

if (result.status !== 0) {
return result.status ?? 1;
}

renameSync(path.join(hookBuild.outputDir, 'index.js'), hookBuild.finalFile);
rmSync(hookBuild.outputDir, { recursive: true, force: true });
}
}

Expand Down
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
Expand Down