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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ Add a step like this to your workflow:
# Default: ignore
pathspec_error_handling: ignore

# Arguments for the git pull command. By default, the action does not pull.
# Whether to pull from the remote. Set to true to run git pull with no extra
# args, false (or omit) to skip, or a string for git pull arguments
# (e.g. '--rebase --autostash').
# Default: ''
pull: '--rebase --autostash ...'
pull: true

# Whether to push the commit and, if any, its tags to the repo. It can also be used to set the git push arguments (see the paragraph below for more info)
# Default: true
Expand Down Expand Up @@ -153,7 +155,7 @@ By default the action runs the following command: `git push origin ${new_branch

One way to use this is if you want to force push to a trusted/static branch name in your repo: set the `push` input to, for example, `origin yourBranch --force`. Do not build that string from untrusted refs such as `github.head_ref`.

If multiple jobs may push to the same branch (for example a matrix), set `push_attempts` to try the push more than once. When `pull` is set, each failed attempt re-runs that pull (e.g. `--rebase --autostash`) before pushing again, so a commit that lost a race can catch up to the remote tip. Without `pull`, retries only re-run push. The default is `1` (no retries). A typical concurrent setup looks like:
If multiple jobs may push to the same branch (for example a matrix), set `push_attempts` to try the push more than once. When `pull` is set (`true` for a default `git pull`, or a string such as `--rebase --autostash`), each failed attempt re-runs that pull before pushing again, so a commit that lost a race can catch up to the remote tip. Without `pull`, retries only re-run push. The default is `1` (no retries). A typical concurrent setup looks like:

```yaml
with:
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ inputs:
required: false
default: ignore
pull:
description: Arguments for the git pull command. By default, the action does not pull.
description: Whether to pull from the remote. true runs git pull with no extra args; false or omit skips pull; any other string is passed as git pull arguments. By default, the action does not pull.
required: false
push:
description: Whether to push the commit and, if any, its tags to the repo. It can also be used to set the git push arguments (more info in the README)
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ export function setOutput<T extends output>(name: T, value: OutputTypes[T]) {
core.setOutput(name, value);
}

/**
* Parses an input that can be a boolean (`true`/`false`) or a git-args string.
* Empty / unset values are returned as an empty string (falsy).
*/
export function parseBoolOrGitArgs(
name: 'fetch' | 'push' | 'pull',
): string | boolean {
try {
return getInput(name, true);
} catch {
return getInput(name) || '';
}
}

