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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
.env.development.local
.env.test.local
.env.production.local
.envrc

# Testing
coverage
Expand Down
59 changes: 52 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![View on NPM](https://badgen.net/npm/v/@changesets/ghcommit)](https://www.npmjs.com/package/@changesets/ghcommit)

NPM / TypeScript package to commit changes GitHub repositories using the GraphQL API.
NPM / TypeScript package to commit changes to GitHub repositories using the GitHub API.

## Why?

Expand Down Expand Up @@ -37,14 +37,22 @@ pnpm install @changesets/ghcommit

### Usage in github actions

All functions in this library that interact with the GitHub API require an octokit client that can execute GraphQL. If you are writing code that is designed to be run from within a GitHub Action, this can be done using the `@actions.github` library:
All functions in this library that interact with the GitHub API require an octokit client that can execute both GraphQL queries and REST API requests. If you are writing code that is designed to be run from within a GitHub Action, this can be done using the `@actions/github` library:

```ts
import { getOctokit } from "@actions/github";

const octokit = getOctokit(process.env.GITHUB_TOKEN);
```

Alternatively, you can use `@octokit/core` directly:

```ts
import { Octokit } from "@octokit/core";

const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
```

### Importing specific modules

To allow for you to produce smaller bundle sizes, the functionality exposed in this package is grouped into specific modules that only import the packages required for their use. We recommend that you import from the specific modules rather than the root of the package.
Expand Down Expand Up @@ -253,6 +261,11 @@ In addition to `CommitFilesBasedArgs`, this function has the following arguments
additions?: Array<{
path: string;
contents: Buffer;
/**
* Optional file mode. Defaults to '100644' (normal file).
* Can be any valid git file mode string (e.g., '100755' for executable).
*/
mode?: string;
}>;
deletions?: string[];
};
Expand All @@ -264,6 +277,7 @@ Example:
```ts
import { context, getOctokit } from "@actions/github";
import { commitFilesFromBuffers } from "@changesets/ghcommit/node";
import { FileModes } from "@changesets/ghcommit";

const octokit = getOctokit(process.env.GITHUB_TOKEN);

Expand All @@ -282,6 +296,11 @@ await commitFilesFromBuffers({
path: "hello/world.txt",
contents: Buffer.alloc(1024, "Hello, world!"),
},
{
path: "scripts/run.sh",
contents: Buffer.from("#!/bin/bash\necho 'Hello!'"),
mode: FileModes.executableFile, // '100755'
},
],
},
});
Expand All @@ -290,12 +309,38 @@ await commitFilesFromBuffers({
## Known Limitations

Due to using the GitHub API to make changes to repository contents,
there are some things it's not possible to commit,
and where using the Git CLI is still required.
there are some things that may not work as expected:

- Submodule changes (gitlinks with mode `160000`) - while the mode is supported, submodule-specific behavior may not work correctly

### File Mode Support

- Executable files
- Symbolic Links
- Submodule changes
This library supports any git file mode. Common modes include:

- `100644` - Normal file (default)
- `100755` - Executable file
- `120000` - Symbolic link
- `040000` - Directory (subdirectory)
- `160000` - Submodule (gitlink)

**Automatic detection:**

- When using `commitChangesFromRepo`, file modes are automatically detected from the git working directory.
- When using `commitFilesFromDirectory`, file modes are automatically detected from the filesystem (based on execute bits).
- When using `commitFilesFromBuffers`, you can explicitly specify any file mode string.

```ts
import { FileModes } from "@changesets/ghcommit";

// Convenience constants:
FileModes.file; // '100644' - Normal file (default)
FileModes.executableFile; // '100755' - Executable file
FileModes.symlink; // '120000' - Symbolic link

