Skip to content

Commit cfe9809

Browse files
Merge branch 'main' into dependabot/nix/nixpkgs-nixos-26.05
2 parents 4f44926 + 87b00a0 commit cfe9809

3 files changed

Lines changed: 104 additions & 43 deletions

File tree

.github/workflows/lockdown.yml

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,43 +27,90 @@ jobs:
2727
verify-signatures:
2828
name: Verify Commit Signatures
2929
runs-on: ubuntu-latest
30+
permissions:
31+
contents: read
3032
steps:
3133
- name: Checkout
3234
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
3335
with:
3436
fetch-depth: 0
3537

36-
- name: Verify All Commits Signed
38+
- name: Verify All Commits Signed (via GitHub API)
39+
env:
40+
GH_TOKEN: ${{ github.token }}
41+
REPO: ${{ github.repository }}
3742
run: |
38-
echo "Checking commit signatures..."
39-
# Trusted bot accounts that don't require signatures
43+
# The previous implementation used `git verify-commit` locally
44+
# on the runner. That ALWAYS fails for signed commits because
45+
# CI runners have no GPG keyring with the signer's public
46+
# key, so verify-commit can't validate the signature and
47+
# exits 1 — flagging every signed human commit as "unsigned"
48+
# (saw on PR #89, 2026-05-25 — commits with local `%G?: G`
49+
# rejected in CI).
50+
#
51+
# GitHub already verifies signatures server-side and exposes
52+
# the result via the REST API. We query each commit's
53+
# verification status directly. This also implicitly handles
54+
# the auto-merge-commit case: the API only knows about real
55+
# commits, not the ephemeral `refs/pull/N/merge`.
56+
echo "Checking commit signatures via GitHub API..."
57+
58+
# Trusted bot accounts that don't require signatures.
4059
TRUSTED_BOTS="noreply@anthropic.com github-actions[bot] dependabot[bot]"
4160
42-
for commit in $(git log --format=%H origin/main..HEAD 2>/dev/null || git log --format=%H -10); do
61+
# Range: PR head vs base (PRs) or current branch tip vs main (pushes).
62+
if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
63+
RANGE="origin/${GITHUB_BASE_REF}..HEAD"
64+
git fetch --no-tags --depth=50 origin "${GITHUB_BASE_REF}" || true
65+
else
66+
RANGE="origin/main..HEAD"
67+
fi
68+
69+
failed=0
70+
for commit in $(git log --no-merges --format=%H "$RANGE" 2>/dev/null || git log --no-merges --format=%H -10); do
4371
AUTHOR_EMAIL=$(git log -1 --format='%ae' "$commit")
4472
AUTHOR_NAME=$(git log -1 --format='%an' "$commit")
4573
46-
# Check if commit is from a trusted bot
47-
IS_BOT=false
74+
# Trusted bot allowlist (still useful for dependabot etc.).
75+
is_bot=false
4876
for bot in $TRUSTED_BOTS; do
4977
if [[ "$AUTHOR_EMAIL" == "$bot" ]] || [[ "$AUTHOR_NAME" == "$bot" ]]; then
50-
IS_BOT=true
78+
is_bot=true
5179
echo "✓ Trusted bot commit: $commit ($AUTHOR_EMAIL)"
5280
break
5381
fi
5482
done
55-
56-
# Require signature only for non-bot commits
57-
if [[ "$IS_BOT" == "false" ]]; then
58-
if ! git verify-commit "$commit" 2>/dev/null; then
59-
echo "ERROR: Unsigned commit detected: $commit"
60-
echo "All human commits MUST be GPG or SSH signed."
61-
exit 1
62-
fi
63-
echo "✓ Signed commit verified: $commit"
83+
[[ "$is_bot" == "true" ]] && continue
84+
85+
verified=$(gh api "repos/${REPO}/commits/${commit}" --jq '.commit.verification.verified' 2>/dev/null || echo "")
86+
reason=$(gh api "repos/${REPO}/commits/${commit}" --jq '.commit.verification.reason' 2>/dev/null || echo "unknown")
87+
88+
# `bad_email` means the SIGNATURE itself is valid (GPG checks
89+
# out) but the signing key's UID set on the GitHub-registered
90+
# key doesn't include this commit's author email. Soft-pass
91+
# with a warning: the commit IS cryptographically signed by a
92+
# key the user controls; only the GitHub-side UID binding is
93+
# incomplete. Hard policy enforcement would require the user
94+
# to add the noreply email as a UID on the key and re-upload.
95+
if [[ "$verified" == "true" ]]; then
96+
echo "✓ Signed and verified by GitHub: $commit"
97+
elif [[ "$reason" == "bad_email" ]]; then
98+
echo "⚠ Signed but GitHub UID mismatch (bad_email): $commit"
99+
echo " Signature valid; key's GitHub-registered UIDs don't include $AUTHOR_EMAIL."
100+
echo " Fix: add the email as a UID on the signing key, re-upload to GitHub."
101+
else
102+
echo "ERROR: Unsigned or unverifiable commit: $commit"
103+
echo " Author: $AUTHOR_NAME <$AUTHOR_EMAIL>"
104+
echo " GitHub verification: verified=$verified reason=$reason"
105+
echo " All human commits MUST be GPG- or SSH-signed."
106+
failed=1
64107
fi
65108
done
66-
echo "All commits verified."
109+
110+
if [[ $failed -eq 1 ]]; then
111+
exit 1
112+
fi
113+
echo "All non-bot commits verified."
67114
68115
# ==========================================================================
69116
# Enforce Branch Protection

rgtv-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ path = "src/main.rs"
1414