/**
* Parses `push_attempts` as a positive integer (≥ 1).
* Accepts only base-10 integer strings (optional leading `+`).
Expand Down Expand Up @@ -279,6 +293,13 @@ export async function checkInputs() {
core.warning(
"`NO-PULL` is a legacy option for the `pull` input. If you don't want the action to pull the repo, simply remove this input.",
);

const pullOption = parseBoolOrGitArgs('pull');
if (getInput('pull')) {
core.debug(
`Current pull option: '${pullOption}' (parsed as ${typeof pullOption})`,
);
}
// #endregion

// #region push
Expand All @@ -299,7 +320,7 @@ export async function checkInputs() {
// #region push_attempts
const pushAttempts = parsePushAttempts(getInput('push_attempts') || '1');
core.debug(`Current push_attempts option: ${pushAttempts}`);
if (pushAttempts > 1 && !getInput('pull')) {
if (pushAttempts > 1 && !pullOption) {
core.warning(
'push_attempts is greater than 1 but pull is not set. Retries will re-run push only; without pull (e.g. --rebase), concurrent remote updates are unlikely to recover.',
);
Expand Down
21 changes: 14 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
checkInputs,
getInput,
logOutputs,
parseBoolOrGitArgs,
parsePushAttempts,
setOutput,
} from './io';
Expand Down Expand Up @@ -159,7 +160,7 @@ core.info(`Running in ${baseDir}`);
});
}

const pullOption = getInput('pull');
const pullOption = parseBoolOrGitArgs('pull');
if (pullOption) {
await pullFromRemote(pullOption, {restage: true, ignoreErrors});
} else core.info('> Not pulling from repo.');
Expand Down Expand Up @@ -311,9 +312,14 @@ async function logDryRunRemainingSteps() {
);
}

const pullOption = getInput('pull');
if (pullOption) core.info(`> Would pull from remote with: ${pullOption}.`);
else core.info('> Would not pull from repo.');
const pullOption = parseBoolOrGitArgs('pull');
if (pullOption) {
core.info(
`> Would pull from remote${
pullOption === true ? '' : ` with: ${pullOption}`
}.`,
);
} else core.info('> Would not pull from repo.');

core.info(
`> Would create commit with message: "${getInput('message')}"${
Expand Down Expand Up @@ -366,17 +372,18 @@ async function logDryRunRemainingSteps() {
}

async function pullFromRemote(
pullOption: string,
pullOption: true | string,
options: {
restage: boolean;
ignoreErrors: 'all' | 'pathspec' | 'none';
},
) {
core.info('> Pulling from remote...');
core.debug(`Current git pull arguments: ${pullOption}`);
const args = pullOption === true ? '' : pullOption;
core.debug(`Current git pull arguments: ${args}`);
await git
.fetch(undefined, log)
.pull(undefined, undefined, parseGitArgs(pullOption), log);
.pull(undefined, undefined, parseGitArgs(args), log);

core.info('> Checking for conflicts...');
const status = await git.status(undefined, log);
Expand Down
85 changes: 85 additions & 0 deletions test/integration/action.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
commitAndPushToRemote,
createFixture,
type Fixture,
gitLog,
Expand Down Expand Up @@ -272,6 +273,90 @@ describe('action integration', () => {
expect(fs.existsSync(path.join(f.local, 'skip-me.txt'))).toBe(true);
});

it('pull: true incorporates remote commits before committing', () => {
const f = fixture!;
commitAndPushToRemote(f, 'from-remote.txt', 'remote\n', 'Remote change');
writeFile(f.local, 'from-local.txt', 'local\n');

const result = runAction(f, {
message: 'Local change',
pull: 'true',
push: 'false',
});

expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(/> Pulling from remote/);
expect(result.stdout).not.toMatch(/Not pulling from repo/);

const files = listFilesAtHead(f.local);
expect(files).toContain('from-remote.txt');
expect(files).toContain('from-local.txt');
});

it.each([
{pull: 'false', label: 'false'},
{pull: undefined, label: 'omitted'},
])('skips pull when pull is $label', ({pull}) => {
const f = fixture!;
commitAndPushToRemote(f, 'from-remote.txt', 'remote\n', 'Remote change');
writeFile(f.local, 'from-local.txt', 'local\n');

const result = runAction(f, {
message: 'Local change only',
...(pull !== undefined ? {pull} : {}),
push: 'false',
});

expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(/Not pulling from repo/);

const files = listFilesAtHead(f.local);
expect(files).toContain('from-local.txt');
expect(files).not.toContain('from-remote.txt');
});

it('pull with custom git args still pulls', () => {
const f = fixture!;
commitAndPushToRemote(f, 'from-remote.txt', 'remote\n', 'Remote change');
writeFile(f.local, 'from-local.txt', 'local\n');

const result = runAction(f, {
message: 'Local change',
pull: '--rebase --autostash',
push: 'false',
});

expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(/> Pulling from remote/);

const files = listFilesAtHead(f.local);
expect(files).toContain('from-remote.txt');
expect(files).toContain('from-local.txt');
});

it('dry_run with pull: true reports a default pull without mutating', () => {
const f = fixture!;
writeFile(f.local, 'dry-pull.txt', 'preview\n');
const before = gitRevParse(f.local, 'HEAD');

const result = runAction(f, {
message: 'Would commit',
dry_run: 'true',
pull: 'true',
push: 'false',
});

expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('false');
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
expect(listFilesAtHead(f.local)).not.toContain('dry-pull.txt');
expect(result.stdout).toMatch(/> Would pull from remote\./);
expect(result.stdout).not.toMatch(/with: true/);
});

it('commits when cwd is an absolute path and process.cwd differs', () => {
const f = fixture!;
writeFile(f.local, 'abs-cwd.txt', 'absolute\n');
Expand Down
29 changes: 29 additions & 0 deletions test/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ function configureFixtureRepo(repo: string, name: string, email: string) {
git(['config', 'user.email', email], repo);
git(['config', 'commit.gpgsign', 'false'], repo);
git(['config', 'tag.gpgsign', 'false'], repo);
// Host pull.* config must not leak into fixtures (rebase without --autostash
// would fail once the action has staged local changes).
git(['config', 'pull.rebase', 'false'], repo);
git(['config', 'pull.ff', 'true'], repo);
}

/**
Expand Down Expand Up @@ -297,6 +301,31 @@ export function writeFile(repo: string, relativePath: string, content: string) {
fs.writeFileSync(full, content);
}

/**
* Clone the bare remote into a sibling worktree, commit a file, and push.
* Returns the new remote HEAD SHA. Does not touch `fixture.local`.
*/
export function commitAndPushToRemote(
fixture: Fixture,
relativePath: string,
content: string,
message: string,
): string {
const parent = path.dirname(fixture.local);
const work = fs.mkdtempSync(path.join(parent, 'remote-work-'));
try {
git(['clone', '-q', fixture.remote, '.'], work);
configureFixtureRepo(work, 'Remote Writer', 'remote@example.com');
writeFile(work, relativePath, content);
git(['add', '--', relativePath], work);
git(['commit', '-q', '-m', message], work);
git(['push', '-q', 'origin', 'HEAD'], work);
return git(['rev-parse', 'HEAD'], work);
} finally {
fs.rmSync(work, {recursive: true, force: true});
}
}

export function removeFile(repo: string, relativePath: string) {
fs.unlinkSync(path.join(repo, relativePath));
}
Expand Down
29 changes: 28 additions & 1 deletion test/io.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,31 @@
import {parsePushAttempts} from '../src/io';
import {parseBoolOrGitArgs, parsePushAttempts} from '../src/io';

describe('parseBoolOrGitArgs', () => {
const original = process.env.INPUT_PULL;

afterEach(() => {
if (original === undefined) delete process.env.INPUT_PULL;
else process.env.INPUT_PULL = original;
});

it('parses true/false as booleans', () => {
process.env.INPUT_PULL = 'true';
expect(parseBoolOrGitArgs('pull')).toBe(true);

process.env.INPUT_PULL = 'false';
expect(parseBoolOrGitArgs('pull')).toBe(false);
});

it('returns git-args strings unchanged', () => {
process.env.INPUT_PULL = '--rebase --autostash';
expect(parseBoolOrGitArgs('pull')).toBe('--rebase --autostash');
});

it('returns an empty string when unset', () => {
delete process.env.INPUT_PULL;
expect(parseBoolOrGitArgs('pull')).toBe('');
});
});

describe('parsePushAttempts', () => {
it('accepts positive integers', () => {
Expand Down