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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Add a step like this to your workflow:
committer_email: mail@example.com

# The local path to the directory where your repository is located. You should use actions/checkout first to set it up.
# Relative to the runner workspace, or an absolute path (e.g. ${{ github.workspace }}/path).
# Note: $GITHUB_WORKSPACE is not expanded in with: — use ${{ github.workspace }} instead.
# Default: '.'
cwd: './path/to/the/repo'

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ inputs:
description: The email of the custom committer you want to use
required: false
cwd:
description: The directory where your repository is located. You should use actions/checkout first to set it up
description: The directory where your repository is located (relative to the runner workspace, or an absolute path). You should use actions/checkout first to set it up. In with:, use ${{ github.workspace }} — $GITHUB_WORKSPACE is not expanded.
required: false
default: '.'
default_author:
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as core from '@actions/core';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import simpleGit, {Response} from 'simple-git';
import simpleGit, {Response, SimpleGit} from 'simple-git';
import {
checkInputs,
getInput,
Expand All @@ -12,16 +12,19 @@ import {
} from './io';
import {
assertNoUnexpectedGitlinks,
assertWorkingDirectory,
findUnexpectedGitlinks,
log,
matchGitArgs,
neutralizeLogString,
parseInputArray,
pickGitIdentityConfig,
resolveBaseDir,
} from './util';

const baseDir = path.join(process.cwd(), getInput('cwd') || '');
const git = simpleGit({baseDir});
const cwdInput = getInput('cwd') || '';
const baseDir = resolveBaseDir(cwdInput);
let git!: SimpleGit;

/** Env for git child processes; restricts transports unless opt-out is set. */
function gitChildEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
Expand All @@ -43,6 +46,9 @@ const exitErrors: Error[] = [];

core.info(`Running in ${baseDir}`);
(async () => {
assertWorkingDirectory(baseDir, cwdInput);
git = simpleGit({baseDir});

await checkInputs();

git.env(gitChildEnv());
Expand Down
26 changes: 26 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,34 @@ import * as YAML from 'js-yaml';
import {getOctokit} from '@actions/github';
import {execFileSync} from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import {getInput} from './io';

/**
* Resolves the action working directory from the `cwd` input.
* Absolute paths are kept as-is; relative paths are resolved against `from`
* (default: `process.cwd()`). Empty input is treated as `'.'`.
*/
export function resolveBaseDir(
cwdInput: string,
from: string = process.cwd(),
): string {
return path.resolve(from, cwdInput || '.');
}

/**
* Ensures `dir` exists and is a directory before constructing simple-git.
*/
export function assertWorkingDirectory(dir: string, cwdInput: string): void {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
throw new Error(
`The cwd input '${cwdInput || '.'}' resolved to '${dir}', which is not an existing directory. ` +
'Use a path relative to the runner workspace, or an absolute path that exists ' +
'(e.g. ${{ github.workspace }}/path — note that $GITHUB_WORKSPACE is not expanded in with:).',
);
}
}

function getOctokitClient() {
const token = getInput('github_token');
if (!token) {
Expand Down
53 changes: 53 additions & 0 deletions test/integration/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,57 @@ describe('action integration', () => {
// Untracked file should still be on disk.
expect(fs.existsSync(path.join(f.local, 'skip-me.txt'))).toBe(true);
});

it('commits when cwd is an absolute path and process.cwd differs', () => {
const f = fixture!;
writeFile(f.local, 'abs-cwd.txt', 'absolute\n');
const before = gitRevParse(f.local, 'HEAD');
// Spawn from the fixture parent so process.cwd() !== the git work tree.
const spawnCwd = path.dirname(f.local);

const result = runAction(
f,
{
message: 'Absolute cwd commit',
cwd: f.local,
push: 'false',
},
{spawnCwd},
);

expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(
new RegExp(`Running in ${f.local.replace(/\\/g, '\\\\')}`),
);
const after = gitRevParse(f.local, 'HEAD');
expect(after).not.toBe(before);
expect(after).toBe(result.outputs.commit_long_sha);
expect(listFilesAtHead(f.local)).toContain('abs-cwd.txt');
});

it('fails clearly when cwd does not exist without dumping the bundle', () => {
const f = fixture!;
const missing = path.join(
path.dirname(f.local),
`missing-cwd-${process.pid}-${Date.now()}`,
);

const result = runAction(
f,
{
cwd: missing,
push: 'false',
},
{spawnCwd: path.dirname(f.local)},
);

expect(result.status).not.toBe(0);
const combined = `${result.stdout}\n${result.stderr}`;
expect(combined).toMatch(/not an existing directory/);
expect(combined).toContain(missing);
// Uncaught throws from the minified bundle dump the whole line of source.
expect(combined).not.toMatch(/function isObject/);
expect(combined.length).toBeLessThan(50_000);
});
});
15 changes: 6 additions & 9 deletions test/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,14 @@ export interface ActionInputs {
/**
* Spawn the shipped action (lib/index.js) with an allowlisted environment.
*
* The action resolves the working tree as `path.join(process.cwd(), cwdInput)`.
* On modern Node, `path.join` does not treat an absolute second segment as a
* new root, so we spawn with `process.cwd()` set to the fixture and pass
* `cwd: '.'` rather than an absolute path.
* By default the child process runs with `process.cwd()` = the fixture clone
* and `cwd: '.'`. Pass an absolute `inputs.cwd` with `options.spawnCwd` set to
* another directory to exercise absolute-path resolution.
*/
export function runAction(
fixture: Fixture,
inputs: ActionInputs = {},
options: {spawnCwd?: string} = {},
): RunActionResult {
assertLocalOrigin(
git(['remote', 'get-url', 'origin'], fixture.local),
Expand All @@ -209,8 +209,6 @@ export function runAction(
);
fs.writeFileSync(outputFile, '');

const restInputs = {...inputs};
delete restInputs.cwd;
const merged: Record<string, string> = {
// Mirror action.yml defaults that matter when spawning lib/ directly.
cwd: '.',
Expand All @@ -225,7 +223,7 @@ export function runAction(
author_name: 'Integration Tester',
author_email: 'integration@example.com',
message: 'Integration test commit',
...restInputs,
...inputs,
};

const env: NodeJS.ProcessEnv = {
Expand All @@ -249,8 +247,7 @@ export function runAction(
}

const result = spawnSync(process.execPath, [ACTION_ENTRY], {
// Workspace = fixture clone; action cwd input stays relative ('.').
cwd: fixture.local,
cwd: options.spawnCwd ?? fixture.local,
env,
encoding: 'utf8',
// Action can take a bit when doing push/tag; keep a generous limit.
Expand Down
60 changes: 60 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,73 @@
import {
assertNoUnexpectedGitlinks,
assertValidBranchName,
assertWorkingDirectory,
findUnexpectedGitlinks,
matchGitArgs,
neutralizeForLog,
neutralizeLogString,
parseInputArray,
pickGitIdentityConfig,
resolveBaseDir,
} from '../src/util';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

describe('resolveBaseDir', () => {
const from = path.join(path.sep, 'home', 'runner', 'work', 'repo', 'repo');

it('resolves relative paths against from', () => {
expect(resolveBaseDir('./my-checkout', from)).toBe(
path.join(from, 'my-checkout'),
);
expect(resolveBaseDir('subdir', from)).toBe(path.join(from, 'subdir'));
});

it('keeps absolute paths', () => {
const abs = path.join(from, 'my-checkout-path');
expect(resolveBaseDir(abs, from)).toBe(abs);
expect(resolveBaseDir(abs, path.join(from, 'other'))).toBe(abs);
});

it('treats empty and "." as from', () => {
expect(resolveBaseDir('', from)).toBe(from);
expect(resolveBaseDir('.', from)).toBe(from);
});
});

describe('assertWorkingDirectory', () => {
it('accepts an existing directory', () => {
expect(() =>
assertWorkingDirectory(os.tmpdir(), os.tmpdir()),
).not.toThrow();
});

it('rejects a missing path', () => {
const missing = path.join(
os.tmpdir(),
`aac-missing-${process.pid}-${Date.now()}`,
);
expect(() => assertWorkingDirectory(missing, missing)).toThrow(
/not an existing directory/,
);
});

it('rejects a file path', () => {
const file = path.join(
os.tmpdir(),
`aac-file-${process.pid}-${Date.now()}.txt`,
);
fs.writeFileSync(file, 'x');
try {
expect(() => assertWorkingDirectory(file, file)).toThrow(
/not an existing directory/,
);
} finally {
fs.unlinkSync(file);
}
});
});

describe('parseInputArray', () => {
beforeAll(() => {
Expand Down