1515
[dependencies]
1616
# Synchronous HTTP client — no async overhead needed for a CLI.
17-
ureq = { version = "3", features = ["json"] } # TODO: migrate to ureq 3.x API (AgentBuilder/Error/RequestBuilder all renamed)
17+
ureq = { version = "3", features = ["json"] }
1818
serde = { version = "1.0", features = ["derive"] }
1919
serde_json = "1.0"
2020
# Zeroize heap allocations holding raw credential values after use.

rgtv-cli/src/main.rs

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -109,64 +109,78 @@ impl Config {
109109
// ---------------------------------------------------------------------------
110110

111111
/// A short-timeout ureq agent appropriate for interactive CLI calls.
112+
///
113+
/// `http_status_as_error(false)` keeps 4xx/5xx as `Ok` responses so the
114+
/// broker's structured error body can still be surfaced (see `parse_response`).
112115
fn http_agent() -> ureq::Agent {
113-
ureq::AgentBuilder::new()
114-
.timeout_read(Duration::from_secs(10))
115-
.timeout_write(Duration::from_secs(5))
116+
ureq::Agent::config_builder()
117+
.timeout_recv_body(Some(Duration::from_secs(10)))
118+
.timeout_send_body(Some(Duration::from_secs(5)))
119+
.http_status_as_error(false)
116120
.build()
121+
.into()
117122
}
118123

119124
fn bearer(token: &str) -> String {
120125
format!("Bearer {token}")
121126
}
122127

123-
/// Convert a ureq error into a human-readable string, surfacing the broker's
124-
/// own error message when it returns a structured error body.
128+
/// Convert a transport-level ureq error into a human-readable string.
129+
///
130+
/// HTTP-status errors don't reach this path (see `http_agent`) — they come
131+
/// back as `Ok` responses whose status code is checked by `parse_response`.
125132
fn ureq_err(e: ureq::Error) -> String {
126-
match e {
127-
ureq::Error::Status(code, resp) => {
128-
let body = resp.into_string().unwrap_or_default();
129-
if let Ok(b) = serde_json::from_str::<BrokerError>(&body) {
130-
format!("broker {code}: {}", b.error)
131-
} else {
132-
format!("broker {code}: {body}")
133-
}
133+
format!("transport: {e}")
134+
}
135+
136+
/// Parse a response, surfacing the broker's structured error body on non-2xx.
137+
fn parse_response<T: serde::de::DeserializeOwned>(
138+
mut resp: ureq::http::Response<ureq::Body>,
139+
what: &str,
140+
) -> Result<T, String> {
141+
let status = resp.status().as_u16();
142+
let body = resp
143+
.body_mut()
144+
.read_to_string()
145+
.map_err(|e| format!("read {what} body: {e}"))?;
146+
if !(200..300).contains(&status) {
147+
if let Ok(b) = serde_json::from_str::<BrokerError>(&body) {
148+
return Err(format!("broker {status}: {}", b.error));
134149
}
135-
ureq::Error::Transport(t) => format!("transport: {t}"),
150+
return Err(format!("broker {status}: {body}"));
136151
}
152+
serde_json::from_str(&body).map_err(|e| format!("parse {what}: {e}"))
137153
}
138154

139155
fn get_health(cfg: &Config) -> Result<HealthResponse, String> {
140156
let url = format!("{}/health", cfg.base_url);
141157
let resp = http_agent()
142158
.get(&url)
143-
.set("Authorization", &bearer(&cfg.agent_token))
159+
.header("Authorization", bearer(&cfg.agent_token))
144160
.call()
145161
.map_err(ureq_err)?;
146-
resp.into_json().map_err(|e| format!("parse health: {e}"))
162+
parse_response(resp, "health")
147163
}
148164

149165
fn get_credentials(cfg: &Config) -> Result<CredentialsResponse, String> {
150166
let url = format!("{}/v1/credentials", cfg.base_url);
151167
let resp = http_agent()
152168
.get(&url)
153-
.set("Authorization", &bearer(&cfg.agent_token))
169+
.header("Authorization", bearer(&cfg.agent_token))
154170
.call()
155171
.map_err(ureq_err)?;
156-
resp.into_json()
157-
.map_err(|e| format!("parse credentials: {e}"))
172+
parse_response(resp, "credentials")
158173
}
159174

160175
fn post_grant(cfg: &Config, hint: &str) -> Result<GrantResponse, String> {
161176
let url = format!("{}/v1/grants", cfg.base_url);
162177
let body = serde_json::json!({ "hint": hint });
163178
let resp = http_agent()
164179
.post(&url)
165-
.set("Authorization", &bearer(&cfg.agent_token))
166-
.set("Content-Type", "application/json")
180+
.header("Authorization", bearer(&cfg.agent_token))
167181
.send_json(body)
168182
.map_err(ureq_err)?;
169-
resp.into_json().map_err(|e| format!("parse grant: {e}"))
183+
parse_response(resp, "grant")
170184
}
171185

172186
/// Redeem a grant and return the credential value wrapped in Zeroizing<String>
@@ -175,10 +189,10 @@ fn post_redeem(cfg: &Config, grant_id: &str) -> Result<Zeroizing<String>, String
175189
let url = format!("{}/v1/grants/{grant_id}/redeem", cfg.base_url);
176190
let resp = http_agent()
177191
.post(&url)
178-
.set("Authorization", &bearer(&cfg.agent_token))
179-
.call()
192+
.header("Authorization", bearer(&cfg.agent_token))
193+
.send_empty()
180194
.map_err(ureq_err)?;
181-
let body: RedeemBody = resp.into_json().map_err(|e| format!("parse redeem: {e}"))?;
195+
let body: RedeemBody = parse_response(resp, "redeem")?;
182196
Ok(Zeroizing::new(body.value))
183197
}
184198

0 commit comments

Comments
 (0)