Skip to content
Open
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
43 changes: 35 additions & 8 deletions commit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@

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 }}`).

By default the action stages everything in the working tree (`git add .`) before committing. Pass the `path` input to scope the staging, or set it to a pathspec that matches nothing if you want to control staging yourself before calling the action.

## Usage

```yaml
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Make changes and stage them
- name: Make changes
run: |
echo "hello" > greeting.txt
git add greeting.txt

- name: Commit through API
uses: apify/workflows/commit@v0.43.0
with:
commit-message: "chore: add greeting"
github-token: ${{ secrets.YOUR_GITHUB_TOKEN_WITH_WRITE_PERMISSION }}
path: greeting.txt
```

### Inputs
Expand All @@ -28,10 +30,13 @@ steps:
- `repository` (optional, default `${{ github.repository }}`) — Target repository in `<owner>/<repo>` format.
- `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`.
- `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.
- `path` (optional, default `.`) — Paths passed to `git add` before committing. Defaults to `.` (everything).
- `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).

### Outputs

- `commit-sha` — The SHA of the created commit.
- `commit-sha` — The SHA of the created commit, or the current commit SHA if no new commit was created.
- `committed` — `'true'` when a commit was created, `'false'` when there were no changes to commit.

### Example: commit to a new branch

Expand All @@ -40,16 +45,38 @@ steps:
- name: Checkout
uses: actions/checkout@v

- name: Make changes and stage them
run: |
echo "hello" > greeting.txt
git add greeting.txt
- name: Make changes
run: echo "hello" > greeting.txt

- name: Commit to a new branch
uses: apify/workflows/commit@v0.43.0
uses: apify/workflows/commit@v0.45.0
with:
commit-message: "chore: add greeting"
github-token: ${{ secrets.YOUR_GITHUB_TOKEN_WITH_WRITE_PERMISSION }}
branch: chore/add-greeting
create-branch: 'true'
path: greeting.txt
```

### Example: pull before committing

When another job may push to the same branch concurrently, use `pull` to fetch the latest changes before committing.

```yaml
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Make changes
run: echo "hello" > greeting.txt

# Someone else would push to the same branch at this point.

- name: Commit through API
uses: apify/workflows/commit@v0.45.0
with:
commit-message: "chore: add greeting"
github-token: ${{ secrets.YOUR_GITHUB_TOKEN_WITH_WRITE_PERMISSION }}
path: greeting.txt
pull: --rebase --autostash
```
28 changes: 27 additions & 1 deletion commit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@ inputs:
description: 'Create branch if it does not exist. When `true`, `branch` must be passed explicitly.'
default: 'false'
required: false
path:
description: 'Paths to stage before committing, passed to `git add`. Defaults to `.` (everything).'
default: '.'
required: false
pull:
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).'
default: ''
required: false

outputs:
commit-sha:
description: 'The SHA of the created commit.'
description: 'The SHA of the created commit. Empty when `committed` is `false`.'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it would be an easy minor win over EndBug/add-and-commit if this returned the current HEAD in case committed === false.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, done.

value: ${{ steps.commit.outputs.commit-sha }}
committed:
description: '`true` when a commit was created, `false` when there were no staged changes to commit.'
value: ${{ steps.commit.outputs.committed }}

runs:
using: composite
Expand All @@ -49,6 +60,21 @@ runs:
run: |
git checkout "${{ inputs.branch }}"

- name: Pull latest changes
if: ${{ inputs.pull != '' }}
shell: bash
run: |
if [[ "${{ inputs.pull }}" == "true" ]]; then
git pull
else
git pull ${{ inputs.pull }}
fi

- name: Stage files
shell: bash
run: |
git add ${{ inputs.path }}

- name: Get HEAD SHA
id: get-head-sha
shell: bash
Expand Down
9 changes: 9 additions & 0 deletions commit/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ export async function main({ github, env, core }: { github: Octokit, env: Record
};
core.info(`committing file changes: "${JSON.stringify(changedPaths, null, 4)}"`);

if (fileChanges.additions.length === 0 && fileChanges.deletions.length === 0) {
core.info('no staged changes — skipping commit');
const currentHeadSha = (await exec('git rev-parse HEAD', { encoding: 'utf8' })).stdout.trim();
core.setOutput('committed', 'false');
core.setOutput('commit-sha', currentHeadSha);
return;
}

const commitMessageLines = COMMIT_MESSAGE.split('\n');
const messageTitle = commitMessageLines[0];
const messageBody = commitMessageLines.slice(1).join('\n').trim();
Expand Down Expand Up @@ -121,6 +129,7 @@ export async function main({ github, env, core }: { github: Octokit, env: Record
core.info(`successfully pushed commit "${commitSha}"`);

core.setOutput('commit-sha', commitSha);
core.setOutput('committed', 'true');
}

/**
Expand Down
37 changes: 35 additions & 2 deletions commit/index.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as childProcess from 'node:child_process';

import { describe, afterEach, beforeEach, it, expect } from 'vitest';
import { describe, afterEach, beforeEach, it, expect, vi } from 'vitest';

import { status, FILE_STATUS, checkSupportedFileModes } from './index.mts';
import { status, FILE_STATUS, checkSupportedFileModes, main } from './index.mts';

const exec = util.promisify(childProcess.exec);

Expand Down Expand Up @@ -119,6 +119,39 @@ describe('signed commit action', () => {
expect(() => statuses.forEach(checkSupportedFileModes)).toThrow();
});

it('skips the commit and sets committed=false when nothing is staged', async () => {
const outputs: Record<string, string> = {};
const fakeCore = {
info: () => {},
setOutput: (name: string, value: string) => { outputs[name] = value; },
};
const graphql = vi.fn();
const fakeGithub = { graphql };

const headSha = (await doExec('git rev-parse HEAD')).stdout.trim();

const originalCwd = process.cwd();
try {
process.chdir(repoDir);
await main({
github: fakeGithub as any,
core: fakeCore as any,
env: {
COMMIT_MESSAGE: 'chore: nothing',
REPO: 'apify/workflows',
EXPECTED_HEAD_OID: 'abcd1234',
BRANCH: 'main',
},
});
} finally {
process.chdir(originalCwd);
}

expect(graphql).not.toHaveBeenCalled();
expect(outputs.committed).toEqual('false');
expect(outputs['commit-sha']).toEqual(headSha);
});

it('checks file modes and does not throw when correct', async () => {
const validModes = [
0o666,
Expand Down
Loading