Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9012105
Add PR commit signature verification
brooke-hamilton Aug 18, 2026
194aa11
Refactor signature comment orchestration
brooke-hamilton Aug 19, 2026
a3bee1f
Enable manual signature verification
brooke-hamilton Aug 19, 2026
fcbf3e3
Report commit verification results
brooke-hamilton Aug 19, 2026
4dbb978
Retain commit signature guidance comments
brooke-hamilton Aug 19, 2026
d185c64
Avoid workflow command injection
brooke-hamilton Aug 19, 2026
561ebd3
Potential fix for pull request finding
brooke-hamilton Aug 19, 2026
622b615
Validate signature verifier output
brooke-hamilton Aug 19, 2026
9b23913
Merge branch 'main' into brooke-hamilton-commit-signoff-check
brooke-hamilton Aug 19, 2026
6833783
Use github-script for signature verification
brooke-hamilton Aug 19, 2026
af8be6a
Decompose signature verification script
brooke-hamilton Aug 19, 2026
65adad0
Extract signature verification script
brooke-hamilton Aug 20, 2026
c978ccf
Merge origin/main into commit signature verification
brooke-hamilton Aug 26, 2026
925fbf6
Address signature verifier review feedback
brooke-hamilton Aug 26, 2026
c8d6a5c
Apply repository JavaScript formatting
brooke-hamilton Aug 26, 2026
ca94950
Serialize pnpm-based lint checks
brooke-hamilton Aug 26, 2026
0d734a8
Revert "Serialize pnpm-based lint checks"
brooke-hamilton Aug 26, 2026
5b4bab3
Merge branch 'main' into brooke-hamilton-commit-signoff-check
brooke-hamilton Aug 31, 2026
8cb4e64
Merge branch 'main' into brooke-hamilton-commit-signoff-check
brooke-hamilton Sep 4, 2026
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
189 changes: 189 additions & 0 deletions .github/scripts/verify-commit-signatures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright 2026 The Radius Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// @ts-check

const COMMENT_MARKER =
"<!-- Sticky Pull Request Commentcommit-signature-verification -->";

const COMMITS_QUERY = `
query(
$owner: String!
$repo: String!
$number: Int!
$cursor: String
) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
commits(first: 100, after: $cursor) {
nodes {
commit {
oid
signature {
isValid
state
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;

/**
* @param {{ oid: string, signature?: { isValid: boolean, state: string } | null }} commit
* @returns {boolean}
*/
export function hasVerifiedSignature(commit) {
if (!/^[0-9a-f]{40}$/.test(commit.oid)) {
throw new Error("GitHub returned an invalid commit OID");
}

return (
commit.signature?.isValid === true && commit.signature.state === "VALID"
);
}

/**
* @param {import('@actions/github-script').AsyncFunctionArguments['github']} github
* @param {{ owner: string, repo: string }} repository
* @param {number} number
* @returns {Promise<Array<{ oid: string, signature?: { isValid: boolean, state: string } | null }>>}
*/
export async function listPullRequestCommits(github, repository, number) {
const commits = [];
let cursor = null;

do {
const result = await github.graphql(COMMITS_QUERY, {
...repository,
number,
cursor,
});
const pullRequest = result.repository.pullRequest;
if (!pullRequest) {
throw new Error(`Pull request #${number} was not found`);
}

const page = pullRequest.commits;
commits.push(...page.nodes.map((node) => node.commit));
if (page.pageInfo.hasNextPage && !page.pageInfo.endCursor) {
throw new Error("GitHub returned an invalid pagination cursor");
}
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);

return commits;
}

/**
* @param {string[]} unverified
* @param {{ owner: string, repo: string }} repository
* @param {string} serverUrl
* @returns {string}
*/
export function formatGuidance(unverified, repository, serverUrl) {
const guideUrl =
`${serverUrl}/radius-project/radius/blob/main/` +
"docs/contributing/contributing-code/" +
"contributing-code-first-commit/" +
"first-commit-06-creating-a-pr/index.md#signing-your-commits";
const commitLines = unverified.map(
(oid) =>
`- [\`${oid.slice(0, 7)}\`](` +
`${serverUrl}/${repository.owner}/` +
`${repository.repo}/commit/${oid})`,
);

return [
"Thank you for contributing to Radius.",
"",
"The following commits do not currently show a GitHub **Verified** signature:",
"",
...commitLines,
"",
`Please follow our [commit-signing guide](${guideUrl}) to configure ` +
"GPG, SSH, or S/MIME signing. Then re-sign the affected commits " +
"and force-push the rewritten branch with `--force-with-lease`.",
"",
"Cryptographic commit signing is separate from the DCO " +
"`Signed-off-by` line; both are required.",
"",
COMMENT_MARKER,
].join("\n");
}

