Skip to content

Commit 471a57c

Browse files
njhensleymchmarny
andauthored
ci(evidence): make sign workflow commit-back GitHub-Verified (#1720)
Signed-off-by: Nathan Hensley <nhensley@nvidia.com> Co-authored-by: Mark Chmarny <mchmarny@users.noreply.github.com>
1 parent 3af23fa commit 471a57c

3 files changed

Lines changed: 144 additions & 37 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env bash
2+
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Commit the signed/relocated evidence pointers back to the branch through
6+
# GitHub's GraphQL `createCommitOnBranch` mutation so the commit carries
7+
# GitHub's web-flow signature and shows the **Verified** badge (#1551).
8+
#
9+
# Why the API instead of `git push`: GitHub auto-signs only commits it creates
10+
# server-side (REST contents API, GraphQL createCommitOnBranch, web editor,
11+
# merge button). A commit that arrives via `git push` is never signed by
12+
# GitHub, so the runner's client-side commit-back was always Unverified — the
13+
# `github-actions[bot]` identity has no GPG/SSH key on the runner to `-S` with.
14+
# createCommitOnBranch authors the commit as the GITHUB_TOKEN identity
15+
# (github-actions[bot]) and GitHub signs it → Verified.
16+
#
17+
# The signing step's relocation is a delete (flat pointer) + add (nested
18+
# pointer) plus an in-place signer patch, so the mutation sends the FULL
19+
# fileChanges.additions / fileChanges.deletions set computed from the working
20+
# tree against HEAD.
21+
#
22+
# Behavior preserved from the previous `git push` implementation:
23+
# * Clean no-op when nothing under recipes/evidence/ changed (nothing to
24+
# sign): exit 0 without creating a commit.
25+
# * DCO sign-off — a `Signed-off-by:` trailer matching the bot author is
26+
# added to the commit body so the DCO check passes on the commit-back.
27+
# * Loop guard — createCommitOnBranch runs with the default GITHUB_TOKEN, and
28+
# GitHub does not trigger workflow runs for token-authored commits, so the
29+
# commit-back does not re-trigger the sign workflow. The headline is
30+
# unchanged so the workflow's belt-and-suspenders `startsWith(...)` guard
31+
# still matches for any fork pushing via a PAT.
32+
#
33+
# Required env:
34+
# GH_TOKEN token authenticating `gh api` (github.token)
35+
# GITHUB_REPOSITORY owner/repo (provided by Actions)
36+
# GITHUB_REF_NAME branch name to commit onto (provided by Actions)
37+
38+
set -euo pipefail
39+
40+
: "${GH_TOKEN:?GH_TOKEN is required (token authenticating gh api)}"
41+
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
42+
: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required (branch name)}"
43+
44+
# Match the bot author createCommitOnBranch stamps on the commit so the DCO
45+
# sign-off trailer is consistent with the commit author.
46+
readonly BOT_NAME="github-actions[bot]"
47+
readonly BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com"
48+
readonly HEADLINE="chore(evidence): sign pending evidence pointers"
49+
50+
# Stage the relocation (delete flat + add nested + in-place signer patch) so an
51+
# untracked relocated file is counted, then decide whether there is anything to
52+
# commit. --no-renames splits every rename into a delete + add pair, which is
53+
# exactly the shape createCommitOnBranch.fileChanges expects.
54+
git add -A recipes/evidence/
55+
if git diff --cached --quiet -- recipes/evidence/; then
56+
echo "No pointer changes to commit (nothing to sign)."
57+
exit 0
58+
fi
59+
60+
additions='[]'
61+
deletions='[]'
62+
while IFS= read -r -d '' status && IFS= read -r -d '' path; do
63+
case "$status" in
64+
D)
65+
deletions=$(jq -c --arg p "$path" '. += [{path: $p}]' <<<"$deletions")
66+
;;
67+
*)
68+
# A (add) or M (modify): send the full file contents, base64-encoded as
69+
# the GraphQL API requires. -w0 keeps it single-line (GNU coreutils on
70+
# the ubuntu runner).
71+
contents=$(base64 -w0 <"$path")
72+
additions=$(jq -c --arg p "$path" --arg c "$contents" \
73+
'. += [{path: $p, contents: $c}]' <<<"$additions")
74+
;;
75+
esac
76+
done < <(git diff --cached --name-status --no-renames -z -- recipes/evidence/)
77+
78+
# expectedHeadOid pins the mutation to the branch tip we checked out; a
79+
# concurrent advance fails the mutation loudly (re-dispatch after pulling)
80+
# rather than silently racing.
81+
head_oid=$(git rev-parse HEAD)
82+
body="Signed-off-by: ${BOT_NAME} <${BOT_EMAIL}>"
83+
84+
variables=$(jq -n \
85+
--arg repo "$GITHUB_REPOSITORY" \
86+
--arg branch "$GITHUB_REF_NAME" \
87+
--arg oid "$head_oid" \
88+
--arg headline "$HEADLINE" \
89+
--arg body "$body" \
90+
--argjson additions "$additions" \
91+
--argjson deletions "$deletions" \
92+
'{
93+
input: {
94+
branch: {repositoryNameWithOwner: $repo, branchName: $branch},
95+
expectedHeadOid: $oid,
96+
message: {headline: $headline, body: $body},
97+
fileChanges: {additions: $additions, deletions: $deletions}
98+
}
99+
}')
100+
101+
read -r -d '' query <<'GRAPHQL' || true
102+
mutation ($input: CreateCommitOnBranchInput!) {
103+
createCommitOnBranch(input: $input) {
104+
commit {
105+
oid
106+
url
107+
}
108+
}
109+
}
110+
GRAPHQL
111+
112+
# Post {query, variables} to the GraphQL endpoint. `input` is an object
113+
# variable, so it cannot be passed via `gh api graphql -f input=...` (that
114+
# would send a string and fail type-checking) — build the full request body
115+
# and stream it in.
116+
commit_oid=$(jq -n --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}' \
117+
| gh api graphql --input - --jq '.data.createCommitOnBranch.commit.oid')
118+
119+
echo "Committed signed pointers as ${commit_oid} (GitHub-signed, Verified)."

