Skip to content

Commit a1e8619

Browse files
authored
feat: Add more inputs and outputs to the commit action to make it more useful (#298)
We've decided to add a few inputs and outputs to the shared `commit` action to enable easier usage of it. The changes are: - added `path` input specifying which files should be staged before the commit - added `pull` input specifying whether to call `git pull` before the commit and with what parameters - added `retries` input specifying how many retries to do in case of errors - renamed `commit-message` input to `message` - added `committed` output saying `true` / `false` whether the commit actually happened, or whether there were no changes - changed `commit-sha` output to `commit_sha`, and it contains the short SHA - added `commit_long_sha` output They mirror the interface of `EndBug/add-and-commit` for an easier migration from that action.
1 parent 254b885 commit a1e8619

4 files changed

Lines changed: 289 additions & 78 deletions

File tree

commit/README.md

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,86 @@
22

33
This action creates a commit from the staged files through the GitHub GraphQL API, so the commit is automatically signed by GitHub. The author of the commit will be the identity associated with the provided token (typically `github-actions[bot]` when using `${{ secrets.GITHUB_TOKEN }}`).
44

5+
By default the action stages everything in the working tree (`git add .`) before committing. Pass the `add` input to scope the staging, or set it to an empty string if you want to control staging yourself before calling the action.
6+
57
## Usage
68

79
```yaml
810
steps:
911
- name: Checkout
1012
uses: actions/checkout@v6
1113

12-
- name: Make changes and stage them
14+
- name: Make changes
1315
run: |
1416
echo "hello" > greeting.txt
15-
git add greeting.txt
1617
1718
- name: Commit through API
18-
uses: apify/workflows/commit@v0.43.0
19+
uses: apify/workflows/commit@v0.45.0
1920
with:
20-
commit-message: "chore: add greeting"
21+
message: "chore: add greeting"
2122
github-token: ${{ secrets.YOUR_GITHUB_TOKEN_WITH_WRITE_PERMISSION }}
23+
add: greeting.txt
2224
```
2325
2426
### Inputs
2527
2628
- `github-token` (required) — Token used to authenticate the GraphQL call. Must have `contents: write` permission on the target repository.
27-
- `commit-message` (required) — The commit message.
29+
- `message` (required) — The commit message.
2830
- `repository` (optional, default `${{ github.repository }}`) — Target repository in `<owner>/<repo>` format.
2931
- `branch` (optional, default `${{ github.head_ref || github.ref_name }}`) — Target branch name. On pull requests this resolves to the PR's source branch (`github.head_ref`); on other events it resolves to `github.ref_name`. Required when `create-branch` is `true`.
3032
- `create-branch` (optional, default `false`) — When `true`, the action pushes `HEAD` to `branch` as a new remote branch before committing. `branch` must be passed explicitly in this case.
33+
- `add` (optional, default `.`) — Paths passed to `git add` before committing. Defaults to `.` (everything). When set to an empty string, `git add` is skipped entirely.
34+
- `pull` (optional, default `''`) — When non-empty, run `git pull <pull>` before staging and committing (e.g. `--rebase --autostash`). The special value `true` runs a plain `git pull` with no arguments. Defaults to an empty string (no pull).
35+
- `retries` (optional, default `0`) — How many times to retry the commit if it fails because the branch was updated by another author in the meantime. Requires `pull` to be non-empty (otherwise the action would just re-attempt against the same stale HEAD).
3136

3237
### Outputs
3338

34-
- `commit-sha` — The SHA of the created commit.
39+
- `commit_sha` — The short (7-character) SHA of the created commit, or the current commit SHA if no new commit was created.
40+
- `commit_long_sha` — The full 40-character SHA of the created commit, or the current commit SHA if no new commit was created.
41+
- `committed` — `'true'` when a commit was created, `'false'` when there were no changes to commit.
3542

3643
### Example: commit to a new branch
3744

3845
```yaml
3946
steps:
4047
- name: Checkout
41-
uses: actions/checkout@v
48+
uses: actions/checkout@v6
4249
43-
- name: Make changes and stage them
44-
run: |
45-
echo "hello" > greeting.txt
46-
git add greeting.txt
50+
- name: Make changes
51+
run: echo "hello" > greeting.txt
4752
4853
- name: Commit to a new branch
49-
uses: apify/workflows/commit@v0.43.0
54+
uses: apify/workflows/commit@v0.45.0
5055
with:
51-
commit-message: "chore: add greeting"
56+
message: "chore: add greeting"
5257
github-token: ${{ secrets.YOUR_GITHUB_TOKEN_WITH_WRITE_PERMISSION }}
5358
branch: chore/add-greeting
5459
create-branch: 'true'
60+
add: greeting.txt
5561
```
62+
63+
### Example: pull before committing
64+
65+
When another job may push to the same branch concurrently, use `pull` and `retries` to fetch the latest changes before committing.
66+
67+
```yaml
68+
steps:
69+
- name: Checkout
70+
uses: actions/checkout@v6
71+
72+
- name: Make changes
73+
run: echo "hello" > greeting.txt
74+
75+
# Someone else would push to the same branch at this point.
76+
77+
- name: Commit through API
78+
uses: apify/workflows/commit@v0.45.0
79+
with:
80+
message: "chore: add greeting"
81+
github-token: ${{ secrets.YOUR_GITHUB_TOKEN_WITH_WRITE_PERMISSION }}
82+
add: greeting.txt
83+
pull: --rebase --autostash
84+
retries: 3
85+
```
86+
87+
This will retry the commit up to 3 times if it fails due to the branch being updated by another author, waiting with exponential backoff between attempts. The `pull` input is required for retries to work, as the action needs to fetch the new HEAD between attempts to avoid retrying against the same stale commit.

commit/action.yml

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ inputs:
44
github-token:
55
description: 'Token to authenticate API calls'
66
required: true
7-
commit-message:
7+
message:
88
required: true
99
description: 'The commit message'
1010
repository:
@@ -19,11 +19,29 @@ inputs:
1919
description: 'Create branch if it does not exist. When `true`, `branch` must be passed explicitly.'
2020
default: 'false'
2121
required: false
22+
add:
23+
description: 'Paths to stage before committing, passed to `git add`. Defaults to `.` (everything). When set to an empty string, `git add` is not called.'
24+
default: '.'
25+
required: false
26+
pull:
27+
description: 'When non-empty, run `git pull <pull>` before staging and committing (e.g. `--rebase --autostash`). The special value `true` runs a plain `git pull` with no arguments. Defaults to an empty string (no pull).'
28+
default: ''
29+
required: false
30+
retries:
31+
description: 'How many times to retry the commit if it fails because the branch was updated by another author in the meantime. Requires `pull` to be non-empty (so the action can fetch the new HEAD between attempts). Defaults to `0` (no retries).'
32+
default: '0'
33+
required: false
2234

2335
outputs:
24-
commit-sha:
25-
description: 'The SHA of the created commit.'
26-
value: ${{ steps.commit.outputs.commit-sha }}
36+
commit_sha:
37+
description: 'The short (7-character) SHA of the created commit. Falls back to the current HEAD SHA when no commit was made.'
38+
value: ${{ steps.commit.outputs.commit_sha }}
39+
commit_long_sha:
40+
description: 'The full 40-character SHA of the created commit. Falls back to the current HEAD SHA when no commit was made.'
41+
value: ${{ steps.commit.outputs.commit_long_sha }}
42+
committed:
43+
description: '`true` when a commit was created, `false` when there were no staged changes to commit.'
44+
value: ${{ steps.commit.outputs.committed }}
2745

2846
runs:
2947
using: composite
@@ -49,19 +67,32 @@ runs:
4967
run: |
5068
git checkout "${{ inputs.branch }}"
5169
52-
- name: Get HEAD SHA
53-
id: get-head-sha
70+
- name: Pull latest changes
71+
if: ${{ inputs.pull != '' }}
5472
shell: bash
55-
run: echo "head_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
73+
run: |
74+
if [[ "${{ inputs.pull }}" == "true" ]]; then
75+
git pull
76+
else
77+
git pull ${{ inputs.pull }}
78+
fi
79+
80+
- name: Stage files
81+
if: ${{ inputs.add != '' }}
82+
shell: bash
83+
run: |
84+
git add ${{ inputs.add }}
5685
5786
- name: Commit through API
5887
id: commit
5988
uses: actions/github-script@v8
6089
env:
61-
COMMIT_MESSAGE: ${{ inputs.commit-message }}
90+
COMMIT_MESSAGE: ${{ inputs.message }}
6291
REPO: ${{ inputs.repository }}
63-
EXPECTED_HEAD_OID: ${{ steps.get-head-sha.outputs.head_ref }}
6492
BRANCH: ${{ inputs.branch }}
93+
ADD: ${{ inputs.add }}
94+
PULL: ${{ inputs.pull }}
95+
RETRIES: ${{ inputs.retries }}
6596
with:
6697
github-token: ${{ inputs.github-token }}
6798
script: |

commit/index.mts

Lines changed: 124 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,44 @@ export function checkSupportedFileModes(status: GitFileStatus) {
4949
}
5050
}
5151