/** @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments */
export default async ({ github, context, core }) => {
Comment thread
brooke-hamilton marked this conversation as resolved.
const prNumber = Number(process.env.PR_NUMBER);
Comment thread
brooke-hamilton marked this conversation as resolved.
Outdated
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
throw new Error("PR_NUMBER must be a positive integer");
}

const commits = await listPullRequestCommits(github, context.repo, prNumber);
const unverified = commits
.filter((commit) => !hasVerifiedSignature(commit))
.map((commit) => commit.oid);

if (unverified.length === 0) {
core.info(
"All pull request commits have Verified signatures. " +
"Existing guidance comments are retained.",
);
return;
}

core.warning(
`${unverified.length} commit(s) do not have Verified signatures.`,
);
for (const oid of unverified) {
core.info(`- ${oid.slice(0, 7)}`);
}

const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
const body = formatGuidance(unverified, context.repo, serverUrl);
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(COMMENT_MARKER),
);

if (!existing) {
await github.rest.issues.createComment({
...context.repo,
issue_number: prNumber,
body,
});
core.info("Posted the signature guidance comment.");
} else if (existing.body !== body) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body,
});
core.info("Updated the signature guidance comment.");
} else {
core.info("The signature guidance comment is already current.");
}
};
50 changes: 50 additions & 0 deletions .github/workflows/verify-commit-signatures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
---
name: verify-commit-signatures

on:
# This workflow uses the trusted base commit and never executes PR-head code.
pull_request_target:
types: [opened, reopened, synchronize]
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to verify
required: true
type: number

concurrency:
group: verify-commit-signatures-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: true

permissions: {}

jobs:
verify-commit-signatures:
name: Verify Commit Signatures
runs-on: ubuntu-24.04
timeout-minutes: 5
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout signature verifier
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha || github.sha }}
sparse-checkout: |
.github/scripts/verify-commit-signatures.mjs
sparse-checkout-cone-mode: false
persist-credentials: false

- name: Verify commit signatures
Comment thread
brooke-hamilton marked this conversation as resolved.
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const { default: script } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/verify-commit-signatures.mjs`
)
await script({ github, context, core })
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ git commit -s -m 'This is my commit message'

Visual Studio Code has a setting, `git.alwaysSignOff` to automatically add a Signed-off-by line to commit messages. Search for "sign-off" in VS Code settings to find it and enable it.

Every commit must also be cryptographically signed so that GitHub displays a **Verified** badge. Cryptographic signing is separate from the DCO `Signed-off-by` line; both are required. See [Signing your commits](docs/contributing/contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md#signing-your-commits) for setup and remediation guidance.

## Code of conduct

This project has adopted the [Contributor Covenant](http://contributor-covenant.org/).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Visual Studio Code has a setting, `git.alwaysSignOff` to automatically add a Sig

## Signing your commits

> 💡 Commit signing is **separate from** the DCO `Signed-off-by` line described above. The `Signed-off-by` line is a textual attestation, while commit signing proves that the commit was signed with the private key corresponding to your configured public key. Both are recommended.
> 💡 Commit signing is **separate from** the DCO `Signed-off-by` line described above. The `Signed-off-by` line is a textual attestation, while commit signing proves that the commit was signed with the private key corresponding to your configured public key. Both are required.

We require all contributors to **cryptographically sign their commits** so that they show as **Verified** on GitHub. On GitHub, **Verified** means GitHub could validate the signature and that the signing key is associated with the account. This gives reviewers and the community additional confidence in the integrity and provenance of commits, which is an important supply-chain safeguard.

Expand Down
4 changes: 3 additions & 1 deletion docs/contributing/contributing-pull-requests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ We **squash** pull requests as part of the merge process, so intermediate commit

### 3. Sign your commits

The Developer Certificate of Origin (DCO) check requires every commit to be signed off. See [Signing your commits](../contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md#signing-your-commits) in the first commit guide for how to do this.
The Developer Certificate of Origin (DCO) check requires every commit to include a `Signed-off-by` line. Every commit must also be cryptographically signed so that GitHub displays a **Verified** badge. These are separate requirements; see [Signing your commits](../contributing-code/contributing-code-first-commit/first-commit-06-creating-a-pr/index.md#signing-your-commits) in the first commit guide for setup and remediation guidance.

The commit-signature verification workflow posts a non-blocking reminder when any commit does not show a **Verified** signature. It updates that reminder while commits remain unverified and leaves it on the pull request after all commits are verified.

### 4. Open the pull request and fill out the template

Expand Down
Loading