From e590b39a2426f0ce6bf8a6f69d3be2e557427f29 Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 2 Sep 2026 16:57:49 -0400 Subject: [PATCH 1/2] test(forge): live-oracle GitHub legs authenticate as Apps, not PATs (RIG-3096) Swap the forge live-contract oracle's author + reviewer identities from fine-grained bot PATs to GitHub App installation-token sources, exercising the production mint path (RS256 App JWT -> installation access token) the deployed server uses after the RIG-3090 credential cutover. The Linear legs stay on the app-actor token (client_credentials mint); the retained-Linear-user-cred plan from the frozen record is dropped per Matt (agent delegation only, moving off the user model). Refs RIG-3096 Co-authored-by: Matt Wilkinson --- .github/workflows/ci.yml | 39 +++++---- go/internal/forge/livegithub_test.go | 114 +++++++++++++++++++-------- 2 files changed, 108 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d005817..5784bfcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1035,16 +1035,22 @@ jobs: (github.event_name == 'workflow_dispatch' && github.event.inputs.pr != '') env: - # The T2 env contract (design §358-362, §431-436): the GitHub live legs - # gate on the LIVEGITHUB_* trio, the Linear live legs gate independently - # on LINEAR_FORGE (+ its team). The LIVEGITHUB_* trio + LINEAR_FORGE_TEAM - # come straight from repo Actions secrets — never available to a fork PR, - # which is why the fork arm of the `if:` skips. LINEAR_FORGE itself is - # the app-actor token the mint step above just exported to the job env - # (a personal key 400s against Linear's Bearer endpoint — RIG-2423). + # The T2 env contract (design §358-362, §431-436), post-RIG-3096 App + # cutover: the GitHub live legs authenticate as two GitHub Apps — the + # author + reviewer id/installation/PEM sets below — and the Linear + # live legs gate independently on LINEAR_FORGE (+ its team). The App + # secrets + LINEAR_FORGE_TEAM come straight from repo Actions secrets — + # never available to a fork PR, which is why the fork arm of the `if:` + # skips. LINEAR_FORGE itself is the app-actor token the mint step above + # just exported to the job env (a personal key 400s against Linear's + # Bearer endpoint — RIG-2423). LIVEGITHUB_REPO: ${{ secrets.LIVEGITHUB_REPO }} - LIVEGITHUB_AUTHOR_TOKEN: ${{ secrets.LIVEGITHUB_AUTHOR_TOKEN }} - LIVEGITHUB_REVIEWER_TOKEN: ${{ secrets.LIVEGITHUB_REVIEWER_TOKEN }} + LIVEGITHUB_AUTHOR_APP_ID: ${{ secrets.LIVEGITHUB_AUTHOR_APP_ID }} + LIVEGITHUB_AUTHOR_APP_INSTALLATION_ID: ${{ secrets.LIVEGITHUB_AUTHOR_APP_INSTALLATION_ID }} + LIVEGITHUB_AUTHOR_APP_KEY: ${{ secrets.LIVEGITHUB_AUTHOR_APP_KEY }} + LIVEGITHUB_REVIEWER_APP_ID: ${{ secrets.LIVEGITHUB_REVIEWER_APP_ID }} + LIVEGITHUB_REVIEWER_APP_INSTALLATION_ID: ${{ secrets.LIVEGITHUB_REVIEWER_APP_INSTALLATION_ID }} + LIVEGITHUB_REVIEWER_APP_KEY: ${{ secrets.LIVEGITHUB_REVIEWER_APP_KEY }} LINEAR_FORGE: ${{ env.LINEAR_FORGE }} LINEAR_FORGE_TEAM: ${{ secrets.LINEAR_FORGE_TEAM }} # -race needs cgo, matching go/moon.yml's race lane (§431-436). Without @@ -2344,17 +2350,22 @@ jobs: - name: Regenerate the forge golden fixtures against the live testbed working-directory: go env: - # The SAME T2 env contract as the live-contract oracle in forge-oracle: the - # GitHub live legs gate on the LIVEGITHUB_* trio, the Linear legs on - # LINEAR_FORGE (+ team). The LIVEGITHUB_* trio + LINEAR_FORGE_TEAM come + # The SAME T2 env contract as the live-contract oracle in forge-oracle + # (post-RIG-3096 App cutover): the GitHub live legs authenticate as the + # two GitHub Apps (author + reviewer id/installation/PEM), the Linear + # legs on LINEAR_FORGE (+ team). The App secrets + LINEAR_FORGE_TEAM come # from Actions secrets; LINEAR_FORGE is the app-actor token the mint # step above exported to the job env (client_credentials — RIG-2423). A # dispatch run with the secrets unset is an operator error (the mint # fails loud, or the -update capture would skip and rewrite nothing) — # not a silent-green gate risk, so this lane needs no skip-guard. LIVEGITHUB_REPO: ${{ secrets.LIVEGITHUB_REPO }} - LIVEGITHUB_AUTHOR_TOKEN: ${{ secrets.LIVEGITHUB_AUTHOR_TOKEN }} - LIVEGITHUB_REVIEWER_TOKEN: ${{ secrets.LIVEGITHUB_REVIEWER_TOKEN }} + LIVEGITHUB_AUTHOR_APP_ID: ${{ secrets.LIVEGITHUB_AUTHOR_APP_ID }} + LIVEGITHUB_AUTHOR_APP_INSTALLATION_ID: ${{ secrets.LIVEGITHUB_AUTHOR_APP_INSTALLATION_ID }} + LIVEGITHUB_AUTHOR_APP_KEY: ${{ secrets.LIVEGITHUB_AUTHOR_APP_KEY }} + LIVEGITHUB_REVIEWER_APP_ID: ${{ secrets.LIVEGITHUB_REVIEWER_APP_ID }} + LIVEGITHUB_REVIEWER_APP_INSTALLATION_ID: ${{ secrets.LIVEGITHUB_REVIEWER_APP_INSTALLATION_ID }} + LIVEGITHUB_REVIEWER_APP_KEY: ${{ secrets.LIVEGITHUB_REVIEWER_APP_KEY }} LINEAR_FORGE: ${{ env.LINEAR_FORGE }} LINEAR_FORGE_TEAM: ${{ secrets.LINEAR_FORGE_TEAM }} CGO_ENABLED: '1' diff --git a/go/internal/forge/livegithub_test.go b/go/internal/forge/livegithub_test.go index b51b4f6c..6df0201c 100644 --- a/go/internal/forge/livegithub_test.go +++ b/go/internal/forge/livegithub_test.go @@ -8,19 +8,24 @@ package forge // it — the untagged golden battery (golden_test.go) stays credential-free. // // Each scenario re-runs a T1 golden scenario against the REAL forge (GitHub / -// Linear) using per-identity PATs from the environment, then asserts the live -// decoded domain value matches the committed T1 fixture's `want` EXCEPT for an -// explicit volatile-field allowlist (see volatileFields) — the fields the forge -// assigns per run/identity or that a hygiene-unique artifact name perturbs. +// Linear), then asserts the live decoded domain value matches the committed T1 +// fixture's `want` EXCEPT for an explicit volatile-field allowlist (see +// volatileFields) — the fields the forge assigns per run/identity or that a +// hygiene-unique artifact name perturbs. // -// It reuses T1's harness symbols directly (same package, no import): the -// fixture/fixtureRequest/fixtureResponse schema, loadFixtures, and the -// fakeTokenSource shape (github_test.go) built per identity from its env PAT. +// The GitHub legs authenticate as two GitHub Apps (author + reviewer), each +// built into a real installation-token source (NewAppTokenSource) from an App +// id + installation id + PEM in the environment, so the oracle drives the +// production mint path — never a bot PAT (RIG-3096). The Linear legs +// authenticate as the app-actor client_credentials token (LINEAR_FORGE); there +// is no retained Linear user credential (agent delegation only). The +// auth-failure test builds its own throwaway fakeTokenSource (github_test.go's +// shape) to drive a deliberately-bad credential. // -// The suite SKIPS (never fails) when its credentials are unset: the GitHub trio -// gates the GitHub legs, LINEAR_FORGE gates the Linear legs independently. The -// skip message is a stable one-line string literal (liveSkipMessage / -// liveLinearSkipMessage) that T3's CI guard greps from this source. +// The suite SKIPS (never fails) when its credentials are unset: the GitHub App +// set gates the GitHub legs, LINEAR_FORGE gates the Linear legs independently. +// The skip message is a stable one-line string literal (liveSkipMessage / +// liveLinearSkipMessage) that the CI guard greps from this source. // // context.Background() below is the test root — the sanctioned F-ttsr exemption // (mirrors github_test.go / linear_test.go / golden_test.go). @@ -57,30 +62,77 @@ const liveLinearSkipMessage = "live linear oracle: LINEAR_FORGE credential unset // fixtures), so a bare tagged run skips it cleanly with this one-line literal. const liveUpdateSkipMessage = "live update capture: -update unset; skipping fixture regeneration" -// Env contract (frozen design T2 interfaces). +// Env contract (frozen design T2 interfaces, RIG-3096 App-credential cutover). +// The GitHub live legs now authenticate as two GitHub Apps (author + reviewer), +// each an App id + installation id + PEM private key, so the oracle exercises +// the production installation-token mint path (githubapp.go NewAppTokenSource) +// the deployed server uses after the RIG-3090 cutover — never a bot PAT. const ( - envRepo = "LIVEGITHUB_REPO" // "owner/name" of the throwaway repo - envAuthor = "LIVEGITHUB_AUTHOR_TOKEN" // test-only author bot PAT - envReviewer = "LIVEGITHUB_REVIEWER_TOKEN" // test-only reviewer bot PAT - envLinear = "LINEAR_FORGE" // test-only Linear token (dedicated test team) - envTeam = "LINEAR_FORGE_TEAM" // test team key; no default (dead "SEA" dropped) + envRepo = "LIVEGITHUB_REPO" // "owner/name" of the throwaway repo + + envAuthorAppID = "LIVEGITHUB_AUTHOR_APP_ID" // author App id (numeric) + envAuthorInstall = "LIVEGITHUB_AUTHOR_APP_INSTALLATION_ID" // author App installation id (numeric) + envAuthorKey = "LIVEGITHUB_AUTHOR_APP_KEY" // author App PEM private key + + envReviewerAppID = "LIVEGITHUB_REVIEWER_APP_ID" // reviewer App id (numeric) + envReviewerInstall = "LIVEGITHUB_REVIEWER_APP_INSTALLATION_ID" // reviewer App installation id (numeric) + envReviewerKey = "LIVEGITHUB_REVIEWER_APP_KEY" // reviewer App PEM private key + + envLinear = "LINEAR_FORGE" // test-only Linear token (dedicated test team; app-actor mint) + envTeam = "LINEAR_FORGE_TEAM" // test team key; no default (dead "SEA" dropped) ) -// requireLive reads the GitHub credential trio and t.Skips (never fails) when -// any is unset, mirroring go/e2e/harness_test.go's podmanUsable() skip. It -// returns the throwaway repo coordinate and env-backed token sources for the -// author and reviewer identities (fakeTokenSource is T1's shape; Token yields -// the env PAT, Invalidate counts — so the auth-failure test can assert the -// client's Invalidate() path fired). -func requireLive(t *testing.T) (repo string, author, reviewer *fakeTokenSource) { +// requireLive reads the GitHub App credential set for both identities and +// t.Skips (never fails) when any is unset, mirroring go/e2e/harness_test.go's +// podmanUsable() skip. It returns the throwaway repo coordinate and real App +// installation-token sources for the author and reviewer identities — each an +// *appTokenSource behind the TokenSource seam (Token mints an installation +// access token from the App JWT; Invalidate drops the cache), so the oracle +// drives the same mint path as the deployed server. +func requireLive(t *testing.T) (repo string, author, reviewer TokenSource) { t.Helper() repo = os.Getenv(envRepo) - at := os.Getenv(envAuthor) - rt := os.Getenv(envReviewer) - if repo == "" || at == "" || rt == "" { + if repo == "" { + t.Skip(liveSkipMessage) + } + author = liveAppSource(t, envAuthorAppID, envAuthorInstall, envAuthorKey) + reviewer = liveAppSource(t, envReviewerAppID, envReviewerInstall, envReviewerKey) + return repo, author, reviewer +} + +// liveAppSource builds a real App installation-token source from the id / +// installation / PEM env trio, t.Skipping (never failing) when any is unset so +// a credential-less run skips cleanly, and t.Fataling only when a value is +// present but malformed (a non-numeric id or a NewAppTokenSource rejection is a +// misconfigured secret, not a skip). The PEM is captured by value into the +// PrivateKey resolver — no per-mint env read. +func liveAppSource(t *testing.T, idEnv, installEnv, keyEnv string) TokenSource { + t.Helper() + idStr := os.Getenv(idEnv) + installStr := os.Getenv(installEnv) + pem := os.Getenv(keyEnv) + if idStr == "" || installStr == "" || pem == "" { t.Skip(liveSkipMessage) } - return repo, &fakeTokenSource{token: at}, &fakeTokenSource{token: rt} + appID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + t.Fatalf("%s = %q: not a numeric App id: %v", idEnv, idStr, err) + } + installID, err := strconv.ParseInt(installStr, 10, 64) + if err != nil { + t.Fatalf("%s = %q: not a numeric installation id: %v", installEnv, installStr, err) + } + key := []byte(pem) + ts, err := NewAppTokenSource(GitHubAppConfig{ + AppID: appID, + InstallationID: installID, + PrivateKey: func(context.Context) ([]byte, error) { return key, nil }, + Host: "github.com", + }) + if err != nil { + t.Fatalf("%s/%s: NewAppTokenSource: %v", idEnv, installEnv, err) + } + return ts } // requireLinear reads the Linear credential AND the test team key, t.Skipping @@ -738,7 +790,7 @@ func isTransientNetworkTimeout(err error) bool { // closeGitHubIssue closes an issue via REST (GitHub cannot delete issues). A // teardown failure is logged, never fatal — the next run's unique run-id avoids // collisions regardless. -func closeGitHubIssue(t *testing.T, ts *fakeTokenSource, repo string, number uint64) { +func closeGitHubIssue(t *testing.T, ts TokenSource, repo string, number uint64) { t.Helper() path := fmt.Sprintf("/repos/%s/issues/%d", repo, number) body := strings.NewReader(`{"state":"closed"}`) @@ -748,7 +800,7 @@ func closeGitHubIssue(t *testing.T, ts *fakeTokenSource, repo string, number uin } // teardownGitHubPR closes a PR and deletes its head branch via REST. -func teardownGitHubPR(t *testing.T, ts *fakeTokenSource, repo string, number uint64, head string) { +func teardownGitHubPR(t *testing.T, ts TokenSource, repo string, number uint64, head string) { t.Helper() ctx := context.Background() prPath := fmt.Sprintf("/repos/%s/pulls/%d", repo, number) @@ -791,7 +843,7 @@ func githubREST(ctx context.Context, ts TokenSource, method, path string, body i // (2) creates refs/heads/ at that SHA, then (3) PUTs a unique file on // to make it diverge. Authored by the same identity that opens the PR. // The create-content PUT is wrapped in the shared secondary-rate-limit backoff. -func seedHeadBranch(t *testing.T, ctx context.Context, ts *fakeTokenSource, repo, head string) { +func seedHeadBranch(t *testing.T, ctx context.Context, ts TokenSource, repo, head string) { t.Helper() var mainRef struct { From fd83ead12528bcc5e58e19f5f22ca51256c8013e Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 2 Sep 2026 17:15:27 -0400 Subject: [PATCH 2/2] test(forge): clarify requireLive/liveAppSource credential-validation docs (RIG-3096) Review low-severity doc-precision fixes on PR #837: - Note requireLive eagerly builds BOTH App identities regardless of which the caller uses, so a reviewer-only misconfig still fails an author-only test loud (the intended all-creds-or-skip gate). - Clarify a malformed PEM is caught at first mint on a live leg (lazy key resolution), not at liveAppSource setup \u2014 still red, never silent-green. Comment-only; no behavior change. Refs RIG-3096 Co-authored-by: Matt Wilkinson --- go/internal/forge/livegithub_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/go/internal/forge/livegithub_test.go b/go/internal/forge/livegithub_test.go index 6df0201c..28e2a6d6 100644 --- a/go/internal/forge/livegithub_test.go +++ b/go/internal/forge/livegithub_test.go @@ -88,7 +88,9 @@ const ( // installation-token sources for the author and reviewer identities — each an // *appTokenSource behind the TokenSource seam (Token mints an installation // access token from the App JWT; Invalidate drops the cache), so the oracle -// drives the same mint path as the deployed server. +// drives the same mint path as the deployed server. It eagerly builds BOTH +// identities regardless of which the caller uses, so a reviewer-only misconfig +// still fails an author-only test loud — the intended all-creds-or-skip gate. func requireLive(t *testing.T) (repo string, author, reviewer TokenSource) { t.Helper() repo = os.Getenv(envRepo) @@ -102,10 +104,13 @@ func requireLive(t *testing.T) (repo string, author, reviewer TokenSource) { // liveAppSource builds a real App installation-token source from the id / // installation / PEM env trio, t.Skipping (never failing) when any is unset so -// a credential-less run skips cleanly, and t.Fataling only when a value is -// present but malformed (a non-numeric id or a NewAppTokenSource rejection is a -// misconfigured secret, not a skip). The PEM is captured by value into the -// PrivateKey resolver — no per-mint env read. +// a credential-less run skips cleanly, and t.Fataling when the id or +// installation is present but non-numeric, or NewAppTokenSource rejects the +// config (a misconfigured secret, not a skip). A present-but-malformed PEM is +// NOT caught here: NewAppTokenSource resolves the key lazily, so a bad PEM +// surfaces as a mint failure on the first live GitHub leg (still red, never +// silent-green). The PEM is captured by value into the PrivateKey resolver — +// no per-mint env read. func liveAppSource(t *testing.T, idEnv, installEnv, keyEnv string) TokenSource { t.Helper() idStr := os.Getenv(idEnv)