Skip to content

feat: add a githubauth command line tool - #70

Merged
jferrl merged 2 commits into
mainfrom
feat/cli
Sep 19, 2026
Merged

jferrl merged 2 commits into
mainfrom
feat/cli

Conversation

@jferrl

@jferrl jferrl commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Description

Authenticating as a GitHub App from a shell means signing an RS256 JWT, POSTing it to the
installation endpoint and parsing the reply. That gets copied between repositories as a
block of openssl and curl, and the copies drift.

cmd/githubauth does it in one call:

go install github.com/jferrl/go-githubauth/cmd/githubauth@latest

githubauth token --client-id Iv1.abc --key app.pem --installation 12345

The token is the only thing on stdout, so it composes:

curl -H "Authorization: Bearer $(githubauth token)" \
  https://api.github.com/installation/repositories

githubauth jwt prints the App JWT for the endpoints that take one. Every flag falls back
to an environment variable, and --key accepts a file path, the PEM itself, or - to read
stdin, so the key never has to touch disk.

Decisions worth a second look

Standard library flag, not Cobra. A CLI dependency lands in go.mod and every
library consumer inherits it. go.mod is unchanged by this PR, so the two-dependency
footprint the README advertises still holds. The cost is hand-rolled help and subcommand
dispatch.

Flag defaults are never printed in help. Every flag defaults to an environment
variable and one of them is GITHUB_APP_PRIVATE_KEY, so flag.PrintDefaults would print
the private key to anyone with it set. printFlags replaces it and also renders --name
rather than the single-dash spelling. There is a regression test for the leak.

Exit codes follow clig.dev: 2 for CLI misuse (unknown flag, missing or conflicting
value), 1 for a well-formed request that failed. An unknown command suggests the nearest
match.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this change manually

cmd/githubauth is at 97.9% coverage. The remainder is main (calls os.Exit) and
the debug.ReadBuildInfo failure branch. The token path is exercised against an
httptest server through --base-url; no test reaches the network.

Manually verified: JWT header and claims decode to {"alg":"RS256"} with the client ID as
iss and a 10-minute expiry, stdin key piping, --json, typo suggestion, and per-command
help.

Documentation

  • I have updated the documentation accordingly
  • I have updated the CHANGELOG.md file
  • I have added/updated code comments where necessary

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • My changes generate no new warnings
  • I have checked my code and corrected any misspellings
  • My changes are compatible with the Go versions in go.mod and the CI matrix

Verified against GitHub

Run end to end against production api.github.com with a real GitHub App, not a stub.
Token values are not reproduced here.

Step Result
githubauth jwt → GET /app GitHub returned the App, authenticated by the string client ID as iss
GET /app/installations Installation resolved
githubauth token Exit 0, ghs_ token
That token → GET /installation/repositories Authenticated, correct repository list
--json expires_at exactly 60 minutes out, matching GitHub's window
--repos <name> Scoped token sees only that repository
--key - Key read from stdin, token issued
Wrong installation ID Exit 1, GitHub's 404 surfaced verbatim

The JWT was also verified independently of GitHub: the signature checks out against the
App's public key with openssl dgst -verify, and a tampered payload fails.

One observation, unrelated to this PR but confirming the CHANGELOG note on stateless
tokens: GitHub issued a 377-character ghs_ token, the new JWT format rather than the
short opaque string. It passed through unmodified and authenticated a live call, so the
"no action required" note now rests on a production result rather than on reading the
announcement.

Authenticating as a GitHub App from a shell means signing an RS256 JWT,
POSTing it to the installation endpoint and parsing the reply. That gets
copied between repositories as a block of openssl and curl, and the
copies drift.

cmd/githubauth does it in one call. "githubauth token" prints an
installation token and nothing else to stdout, so $(githubauth token)
drops into curl. "githubauth jwt" prints the App JWT for the endpoints
that take one.

Built on the standard library's flag package. Cobra would read better,
but a CLI dependency lands in go.mod and every library consumer inherits
it, which would cost the two-dependency footprint the README advertises.

Conventions follow clig.dev: usage errors exit 2 and runtime failures
exit 1, errors go to stderr, --json gives machine-readable output, help
carries worked examples, and an unknown command suggests the nearest
match. Flag defaults are deliberately never printed, because each flag
falls back to an environment variable and one of them holds the private
key.
@codecov

codecov Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.03922% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.88%. Comparing base (4e47725) to head (c9f0d7e).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
cmd/githubauth/main.go 98.03% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #70      +/-   ##
==========================================
- Coverage   99.31%   98.88%   -0.44%     
==========================================
  Files           4        5       +1     
  Lines         294      447     +153     
==========================================
+ Hits          292      442     +150     
- Misses          2        5       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Rewrites the suite in the table-driven shape the rest of the repo
uses: cases are structs with a name, subtests run under t.Run, and
pure functions run in parallel.

Two fixes the rewrite forced:

Every flag falls back to an environment variable, so a developer with
GITHUB_APP_CLIENT_ID exported was testing a different program than CI
was. Each case now clears all four first.

Threading stdin through run() instead of reading os.Stdin inside the
commands makes "--key -" reachable from a test; os.Stdin now appears
only in main.

Coverage of cmd/githubauth goes from 97.9% to 98.5%, with tokenCmd
reaching 100% once the --enterprise-url branch and its identifier
check had cases. What is left is unreachable: main calls os.Exit,
debug.ReadBuildInfo does not fail under test, and a correctly built
RSA signer does not fail to sign.

Also satisfies errcheck on writes to stdout and stderr, whose failure
a CLI cannot act on.
@jferrl
jferrl merged commit 69b5dd9 into main Sep 19, 2026
8 of 10 checks passed
@jferrl
jferrl deleted the feat/cli branch September 19, 2026 11:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant