Skip to content

Commit b0de386

Browse files
docs: sign the commits you push, and say so at install (#1836)
1 parent 7e5b0f0 commit b0de386

5 files changed

Lines changed: 112 additions & 10 deletions

File tree

CONTRIBUTING.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,26 @@ To ensure a transparent and trustworthy environment, we have established differe
2424
2. **Forking**: Fork the repository and work on changes in your own branch.
2525
3. **Pull Request**: Submit a PR from your fork. Ensure your branch is up to date with `main`.
2626

27+
### Signed Commits
28+
29+
A commit pushed to a branch in this repository is signed, and its commit email is an address verified on your GitHub account; a push carrying an unsigned commit is refused. A pull request from a fork needs none of this — the commit that lands on `main` is created and signed by GitHub.
30+
31+
Sign with the SSH key you already push with — [GitHub accepts an authentication key a second time as a signing key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification):
32+
33+
```bash
34+
git config --global gpg.format ssh
35+
git config --global user.signingkey ~/.ssh/id_ed25519.pub
36+
git config --global commit.gpgsign true
37+
```
38+
39+
Then register that public key on GitHub under Settings → SSH and GPG keys → New SSH key with the key type **Signing Key**, or run `gh ssh-key add ~/.ssh/id_ed25519.pub --type signing`. Until it is registered as a signing key, signatures made with it stay unverified. `vp install` warns when this is not configured.
40+
41+
If a push is refused, sign the commits your branch already carries and force-push:
42+
43+
```bash
44+
git rebase --exec 'git commit --amend --no-edit -S' origin/main
45+
```
46+
2747
### Compliance
2848

2949
Every commit on a pull request must be authored by an email address that resolves to the GitHub account that opened it, so set `git config user.email` accordingly before you commit.

docs/contributor/local-development.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Install and configure the following tools before you attempt a local build:
3434
After `vp install`, `vp --version` prints the pinned `vite-plus`.
3535
4. **NATS CLI (optional)** – Helpful when inspecting the webhook/sync event stream (NATS is disabled by default locally). The agent job queue runs on PostgreSQL and needs no NATS.
3636

37+
If you push branches to the repository rather than to a fork, configure commit signing as well — [CONTRIBUTING.md § Signed Commits](https://github.com/hephaestus-build/Hephaestus/blob/main/CONTRIBUTING.md#signed-commits) has the setup, and `vp install` warns while it is missing.
38+
3739
## Recommended IDE setup
3840

3941
Open the repository using the `project.code-workspace` file in VS Code and install the workspace recommendations (`@recommended` in the Extensions view). Key extensions include:

scripts/enable-hooks.test.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { execFileSync, spawnSync } from "node:child_process";
3-
import { cpSync, mkdtempSync, rmSync } from "node:fs";
3+
import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join, resolve } from "node:path";
66
import { after, test } from "node:test";
@@ -9,25 +9,42 @@ import { CAPTURE_LIMIT_BYTES } from "./lib/process.ts";
99

1010
const REPO_ROOT = resolve(import.meta.dirname, "..");
1111
const SCRIPT = join(REPO_ROOT, "scripts", "enable-hooks.ts");
12-
const repositories: string[] = [];
12+
const temporaries: string[] = [];
13+
14+
/** A global Git config holding exactly these lines, so the machine's own cannot decide a test. */
15+
function globalConfig(contents = ""): string {
16+
const directory = mkdtempSync(join(tmpdir(), "enable-hooks-config-"));
17+
temporaries.push(directory);
18+
const path = join(directory, "gitconfig");
19+
writeFileSync(path, contents);
20+
return path;
21+
}
22+
23+
const EMPTY_GLOBAL_CONFIG = globalConfig();
1324

1425
/**
1526
* Git exports `GIT_DIR` and its siblings to every hook process, and they outrank a child's `cwd`.
1627
* Inherited, they would point both the script under test and the assertions at the repository the
17-
* hook is running in rather than at the clone made below.
28+
* hook is running in rather than at the clone made below. The global and system configs are then
29+
* replaced rather than dropped, because what a developer's own config says about commit signing is
30+
* exactly what these tests decide.
1831
*/
19-
function hookFreeEnv(): NodeJS.ProcessEnv {
20-
return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
32+
function hookFreeEnv(configPath: string = EMPTY_GLOBAL_CONFIG): NodeJS.ProcessEnv {
33+
return {
34+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
35+
GIT_CONFIG_GLOBAL: configPath,
36+
GIT_CONFIG_NOSYSTEM: "1",
37+
};
2138
}
2239

2340
after(() => {
24-
for (const repository of repositories) rmSync(repository, { recursive: true, force: true });
41+
for (const temporary of temporaries) rmSync(temporary, { recursive: true, force: true });
2542
});
2643

2744
/** A clone with the project hooks whose Git still points at an earlier hook manager's directory. */
2845
function clone(): { repository: string; git: (...args: string[]) => string } {
2946
const repository = mkdtempSync(join(tmpdir(), "enable-hooks-"));
30-
repositories.push(repository);
47+
temporaries.push(repository);
3148
const git = (...args: string[]): string =>
3249
execFileSync("git", args, {
3350
cwd: repository,
@@ -80,3 +97,31 @@ void test("an install under a hook's GIT_DIR configures the clone it runs in", (
8097
assert.equal(git("config", "core.hooksPath"), ".vite-hooks/_");
8198
assert.equal(decoy.git("config", "core.hooksPath"), ".husky/_");
8299
});
100+
101+
void test("an install without commit signing configured warns and still succeeds", () => {
102+
const { repository } = clone();
103+
const result = spawnSync("node", [SCRIPT], {
104+
cwd: repository,
105+
encoding: "utf8",
106+
maxBuffer: CAPTURE_LIMIT_BYTES,
107+
env: hookFreeEnv(),
108+
});
109+
assert.equal(result.status, 0, `${result.stdout}${result.stderr}`);
110+
assert.match(result.stderr, /git config --global commit\.gpgsign true/);
111+
assert.match(result.stderr, /gh ssh-key add .* --type signing/);
112+
});
113+
114+
void test("an install with commit signing configured says nothing about it", () => {
115+
const { repository } = clone();
116+
const signing = globalConfig(
117+
"[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ~/.ssh/id_ed25519.pub\n[commit]\n\tgpgsign = true\n",
118+
);
119+
const result = spawnSync("node", [SCRIPT], {
120+
cwd: repository,
121+
encoding: "utf8",
122+
maxBuffer: CAPTURE_LIMIT_BYTES,
123+
env: hookFreeEnv(signing),
124+
});
125+
assert.equal(result.status, 0, `${result.stdout}${result.stderr}`);
126+
assert.doesNotMatch(result.stderr, /commit\.gpgsign/);
127+
});

scripts/enable-hooks.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ import { CAPTURE_LIMIT_BYTES } from "./lib/process.ts";
1010

1111
const HOOKS_DIR = ".vite-hooks";
1212

13+
/**
14+
* The repository refuses a push whose commits are unsigned, and an install is the last moment a
15+
* contributor is in front of the machine that has to sign. It is a warning rather than a failure:
16+
* a clone with no signing key still has to be able to build.
17+
*/
18+
const SIGNING_WARNING = `
19+
warning: commits pushed to a branch in this repository must be signed, and this checkout is not
20+
configured to sign. Sign with the SSH key you already push with:
21+
22+
git config --global gpg.format ssh
23+
git config --global user.signingkey ~/.ssh/id_ed25519.pub
24+
git config --global commit.gpgsign true
25+
26+
Then register that public key on GitHub as a signing key — Settings > SSH and GPG keys > New SSH
27+
key, key type "Signing Key", or: gh ssh-key add ~/.ssh/id_ed25519.pub --type signing
28+
29+
CONTRIBUTING.md, under "Signed Commits", has the rest.
30+
`;
31+
1332
function git(...args: string[]): string {
1433
try {
1534
return execFileSync("git", args, {
@@ -31,4 +50,10 @@ if (git("rev-parse", "--is-inside-work-tree") === "true") {
3150
stdio: "inherit",
3251
});
3352
process.exitCode = enabled.status ?? 1;
53+
54+
// `--type=bool` so `1`, `yes` and `on` count as configured; every `gpg.format` signs.
55+
const signs =
56+
git("config", "--get", "--type=bool", "commit.gpgsign") === "true" &&
57+
git("config", "--get", "user.signingkey") !== "";
58+
if (!signs) process.stderr.write(SIGNING_WARNING);
3459
}

scripts/plan-release.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,23 @@ after(() => {
147147
void test("reads schema migrations from the diff between the two releases", async () => {
148148
const repo = mkdtempSync(join(tmpdir(), "plan-release-"));
149149
repositories.push(repo);
150+
// A developer's own `tag.gpgSign` would turn the lightweight tag below into an annotated one and
151+
// send git looking for an editor, so the fixture reads an empty global config instead.
152+
const configDirectory = mkdtempSync(join(tmpdir(), "plan-release-config-"));
153+
repositories.push(configDirectory);
154+
const globalConfig = join(configDirectory, "gitconfig");
155+
writeFileSync(globalConfig, "");
150156
const git = (...args: string[]): string =>
151157
execFileSync("git", args, {
152158
cwd: repo,
153159
encoding: "utf8",
154-
env: Object.fromEntries(
155-
Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
156-
),
160+
env: {
161+
...Object.fromEntries(
162+
Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
163+
),
164+
GIT_CONFIG_GLOBAL: globalConfig,
165+
GIT_CONFIG_NOSYSTEM: "1",
166+
},
157167
stdio: ["ignore", "pipe", "pipe"],
158168
}).trim();
159169
const changelog = "server/application/src/main/resources/db/changelog";

0 commit comments

Comments
 (0)