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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ Add a step like this to your workflow:
# Default: true
push: false

# Max times to try pushing. If pull is set, the action re-pulls between failed attempts.
# Default: 1
push_attempts: 3

# The arguments for the `git rm` command (see the paragraph below for more info)
# Default: ''
remove: './dir/old_file.js'
Expand Down Expand Up @@ -131,6 +135,14 @@ 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:

```yaml
with:
pull: '--rebase --autostash'
push_attempts: 3
```

### Creating a new branch

If you want the action to commit in a new branch, you can use the `new_branch` input. This must be a valid git branch name (not raw git arguments): it cannot be empty, start with `-`, contain whitespace/control characters, or fail `git check-ref-format --branch` (for example `feature..name`, `name@{x}`, `name~1`, or a name ending with `.`).
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ inputs:
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)
required: false
default: 'true'
push_attempts:
description: Max times to try pushing. If pull is set, re-pulls between failed attempts (more info in the README)
required: false
default: '1'
remove:
description: Arguments for the git rm command
required: false
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface InputTypes {
pathspec_error_handling: 'ignore' | 'exitImmediately' | 'exitAtEnd';
pull: string | undefined;
push: string;
push_attempts: string;
remove: string | undefined;
tag: string | undefined;
tag_push: string | undefined;
Expand Down Expand Up @@ -64,6 +65,26 @@ export function setOutput<T extends output>(name: T, value: OutputTypes[T]) {
core.setOutput(name, value);
}

/**
* Parses `push_attempts` as a positive integer (≥ 1).
* Accepts only base-10 integer strings (optional leading `+`).
*/
export function parsePushAttempts(value: string): number {
const trimmed = value.trim();
if (!/^\+?\d+$/.test(trimmed)) {
throw new Error(
`'${value}' is not a valid value for push_attempts. It must be a positive integer (≥ 1).`,
);
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error(
`'${value}' is not a valid value for push_attempts. It must be a positive integer (≥ 1).`,
);
}
return parsed;
}

export function logOutputs() {
core.startGroup('Outputs');
for (const key in outputs) {
Expand Down Expand Up @@ -259,6 +280,16 @@ export async function checkInputs() {
}
// #endregion

// #region push_attempts
const pushAttempts = parsePushAttempts(getInput('push_attempts') || '1');
core.debug(`Current push_attempts option: ${pushAttempts}`);
if (pushAttempts > 1 && !getInput('pull')) {
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.',
);
}
// #endregion

// #region github_token
if (!getInput('github_token'))
core.warning(
Expand Down
164 changes: 107 additions & 57 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import * as core from '@actions/core';
import * as path from 'path';
import simpleGit, {Response} from 'simple-git';
import {checkInputs, getInput, logOutputs, setOutput} from './io';
import {
checkInputs,
getInput,
logOutputs,
parsePushAttempts,
setOutput,
} from './io';
import {
assertNoUnexpectedGitlinks,
findUnexpectedGitlinks,
Expand Down Expand Up @@ -104,28 +110,7 @@ core.info(`Running in ${baseDir}`);

const pullOption = getInput('pull');
if (pullOption) {
core.info('> Pulling from remote...');
core.debug(`Current git pull arguments: ${pullOption}`);
await git
.fetch(undefined, log)
.pull(undefined, undefined, matchGitArgs(pullOption), log);

core.info('> Checking for conflicts...');
const status = await git.status(undefined, log);

if (!status.conflicted.length) {
core.info('> No conflicts found.');
core.info('> Re-staging files...');
if (getInput('add')) await add(ignoreErrors);
if (getInput('remove')) await remove(ignoreErrors);
} else
throw new Error(
`There are ${
status.conflicted.length
} conflicting files: ${status.conflicted
.map(neutralizeLogString)
.join(', ')}`,
);
await pullFromRemote(pullOption, {restage: true, ignoreErrors});
} else core.info('> Not pulling from repo.');

core.info('> Creating commit...');
Expand Down Expand Up @@ -172,43 +157,36 @@ core.info(`Running in ${baseDir}`);
pushOption = getInput('push');
}
if (pushOption) {
// If the options is `true | string`...
core.info('> Pushing commit to repo...');

if (pushOption === true) {
const branch = getInput('new_branch');
if (branch) {
core.debug(`Running: git push --set-upstream origin -- ${branch}`);
await git.raw(
['push', '--set-upstream', 'origin', '--', branch],
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
},
const pushAttempts = parsePushAttempts(getInput('push_attempts') || '1');

for (let attempt = 1; attempt <= pushAttempts; attempt++) {
try {
core.info(
pushAttempts > 1
? `> Pushing commit to repo (attempt ${attempt}/${pushAttempts})...`
: '> Pushing commit to repo...',
);
} else {
core.debug('Running: git push origin --set-upstream');
await git.push(
'origin',
undefined,
{'--set-upstream': null},
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
},
await pushCommit(pushOption);
break;
} catch (err) {
if (attempt === pushAttempts) throw err;

const message = err instanceof Error ? err.message : String(err);
core.warning(
`Push failed (attempt ${attempt}/${pushAttempts}): ${neutralizeLogString(message)}`,
);

if (pullOption) {
await pullFromRemote(pullOption, {
restage: false,
ignoreErrors,
});
// Rebase rewrites the commit; refresh outputs from HEAD.
const head = (await git.revparse(['HEAD'])).trim();
setOutput('commit_long_sha', head);
setOutput('commit_sha', head.substring(0, 7));
}
}
} else {
core.debug(`Running: git push ${pushOption}`);
await git.push(
undefined,
undefined,
matchGitArgs(pushOption),
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
},
);
}

if (getInput('tag')) {
Expand Down Expand Up @@ -246,6 +224,78 @@ core.info(`Running in ${baseDir}`);
core.setFailed(e);
});

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

core.info('> Checking for conflicts...');
const status = await git.status(undefined, log);

if (status.conflicted.length) {
throw new Error(
`There are ${
status.conflicted.length
} conflicting files: ${status.conflicted
.map(neutralizeLogString)
.join(', ')}`,
);
}

core.info('> No conflicts found.');
if (options.restage) {
core.info('> Re-staging files...');
if (getInput('add')) await add(options.ignoreErrors);
if (getInput('remove')) await remove(options.ignoreErrors);
}
}

async function pushCommit(pushOption: true | string) {
if (pushOption === true) {
const branch = getInput('new_branch');
if (branch) {
core.debug(`Running: git push --set-upstream origin -- ${branch}`);
await git.raw(
['push', '--set-upstream', 'origin', '--', branch],
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
},
);
} else {
core.debug('Running: git push origin --set-upstream');
await git.push(
'origin',
undefined,
{'--set-upstream': null},
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
},
);
}
} else {
core.debug(`Running: git push ${pushOption}`);
await git.push(
undefined,
undefined,
matchGitArgs(pushOption),
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
},
);
}
}

async function add(ignoreErrors: 'all' | 'pathspec' | 'none' = 'none') {
const input = getInput('add');
if (!input) return [];
Expand Down
19 changes: 19 additions & 0 deletions test/io.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {parsePushAttempts} from '../src/io';

describe('parsePushAttempts', () => {
it('accepts positive integers', () => {
expect(parsePushAttempts('1')).toBe(1);
expect(parsePushAttempts('3')).toBe(3);
expect(parsePushAttempts(' 10 ')).toBe(10);
expect(parsePushAttempts('+2')).toBe(2);
});

it('rejects non-integers and values below 1', () => {
expect(() => parsePushAttempts('0')).toThrow(/positive integer/);
expect(() => parsePushAttempts('-1')).toThrow(/positive integer/);
expect(() => parsePushAttempts('1.5')).toThrow(/positive integer/);
expect(() => parsePushAttempts('true')).toThrow(/positive integer/);
expect(() => parsePushAttempts('')).toThrow(/positive integer/);
expect(() => parsePushAttempts('1e2')).toThrow(/positive integer/);
});
});