Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 25 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
119 changes: 88 additions & 31 deletions go/internal/forge/livegithub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -57,30 +62,82 @@ 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. 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)
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 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)
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
Expand Down Expand Up @@ -738,7 +795,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"}`)
Expand All @@ -748,7 +805,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)
Expand Down Expand Up @@ -791,7 +848,7 @@ func githubREST(ctx context.Context, ts TokenSource, method, path string, body i
// (2) creates refs/heads/<head> at that SHA, then (3) PUTs a unique file on
// <head> 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 {
Expand Down
Loading