52-
export async function main({ github, env, core }: { github: Octokit, env: Record<string, string>, core: typeof Core }) {
53-
const {
54-
COMMIT_MESSAGE,
55-
REPO,
56-
EXPECTED_HEAD_OID,
57-
BRANCH,
58-
} = env;
52+
/**
53+
* Produces the list of staged files for committing.
54+
*/
55+
export async function status(options: Omit<childProcess.ExecOptions, 'encoding'> = {}): Promise<GitFileStatus[]> {
56+
const cmd = [
57+
'git',
58+
'diff-index',
59+
'--cached', // only from the staging area (added files)
60+
'--no-renames', // do not track file renames (copy/move) - we only need added / removed
61+
'HEAD',
62+
];
63+
64+
const stagedFileStatuses = (await exec(cmd.join(' '), { encoding: 'utf8', ...options })).stdout.trim();
5965

66+
// see: man git-diff-index(1) - section RAW OUTPUT FORMAT
67+
return stagedFileStatuses.split('\n')
68+
.map((line) => {
69+
const tabSplit = line.split('\t');
70+
if (tabSplit.length !== 2) {
71+
return null;
72+
}
73+
74+
const [info, filePath] = tabSplit;
75+
const [modeBefore, modeAfter, shaBefore, shaAfter, fileStatus] = info.split(' ');
76+
77+
return {
78+
modeBefore: parseInt(modeBefore.slice(1), 8),
79+
modeAfter: parseInt(modeAfter, 8),
80+
shaBefore,
81+
shaAfter,
82+
fileStatus,
83+
filePath,
84+
};
85+
})
86+
.filter((it) => !!it);
87+
}
88+
89+
async function collectFileChanges(): Promise<FileChanges> {
6090
const fileChanges: FileChanges = { additions: [], deletions: [] };
6191
const stagedFiles = await status();
6292

@@ -83,17 +113,22 @@ export async function main({ github, env, core }: { github: Octokit, env: Record
83113
}
84114
}
85115

86-
// Only log file paths not the base64 encoded file contents.
87-
const changedPaths = {
88-
additions: fileChanges.additions.map(({ path }) => path),
89-
deletions: fileChanges.deletions.map(({ path }) => path),
90-
};
91-
core.info(`committing file changes: "${JSON.stringify(changedPaths, null, 4)}"`);
116+
return fileChanges;
117+
}
118+
119+
const RETRY_BASE_DELAY_MS = 1000;
92120

93-
const commitMessageLines = COMMIT_MESSAGE.split('\n');
94-
const messageTitle = commitMessageLines[0];
95-
const messageBody = commitMessageLines.slice(1).join('\n').trim();
121+
type CreateCommitArgs = {
122+
github: Octokit;
123+
repo: string;
124+
branch: string;
125+
expectedHeadOid: string;
126+
messageTitle: string;
127+
messageBody: string;
128+
fileChanges: FileChanges;
129+
};
96130

131+
async function createCommit({ github, repo, branch, expectedHeadOid, messageTitle, messageBody, fileChanges }: CreateCommitArgs): Promise<string> {
97132
const response = await github.graphql(`\
98133
mutation Commit($input: CreateCommitOnBranchInput!) {
99134
createCommitOnBranch(input: $input) {
@@ -106,56 +141,91 @@ export async function main({ github, env, core }: { github: Octokit, env: Record
106141
input: {
107142
fileChanges,
108143
branch: {
109-
branchName: BRANCH,
110-
repositoryNameWithOwner: REPO,
144+
branchName: branch,
145+
repositoryNameWithOwner: repo,
111146
},
112-
expectedHeadOid: EXPECTED_HEAD_OID,
147+
expectedHeadOid,
113148
message: {
114149
headline: messageTitle,
115150
body: messageBody,
116151
},
117152
},
118153
}) as any;
119154

120-
const commitSha = response.createCommitOnBranch.commit.oid;
121-
core.info(`successfully pushed commit "${commitSha}"`);
122-
123-
core.setOutput('commit-sha', commitSha);
155+
return response.createCommitOnBranch.commit.oid;
124156
}
125157

126-
/**
127-
* Produces the list of staged files for committing.
128-
*/
129-
export async function status(options: Omit<childProcess.ExecOptions, 'encoding'> = {}): Promise<GitFileStatus[]> {
130-
const cmd = [
131-
'git',
132-
'diff-index',
133-
'--cached', // only from the staging area (added files)
134-
'--no-renames', // do not track file renames (copy/move) - we only need added / removed
135-
'HEAD',
136-
];
158+
export async function main({ github, env, core }: { github: Octokit, env: Record<string, string>, core: typeof Core }) {
159+
const {
160+
COMMIT_MESSAGE,
161+
REPO,
162+
BRANCH,
163+
PULL = '',
164+
RETRIES = '0',
165+
} = env;
137166

138-
const stagedFileStatuses = (await exec(cmd.join(' '), { encoding: 'utf8', ...options })).stdout.trim();
167+
const retries = parseInt(RETRIES, 10);
168+
if (Number.isNaN(retries) || retries < 0) {
169+
throw new Error(`'retries' must be a non-negative integer, got "${RETRIES}"`);
170+
}
171+
if (retries > 0 && PULL === '') {
172+
throw new Error(`'retries' is set to ${retries} but 'pull' is empty — retrying requires 'pull' to be set so the action can fetch the new HEAD between attempts.`);
173+
}
139174

140-
// see: man git-diff-index(1) - section RAW OUTPUT FORMAT
141-
return stagedFileStatuses.split('\n')
142-
.map((line) => {
143-
const tabSplit = line.split('\t');
144-
if (tabSplit.length !== 2) {
145-
return null;
146-
}
175+
const commitMessageLines = COMMIT_MESSAGE.split('\n');
176+
const messageTitle = commitMessageLines[0];
177+
const messageBody = commitMessageLines.slice(1).join('\n').trim();
147178

148-
const [info, filePath] = tabSplit;
149-
const [modeBefore, modeAfter, shaBefore, shaAfter, fileStatus] = info.split(' ');
179+
const maxAttempts = retries + 1;
180+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
181+
if (PULL !== '') {
182+
const pullCmd = PULL === 'true' ? 'git pull' : `git pull ${PULL}`;
183+
core.info(`Executing "${pullCmd}" before committing (attempt ${attempt}/${maxAttempts})`);
184+
await exec(pullCmd, { encoding: 'utf8' });
185+
}
150186

151-
return {
152-
modeBefore: parseInt(modeBefore.slice(1), 8),
153-
modeAfter: parseInt(modeAfter, 8),
154-
shaBefore,
155-
shaAfter,
156-
fileStatus,
157-
filePath,
158-
};
159-
})
160-
.filter((it) => !!it);
187+
const expectedHeadOid = (await exec('git rev-parse HEAD', { encoding: 'utf8' })).stdout.trim();
188+
const fileChanges = await collectFileChanges();
189+
190+
// Only log file paths not the base64 encoded file contents.
191+
const changedPaths = {
192+
additions: fileChanges.additions.map(({ path }) => path),
193+
deletions: fileChanges.deletions.map(({ path }) => path),
194+
};
195+
core.info(`committing file changes: "${JSON.stringify(changedPaths, null, 4)}"`);
196+
197+
if (fileChanges.additions.length === 0 && fileChanges.deletions.length === 0) {
198+
core.info('no staged changes — skipping commit');
199+
core.setOutput('committed', 'false');
200+
core.setOutput('commit_sha', expectedHeadOid.slice(0, 7));
201+
core.setOutput('commit_long_sha', expectedHeadOid);
202+
return;
203+
}
204+
205+
try {
206+
const commitSha = await createCommit({
207+
github,
208+
repo: REPO,
209+
branch: BRANCH,
210+
expectedHeadOid,
211+
messageTitle,
212+
messageBody,
213+
fileChanges,
214+
});
215+
core.info(`successfully pushed commit "${commitSha}"`);
216+
217+
core.setOutput('commit_sha', commitSha.slice(0, 7));
218+
core.setOutput('commit_long_sha', commitSha);
219+
core.setOutput('committed', 'true');
220+
return;
221+
} catch (err) {
222+
if (attempt < maxAttempts) {
223+
const delayMs = RETRY_BASE_DELAY_MS * (2 ** (attempt - 1));
224+
core.warning(`commit attempt ${attempt}/${maxAttempts} failed: ${err instanceof Error ? err.message : String(err)} — retrying in ${delayMs}ms`);
225+
await new Promise((resolve) => { setTimeout(resolve, delayMs); });
226+
continue;
227+
}
228+
throw err;
229+
}
230+
}
161231
}

0 commit comments

Comments
 (0)