Skip to content

Commit 207dc3d

Browse files
s0Andarist
andauthored
Introduce an option to use the GitHub API to commit changes, for GPG (#391)
* Create tags on GitHub using API To allow for signed tags to be created, rather than use the git CLI to push tags, manually push each tag using the GitHub API, which will sign the tag using the built-in GitHub GPG key. * Use ghcommit to push changes To allow for all commits to be signed, use the GitHub API to push changes. * Add changeset version * Allow tag publish to fail, assume it was manually published * Add to changeset * Update @s0/ghcommit * Update ghcommit to fix missing ref bug * Make using GitHub API Optional Change this to a minor version bump, with a new feature that allows for using the GitHub API to create tags and commits. * Use a strategy pattern for GitHub API vs CLI usage * refactor git interactions * fix mock thing * usechangesets pkg * switch to `commitMode` --------- Co-authored-by: Mateusz Burzyński <[email protected]>
1 parent bd5d1d2 commit 207dc3d

File tree

12 files changed

+381
-180
lines changed

12 files changed

+381
-180
lines changed

.changeset/green-dogs-change.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@changesets/action": minor
3+
---
4+
5+
Introduce a new input `commitMode` that allows using the GitHub API for pushing tags and commits instead of the Git CLI.
6+
7+
When used with `"github-api"` value all tags and commits will be attributed to the user whose GITHUB_TOKEN is used, and also signed using GitHub's internal GPG key.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changesets Release Action
22

3-
This action for [Changesets](https://github.com/atlassian/changesets) creates a pull request with all of the package versions updated and changelogs updated and when there are new changesets on [your configured `baseBranch`](https://github.com/changesets/changesets/blob/main/docs/config-file-options.md#basebranch-git-branch-name), the PR will be updated. When you're ready, you can merge the pull request and you can either publish the packages to npm manually or setup the action to do it for you.
3+
This action for [Changesets](https://github.com/changesets/changesets) creates a pull request with all of the package versions updated and changelogs updated and when there are new changesets on [your configured `baseBranch`](https://github.com/changesets/changesets/blob/main/docs/config-file-options.md#basebranch-git-branch-name), the PR will be updated. When you're ready, you can merge the pull request and you can either publish the packages to npm manually or setup the action to do it for you.
44

55
## Usage
66

@@ -12,6 +12,7 @@ This action for [Changesets](https://github.com/atlassian/changesets) creates a
1212
- title - The pull request title. Default to `Version Packages`
1313
- setupGitUser - Sets up the git user for commits as `"github-actions[bot]"`. Default to `true`
1414
- createGithubReleases - A boolean value to indicate whether to create Github releases after `publish` or not. Default to `true`
15+
- commitMode - Specifies the commit mode. Use `"git-cli"` to push changes using the Git CLI, or `"github-api"` to push changes via the GitHub API. When using `"github-api"`, all commits and tags are GPG-signed and attributed to the user or app who owns the `GITHUB_TOKEN`. Default to `git-cli`.
1516
- cwd - Changes node's `process.cwd()` if the project is not located on the root. Default to `process.cwd()`
1617

1718
### Outputs

action.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ inputs:
2828
description: "A boolean value to indicate whether to create Github releases after `publish` or not"
2929
required: false
3030
default: true
31+
commitMode:
32+
description: >
33+
An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI,
34+
or "github-api" to push changes via the GitHub API. When using "github-api",
35+
all commits and tags are signed using GitHub's GPG key and attributed to the user
36+
or app who owns the GITHUB_TOKEN.
37+
required: false
38+
default: "git-cli"
3139
branch:
3240
description: Sets the branch in which the action will run. Default to `github.ref_name` if not provided
3341
required: false

package.json

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,23 @@
44
"main": "dist/index.js",
55
"license": "MIT",
66
"devDependencies": {
7-
"@changesets/changelog-github": "^0.4.2",
8-
"@changesets/cli": "^2.20.0",
9-
"@changesets/write": "^0.1.6",
10-
"@vercel/ncc": "^0.36.1",
11-
"fixturez": "^1.1.0",
12-
"prettier": "^2.0.5",
13-
"typescript": "^5.0.4",
147
"@babel/core": "^7.13.10",
158
"@babel/preset-env": "^7.13.10",
169
"@babel/preset-typescript": "^7.13.0",
10+
"@changesets/changelog-github": "^0.4.2",
11+
"@changesets/cli": "^2.20.0",
12+
"@changesets/write": "^0.1.6",
1713
"@types/fs-extra": "^8.0.0",
1814
"@types/jest": "^29.5.1",
1915
"@types/node": "^20.11.17",
2016
"@types/semver": "^7.5.0",
17+
"@vercel/ncc": "^0.36.1",
2118
"babel-jest": "^29.5.0",
19+
"fixturez": "^1.1.0",
2220
"husky": "^3.0.3",
23-
"jest": "^29.5.0"
21+
"jest": "^29.5.0",
22+
"prettier": "^2.0.5",
23+
"typescript": "^5.0.4"
2424
},
2525
"scripts": {
2626
"build": "ncc build src/index.ts -o dist --transpile-only --minify",
@@ -37,6 +37,7 @@
3737
"@actions/core": "^1.10.0",
3838
"@actions/exec": "^1.1.1",
3939
"@actions/github": "^5.1.1",
40+
"@changesets/ghcommit": "1.3.0",
4041
"@changesets/pre": "^1.0.9",
4142
"@changesets/read": "^0.5.3",
4243
"@manypkg/get-packages": "^1.1.3",
@@ -57,5 +58,6 @@
5758
"**/@octokit/core": "4.2.0",
5859
"trim": "^0.0.3",
5960
"y18n": "^4.0.1"
60-
}
61+
},
62+
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
6163
}

src/git.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import * as core from "@actions/core";
2+
import { exec, getExecOutput } from "@actions/exec";
3+
import * as github from "@actions/github";
4+
import { commitChangesFromRepo } from "@changesets/ghcommit/git";
5+
import { Octokit } from "./octokit";
6+
7+
const push = async (branch: string, { force }: { force?: boolean } = {}) => {
8+
await exec(
9+
"git",
10+
["push", "origin", `HEAD:${branch}`, force && "--force"].filter<string>(
11+
Boolean as any
12+
)
13+
);
14+
};
15+
16+
const switchToMaybeExistingBranch = async (branch: string) => {
17+
let { stderr } = await getExecOutput("git", ["checkout", branch], {
18+
ignoreReturnCode: true,
19+
});
20+
let isCreatingBranch = !stderr
21+
.toString()
22+
.includes(`Switched to a new branch '${branch}'`);
23+
if (isCreatingBranch) {
24+
await exec("git", ["checkout", "-b", branch]);
25+
}
26+
};
27+
28+
const reset = async (
29+
pathSpec: string,
30+
mode: "hard" | "soft" | "mixed" = "hard"
31+
) => {
32+
await exec("git", ["reset", `--${mode}`, pathSpec]);
33+
};
34+
35+
const commitAll = async (message: string) => {
36+
await exec("git", ["add", "."]);
37+
await exec("git", ["commit", "-m", message]);
38+
};
39+
40+
const checkIfClean = async (): Promise<boolean> => {
41+
const { stdout } = await getExecOutput("git", ["status", "--porcelain"]);
42+
return !stdout.length;
43+
};
44+
45+
export class Git {
46+
octokit;
47+
constructor(octokit?: Octokit) {
48+
this.octokit = octokit;
49+
}
50+
51+
async setupUser() {
52+
if (this.octokit) {
53+
return;
54+
}
55+
await exec("git", ["config", "user.name", `"github-actions[bot]"`]);
56+
await exec("git", [
57+
"config",
58+
"user.email",
59+
`"41898282+github-actions[bot]@users.noreply.github.com"`,
60+
]);
61+
}
62+
63+
async pushTag(tag: string) {
64+
if (this.octokit) {
65+
return this.octokit.rest.git
66+
.createRef({
67+
...github.context.repo,
68+
ref: `refs/tags/${tag}`,
69+
sha: github.context.sha,
70+
})
71+
.catch((err) => {
72+
// Assuming tag was manually pushed in custom publish script
73+
core.warning(`Failed to create tag ${tag}: ${err.message}`);
74+
});
75+
}
76+
await exec("git", ["push", "origin", tag]);
77+
}
78+
79+
async prepareBranch(branch: string) {
80+
if (this.octokit) {
81+
// Preparing a new local branch is not necessary when using the API
82+
return;
83+
}
84+
await switchToMaybeExistingBranch(branch);
85+
await reset(github.context.sha);
86+
}
87+
88+
async pushChanges({ branch, message }: { branch: string; message: string }) {
89+
if (this.octokit) {
90+
return commitChangesFromRepo({
91+
octokit: this.octokit,
92+
...github.context.repo,
93+
branch,
94+
message,
95+
base: {
96+
commit: github.context.sha,
97+
},
98+
force: true,
99+
});
100+
}
101+
if (!(await checkIfClean())) {
102+
await commitAll(message);
103+
}
104+
await push(branch, { force: true });
105+
}
106+
}

src/gitUtils.ts

Lines changed: 0 additions & 63 deletions
This file was deleted.

src/index.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as core from "@actions/core";
22
import fs from "fs-extra";
3-
import * as gitUtils from "./gitUtils";
4-
import { runPublish, runVersion } from "./run";
3+
import { Git } from "./git";
4+
import { setupOctokit } from "./octokit";
55
import readChangesetState from "./readChangesetState";
6+
import { runPublish, runVersion } from "./run";
67

78
const getOptionalInput = (name: string) => core.getInput(name) || undefined;
89

@@ -20,11 +21,19 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined;
2021
process.chdir(inputCwd);
2122
}
2223

24+
const octokit = setupOctokit(githubToken);
25+
const commitMode = getOptionalInput("commitMode") ?? "git-cli";
26+
if (commitMode !== "git-cli" && commitMode !== "github-api") {
27+
core.setFailed(`Invalid commit mode: ${commitMode}`);
28+
return;
29+
}
30+
const git = new Git(commitMode === "github-api" ? octokit : undefined);
31+
2332
let setupGitUser = core.getBooleanInput("setupGitUser");
2433

2534
if (setupGitUser) {
2635
core.info("setting git user");
27-
await gitUtils.setupUser();
36+
await git.setupUser();
2837
}
2938

3039
core.info("setting GitHub credentials");
@@ -48,7 +57,9 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined;
4857

4958
switch (true) {
5059
case !hasChangesets && !hasPublishScript:
51-
core.info("No changesets present or were removed by merging release PR. Not publishing because no publish script found.");
60+
core.info(
61+
"No changesets present or were removed by merging release PR. Not publishing because no publish script found."
62+
);
5263
return;
5364
case !hasChangesets && hasPublishScript: {
5465
core.info(
@@ -86,7 +97,8 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined;
8697

8798
const result = await runPublish({
8899
script: publishScript,
89-
githubToken,
100+
git,
101+
octokit,
90102
createGithubReleases: core.getBooleanInput("createGithubReleases"),
91103
});
92104

@@ -102,10 +114,12 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined;
102114
case hasChangesets && !hasNonEmptyChangesets:
103115
core.info("All changesets are empty; not creating PR");
104116
return;
105-
case hasChangesets:
117+
case hasChangesets: {
118+
const octokit = setupOctokit(githubToken);
106119
const { pullRequestNumber } = await runVersion({
107120
script: getOptionalInput("version"),
108-
githubToken,
121+
git,
122+
octokit,
109123
prTitle: getOptionalInput("title"),
110124
commitMessage: getOptionalInput("commit"),
111125
hasPublishScript,
@@ -115,6 +129,7 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined;
115129
core.setOutput("pullRequestNumber", String(pullRequestNumber));
116130

117131
return;
132+
}
118133
}
119134
})().catch((err) => {
120135
core.error(err);

src/octokit.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as core from "@actions/core";
2+
import { GitHub, getOctokitOptions } from "@actions/github/lib/utils";
3+
import { throttling } from "@octokit/plugin-throttling";
4+
5+
export const setupOctokit = (githubToken: string) => {
6+
return new (GitHub.plugin(throttling))(
7+
getOctokitOptions(githubToken, {
8+
throttle: {
9+
onRateLimit: (retryAfter, options: any, octokit, retryCount) => {
10+
core.warning(
11+
`Request quota exhausted for request ${options.method} ${options.url}`
12+
);
13+
14+
if (retryCount <= 2) {
15+
core.info(`Retrying after ${retryAfter} seconds!`);
16+
return true;
17+
}
18+
},
19+
onSecondaryRateLimit: (
20+
retryAfter,
21+
options: any,
22+
octokit,
23+
retryCount
24+
) => {
25+
core.warning(
26+
`SecondaryRateLimit detected for request ${options.method} ${options.url}`
27+
);
28+
29+
if (retryCount <= 2) {
30+
core.info(`Retrying after ${retryAfter} seconds!`);
31+
return true;
32+
}
33+
},
34+
},
35+
})
36+
);
37+
};
38+
39+
export type Octokit = ReturnType<typeof setupOctokit>;

0 commit comments

Comments
 (0)