// Or use any git mode string directly:
{ path: "script.sh", contents: buffer, mode: "100755" }
{ path: "custom", contents: buffer, mode: "100644" }
```

## Other Tools / Alternatives

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"access": "public"
},
"dependencies": {
"isomorphic-git": "^1.27.1"
"isomorphic-git": "^1.27.1",
"queue": "^6.0.0"
}
}
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

191 changes: 173 additions & 18 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import {
createCommitOnBranchQuery,
createRefMutation,
getRepositoryMetadata,
updateRefMutation,
} from "./github/graphql/queries.js";
import type {
CreateCommitOnBranchMutationVariables,
GetRepositoryMetadataQuery,
} from "./github/graphql/generated/operations.js";
import type { GetRepositoryMetadataQuery } from "./github/graphql/generated/operations.js";
import {
CommitFilesFromBase64Args,
CommitFilesResult,
GitBase,
FileModes,
} from "./interface.js";
import { CommitMessage } from "./github/graphql/generated/types.js";
import { isUtf8 } from "buffer";
import Queue from "queue";

// Types for GitHub Git Data API responses
interface GitCommitResponse {
sha: string;
tree: {
sha: string;
};
}

interface GitBlobResponse {
sha: string;
}

interface GitTreeResponse {
sha: string;
}

interface GitNewCommitResponse {
sha: string;
}

const getBaseRef = (base: GitBase): string => {
if ("branch" in base) {
Expand Down Expand Up @@ -164,21 +183,157 @@ export const commitFilesFromBase64 = async ({
}
: message;

await log?.debug(`Creating commit on branch ${branch}`);
const createCommitMutation: CreateCommitOnBranchMutationVariables = {
input: {
branch: {
id: refId,
},
expectedHeadOid: baseOid,
message: finalMessage,
fileChanges,
const commitMessageStr = finalMessage.body
? `${finalMessage.headline}\n\n${finalMessage.body}`
: finalMessage.headline;

await log?.debug(`Creating commit on branch ${branch} using Git Data API`);

// Check if the octokit instance supports REST API calls
if (!octokit.request) {
throw new Error(
"The provided octokit instance does not support REST API calls (missing request method). " +
"Please provide an Octokit instance that supports both GraphQL and REST API, such as @octokit/core.",
);
}

// Step 1: Get the base tree from the parent commit
log?.debug(`Getting base commit ${baseOid}`);
const baseCommit = await octokit.request<GitCommitResponse>(
"GET /repos/{owner}/{repo}/git/commits/{commit_sha}",
{
owner,
repo,
commit_sha: baseOid,
},
};
log?.debug(JSON.stringify(createCommitMutation, null, 2));
);
const baseTreeSha = baseCommit.data.tree.sha;
log?.debug(`Base tree SHA: ${baseTreeSha}`);

// Step 2: Create blobs for each file addition
const treeItems: Array<
{
path: string;
mode: string;
type: "blob" | "tree" | "commit";
} & (
| {
sha: string | null;
}
| {
content: string;
}
)
> = [];

// Add file additions
if (fileChanges.additions) {
// Use a queue as we might have to upload a bunch of blobs concurrently
const additionsProcessor = new Queue({
concurrency: 5,
});
additionsProcessor.push(
...fileChanges.additions.map((addition) => {
return async () => {
if (isUtf8(Buffer.from(addition.contents, "base64"))) {
log?.debug(`Using utf8 content directly for ${addition.path}`);

treeItems.push({
path: addition.path,
mode: addition.mode || FileModes.file,
type: "blob",
content: Buffer.from(addition.contents, "base64").toString(
"utf-8",
),
});
} else {
log?.debug(`Creating blob for non-utf8 file at ${addition.path}`);
const blobResponse = await octokit.request!<GitBlobResponse>(
"POST /repos/{owner}/{repo}/git/blobs",
{
owner,
repo,
content: addition.contents,
encoding: "base64",
},
);

const mode = addition.mode || FileModes.file;
log?.debug(
`Created blob ${blobResponse.data.sha} for ${addition.path} with mode ${mode}`,
);

treeItems.push({
path: addition.path,
mode: mode,
type: "blob",
sha: blobResponse.data.sha,
});
}
};
}),
);
await new Promise<void>((resolve, reject) => additionsProcessor.start((err) => {
if (err) {
reject(err)
} else {
resolve();
}
}));
}

// Add file deletions (set sha to null)
if (fileChanges.deletions) {
for (const deletion of fileChanges.deletions) {
log?.debug(`Marking ${deletion.path} for deletion`);
treeItems.push({
path: deletion.path,
mode: "100644",
type: "blob",
sha: null,
});
}
}

// Step 3: Create new tree with the changes
log?.debug(`Creating tree with ${treeItems.length} items`);
const newTree = await octokit.request<GitTreeResponse>(
"POST /repos/{owner}/{repo}/git/trees",
{
owner,
repo,
base_tree: baseTreeSha,
tree: treeItems,
},
);
log?.debug(`Created tree ${newTree.data.sha}`);

// Step 4: Create the commit
log?.debug(`Creating commit with message: ${finalMessage.headline}`);
const newCommit = await octokit.request<GitNewCommitResponse>(
"POST /repos/{owner}/{repo}/git/commits",
{
owner,
repo,
message: commitMessageStr,
tree: newTree.data.sha,
parents: [baseOid],
},
);
log?.debug(`Created commit ${newCommit.data.sha}`);

// Step 5: Update the branch ref to point to the new commit
log?.debug(`Updating ref ${targetRef} to ${newCommit.data.sha}`);
await octokit.request<void>("PATCH /repos/{owner}/{repo}/git/refs/{ref}", {
owner,
repo,
ref: `heads/${branch}`,
sha: newCommit.data.sha,
force: false,
});
log?.debug(`Updated ref successfully`);

const result = await createCommitOnBranchQuery(octokit, createCommitMutation);
return {
refId: result.createCommitOnBranch?.ref?.id ?? null,
refId: refId,
};
};
Loading