.github/workflows/evidence-publish.yaml

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ jobs:
112112
- name: Checkout
113113
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
114114
with:
115-
# Do not persist the push token in .git/config: it would sit there
116-
# across the fork-controlled `go build` step. The push step below
117-
# supplies the token explicitly only when it needs it.
115+
# Do not persist a token in .git/config: it would sit there across
116+
# the fork-controlled `go build` step. The commit-back does not need
117+
# it — it goes through the GitHub API (createCommitOnBranch), which
118+
# authenticates via GH_TOKEN in the commit step's env.
118119
persist-credentials: false
119120

120121
- name: Load versions
@@ -150,38 +151,22 @@ jobs:
150151
run: .github/scripts/evidence-sign-unsigned.sh
151152

152153
- name: Commit signed pointers
153-
# always(), with no signed-count guard: the git-diff check below decides
154-
# whether there is anything to commit. This is load-bearing for partial
155-
# failure — `aicr evidence sign` patches the signer block into the flat
156-
# pointer BEFORE the relocation step, so if relocation then fails the
157-
# bundle is already signed in the registry. Committing the signed (but
158-
# not-yet-relocated) pointer persists that work, so the next dispatch
159-
# takes the idempotent relocate-only path instead of re-signing and
160-
# attaching a duplicate referrer. The job still fails (the sign step
161-
# exited non-zero), surfacing the failure.
154+
# always(), with no signed-count guard: the git-diff check inside the
155+
# script decides whether there is anything to commit. This is
156+
# load-bearing for partial failure — `aicr evidence sign` patches the
157+
# signer block into the flat pointer BEFORE the relocation step, so if
158+
# relocation then fails the bundle is already signed in the registry.
159+
# Committing the signed (but not-yet-relocated) pointer persists that
160+
# work, so the next dispatch takes the idempotent relocate-only path
161+
# instead of re-signing and attaching a duplicate referrer. The job
162+
# still fails (the sign step exited non-zero), surfacing the failure.
163+
#
164+
# The commit-back is created via GitHub's GraphQL createCommitOnBranch
165+
# mutation rather than `git push`, so GitHub applies its web-flow
166+
# signature and the commit shows Verified (#1551). The DCO sign-off is
167+
# preserved in the commit body; the loop guard holds because a commit
168+
# authored by GITHUB_TOKEN does not trigger workflow runs.
162169
if: ${{ always() }}
163170
env:
164171
GH_TOKEN: ${{ github.token }}
165-
run: |
166-
git config user.name "github-actions[bot]"
167-
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
168-
# -A stages the relocation as a delete (flat pointer) + add (nested
169-
# pointer) plus any in-place signer patch, so the moved files commit
170-
# correctly. Check the STAGED set after `git add` so an untracked
171-
# relocated file is still counted as a change.
172-
git add -A recipes/evidence/
173-
if git diff --cached --quiet -- recipes/evidence/; then
174-
echo "No pointer changes to commit (nothing to sign)."
175-
exit 0
176-
fi
177-
# -s adds a `Signed-off-by:` (DCO) trailer matching the bot author,
178-
# so the DCO bot passes on the commit-back. The commit is NOT
179-
# cryptographically signed (no -S) — it fills in the evidence
180-
# pointer's signer block (the bundle signature); repo commit-signing
181-
# policy is satisfied by the eventual squash-merge.
182-
git commit -s -m "chore(evidence): sign pending evidence pointers"
183-
# Push with the token supplied inline (credentials are not persisted
184-
# in .git/config). A non-fast-forward here is a clear, recoverable
185-
# failure: re-dispatch after pulling.
186-
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
187-
"HEAD:${GITHUB_REF_NAME}"
172+
run: .github/scripts/evidence-commit-signed.sh

docs/contributor/evidence-publishing.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,11 @@ It is a clean no-op when there are no flat pointers, and it fails with a
121121
clear message if it cannot pull a bundle (the public-package requirement
122122
above). Pull the commit it pushes (`git pull`) — your PR now carries a
123123
**signed, nested** pointer (the flat pending file is gone). The *bundle* is
124-
signed; the commit-back itself is a normal, unsigned GitHub Actions commit,
125-
which the eventual squash-merge re-signs under the repo's policy.
124+
signed; the commit-back is created through GitHub's `createCommitOnBranch`
125+
API, so GitHub applies its web-flow signature and it shows **Verified** (the
126+
DCO sign-off is preserved). The evidence trust anchor remains the Sigstore
127+
bundle signature, verified by `aicr evidence verify` — independent of the
128+
git commit's signature status.
126129

127130
> **Run this leg before merge.** The blocking per-source contract gate
128131
> requires a **signed, nested** pointer; it rejects a flat pending pointer

0 commit comments

Comments
 (0)