From bdcd9a28a48061242aa34ad898a906be38bac843 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 13:44:35 +0200 Subject: [PATCH 01/10] debug(github): log GitHub HTTP requests/responses under --debug Adds a debug http.RoundTripper to the github client so every REST and GraphQL call (request method/URL/headers/body and response status/headers/body) is dumped to stderr with the [debug-github] prefix when --debug is set. Authorization header is redacted to the last 4 chars to make CI logs safe to share. Wires global.Debug through NewGithubRetrieverFunc / NewGithubConfig / NewGithubClientFromToken and updates affected tests. Also adds .github/workflows/debug-build.yml to build the CLI on this branch and upload it as the kosli-debug artifact for ad-hoc debugging. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 27 ++++++ cmd/kosli/assertPRGithub.go | 2 +- cmd/kosli/assertPRGithub_test.go | 2 +- cmd/kosli/attestPRGithub.go | 2 +- cmd/kosli/attestPRGithub_test.go | 2 +- internal/github/github.go | 108 ++++++++++++++++++++++-- internal/github/github_contract_test.go | 2 + internal/github/github_test.go | 2 +- 8 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/debug-build.yml diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml new file mode 100644 index 000000000..538e0f279 --- /dev/null +++ b/.github/workflows/debug-build.yml @@ -0,0 +1,27 @@ +name: Debug build + +on: + push: + branches: + - 4986-google-cloud-run-1 + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: '.go-version' + check-latest: true + + - run: make build + + - uses: actions/upload-artifact@v7 + with: + name: kosli-debug + path: kosli + if-no-files-found: error + retention-days: 14 diff --git a/cmd/kosli/assertPRGithub.go b/cmd/kosli/assertPRGithub.go index 8979281ef..22745898a 100644 --- a/cmd/kosli/assertPRGithub.go +++ b/cmd/kosli/assertPRGithub.go @@ -40,7 +40,7 @@ func newAssertPullRequestGithubCmd(out io.Writer) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { o.retriever = ghUtils.NewGithubRetrieverFunc(githubFlagsValues.Token, githubFlagsValues.BaseURL, - githubFlagsValues.Org, githubFlagsValues.Repository) + githubFlagsValues.Org, githubFlagsValues.Repository, global.Debug) return o.run(args) }, } diff --git a/cmd/kosli/assertPRGithub_test.go b/cmd/kosli/assertPRGithub_test.go index c846fa481..2bd2a412c 100644 --- a/cmd/kosli/assertPRGithub_test.go +++ b/cmd/kosli/assertPRGithub_test.go @@ -20,7 +20,7 @@ func (suite *AssertPRGithubCommandTestSuite) SetupTest() { suite.commitWithPR = "480e5a00379a52b8e184d6815080242a878ca295" suite.commitWithNoPR = "7d1db1c8b7e71ee0ce369f1b722cc8844d3a7af6" - ghUtils.NewGithubRetrieverFunc = func(token, baseURL, org, repository string) types.PRRetriever { + ghUtils.NewGithubRetrieverFunc = func(token, baseURL, org, repository string, debug bool) types.PRRetriever { return &ghUtils.FakeGitHubClient{ PRsByCommit: map[string][]*types.PREvidence{ suite.commitWithPR: {{URL: "https://github.com/kosli-dev/cli/pull/1", State: "MERGED"}}, diff --git a/cmd/kosli/attestPRGithub.go b/cmd/kosli/attestPRGithub.go index a4c345c70..9e96449d0 100644 --- a/cmd/kosli/attestPRGithub.go +++ b/cmd/kosli/attestPRGithub.go @@ -143,7 +143,7 @@ func newAttestGithubPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.retriever = ghUtils.NewGithubRetrieverFunc(githubFlagsValues.Token, githubFlagsValues.BaseURL, - githubFlagsValues.Org, o.repoName) + githubFlagsValues.Org, o.repoName, global.Debug) return o.run(args) }, } diff --git a/cmd/kosli/attestPRGithub_test.go b/cmd/kosli/attestPRGithub_test.go index 0e899c4a0..c888a35f1 100644 --- a/cmd/kosli/attestPRGithub_test.go +++ b/cmd/kosli/attestPRGithub_test.go @@ -33,7 +33,7 @@ func (suite *AttestGithubPRCommandTestSuite) SetupTest() { suite.commitWithNoPR, err = gv.ResolveRevision("HEAD~1") require.NoError(suite.T(), err) - ghUtils.NewGithubRetrieverFunc = func(token, baseURL, org, repository string) types.PRRetriever { + ghUtils.NewGithubRetrieverFunc = func(token, baseURL, org, repository string, debug bool) types.PRRetriever { return &ghUtils.FakeGitHubClient{ PRsByCommit: map[string][]*types.PREvidence{ suite.commitWithPR: {{URL: "https://github.com/kosli-dev/cli/pull/1", State: "MERGED"}}, diff --git a/internal/github/github.go b/internal/github/github.go index ea2e5176e..db059923c 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -1,10 +1,13 @@ package github import ( + "bytes" "context" "fmt" "io" + "net/http" "net/url" + "os" "strings" "time" @@ -21,6 +24,7 @@ type GithubConfig struct { BaseURL string Org string Repository string + Debug bool // Sleep is called between retries in PREvidenceByPRNumber. Defaults to // time.Sleep when nil. Override in tests to avoid real delays. Sleep func(time.Duration) @@ -34,7 +38,7 @@ type GithubFlagsTempValueHolder struct { } // NewGithubConfig returns a new GithubConfig -func NewGithubConfig(token, baseURL, org, repository string) *GithubConfig { +func NewGithubConfig(token, baseURL, org, repository string, debug bool) *GithubConfig { return &GithubConfig{ Token: token, BaseURL: baseURL, @@ -42,6 +46,7 @@ func NewGithubConfig(token, baseURL, org, repository string) *GithubConfig { // repository name must be extracted if a user is using default value from ${GITHUB_REPOSITORY} // because the value is in the format of "org/repository" Repository: extractRepoName(repository), + Debug: debug, } } @@ -52,12 +57,21 @@ func extractRepoName(fullRepositoryName string) string { return repository } -// NewGithubClientFromToken returns Github client with a token and context -func NewGithubClientFromToken(ctx context.Context, ghToken string, baseURL string) (*gh.Client, error) { +// NewGithubClientFromToken returns Github client with a token and context. +// When debug is true the underlying transport is wrapped so every HTTP +// request and response (REST and GraphQL) is dumped to stderr. +func NewGithubClientFromToken(ctx context.Context, ghToken string, baseURL string, debug bool) (*gh.Client, error) { ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: ghToken}, ) tc := oauth2.NewClient(ctx, ts) + if debug { + base := tc.Transport + if base == nil { + base = http.DefaultTransport + } + tc.Transport = &debugTransport{base: base, out: os.Stderr} + } if baseURL != "" { client, err := gh.NewEnterpriseClient(baseURL, baseURL, tc) if err != nil { @@ -68,6 +82,82 @@ func NewGithubClientFromToken(ctx context.Context, ghToken string, baseURL strin return gh.NewClient(tc), nil } +// debugTransport is an http.RoundTripper that logs each request and +// response to its writer. Used when --debug is enabled to make GitHub +// auth/permission failures debuggable. +type debugTransport struct { + base http.RoundTripper + out io.Writer +} + +func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { + fmt.Fprintf(d.out, "[debug-github] --> %s %s\n", req.Method, req.URL) + for k, vs := range req.Header { + for _, v := range vs { + if strings.EqualFold(k, "Authorization") { + v = redactAuthHeader(v) + } + fmt.Fprintf(d.out, "[debug-github] %s: %s\n", k, v) + } + } + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + fmt.Fprintf(d.out, "[debug-github] \n", err) + } else { + if len(bodyBytes) > 0 { + fmt.Fprintf(d.out, "[debug-github] body: %s\n", string(bodyBytes)) + } + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + } + + resp, err := d.base.RoundTrip(req) + if err != nil { + fmt.Fprintf(d.out, "[debug-github] <-- transport error: %v\n", err) + return resp, err + } + fmt.Fprintf(d.out, "[debug-github] <-- %d %s (%s %s)\n", resp.StatusCode, resp.Status, req.Method, req.URL) + for k, vs := range resp.Header { + for _, v := range vs { + fmt.Fprintf(d.out, "[debug-github] %s: %s\n", k, v) + } + } + if resp.Body != nil { + respBytes, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + fmt.Fprintf(d.out, "[debug-github] \n", err) + resp.Body = io.NopCloser(bytes.NewReader(nil)) + } else { + if len(respBytes) > 0 { + fmt.Fprintf(d.out, "[debug-github] body: %s\n", string(respBytes)) + } + resp.Body = io.NopCloser(bytes.NewReader(respBytes)) + } + } + return resp, nil +} + +// redactAuthHeader hides all but the last 4 chars of the credential so +// debug output is safe to paste into bug reports while still letting the +// user spot truncation/whitespace issues. +func redactAuthHeader(v string) string { + parts := strings.SplitN(v, " ", 2) + if len(parts) != 2 { + if len(v) <= 4 { + return "***" + } + return "***" + v[len(v)-4:] + } + tok := parts[1] + if len(tok) <= 4 { + return parts[0] + " ***" + } + return parts[0] + " ***" + tok[len(tok)-4:] +} + func graphqlEndpoint(baseURL string) string { if baseURL == "" || baseURL == "https://api.github.com" { return "https://api.github.com/graphql" @@ -84,8 +174,8 @@ func (c *GithubConfig) ProviderAndLabel() (string, string) { // parameters. It can be replaced in tests to inject a FakeGitHubClient. var NewGithubRetrieverFunc = defaultNewGithubRetriever -func defaultNewGithubRetriever(token, baseURL, org, repository string) types.PRRetriever { - return NewGithubConfig(token, baseURL, org, repository) +func defaultNewGithubRetriever(token, baseURL, org, repository string, debug bool) types.PRRetriever { + return NewGithubConfig(token, baseURL, org, repository, debug) } // ResetGithubRetrieverFunc restores NewGithubRetrieverFunc to its default. @@ -226,7 +316,7 @@ func buildPREvidence( func (c *GithubConfig) PREvidenceByPRNumber(prNumber int) (*types.PREvidence, error) { ctx := context.Background() - ghClient, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL) + ghClient, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL, c.Debug) if err != nil { return nil, err } @@ -313,7 +403,7 @@ func (c *GithubConfig) PREvidenceForCommitV2(commit string) ([]*types.PREvidence ctx := context.Background() pullRequestsEvidence := []*types.PREvidence{} - ghClient, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL) + ghClient, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL, c.Debug) if err != nil { return pullRequestsEvidence, err } @@ -423,7 +513,7 @@ func (c *GithubConfig) PREvidenceForCommitV1(commit string) ([]*types.PREvidence // PullRequestsForCommit returns a list of pull requests for a specific commit func (c *GithubConfig) PullRequestsForCommit(commit string) ([]*gh.PullRequest, error) { ctx := context.Background() - client, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL) + client, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL, c.Debug) if err != nil { return []*gh.PullRequest{}, err } @@ -437,7 +527,7 @@ func (c *GithubConfig) PullRequestsForCommit(commit string) ([]*gh.PullRequest, func (c *GithubConfig) GetPullRequestApprovers(number int) ([]string, error) { approvers := []string{} ctx := context.Background() - client, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL) + client, err := NewGithubClientFromToken(ctx, c.Token, c.BaseURL, c.Debug) if err != nil { return approvers, err } diff --git a/internal/github/github_contract_test.go b/internal/github/github_contract_test.go index cf9534f67..0169229f9 100644 --- a/internal/github/github_contract_test.go +++ b/internal/github/github_contract_test.go @@ -175,6 +175,7 @@ func TestGitHubContract_RealGitHub(t *testing.T) { "", "kosli-dev", "cli", + false, ) // commitUnknown is a validly-formatted SHA that does not exist in kosli-dev/cli. @@ -212,6 +213,7 @@ func TestGitHubContract_RealGitHub_PRByNumber(t *testing.T) { "", "kosli-dev", "cli", + false, ) runPRByNumberContractTests(t, config, testHelpers.GithubPRNumber()) diff --git a/internal/github/github_test.go b/internal/github/github_test.go index 8e9bb9d0c..e6a7280cf 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -35,7 +35,7 @@ func (suite *GithubTestSuite) TestNewGithubClientFromToken() { }, } { suite.Run(t.name, func() { - client, err := NewGithubClientFromToken(context.Background(), t.token, t.baseURL) + client, err := NewGithubClientFromToken(context.Background(), t.token, t.baseURL, false) require.NoErrorf(suite.T(), err, "was NOT expecting error but got: %s", err) require.NotNilf(suite.T(), client, "client should not be nil") }) From 9b9bcc8220c98257787f34aa03630df01839a1dd Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 14:13:56 +0200 Subject: [PATCH 02/10] fix(github): silence errcheck on debug Fprintf calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI golangci-lint flagged the Fprintf calls in the debug RoundTripper because their error returns weren't checked. Wrap them in a small logf helper that explicitly discards the error — write failures to a debug stderr stream are not actionable. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/github/github.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/github/github.go b/internal/github/github.go index db059923c..6d495ada7 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -90,24 +90,30 @@ type debugTransport struct { out io.Writer } +// logf writes debug output and intentionally ignores write errors — +// failures writing to stderr are not actionable from a debug transport. +func (d *debugTransport) logf(format string, args ...any) { + _, _ = fmt.Fprintf(d.out, format, args...) +} + func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { - fmt.Fprintf(d.out, "[debug-github] --> %s %s\n", req.Method, req.URL) + d.logf("[debug-github] --> %s %s\n", req.Method, req.URL) for k, vs := range req.Header { for _, v := range vs { if strings.EqualFold(k, "Authorization") { v = redactAuthHeader(v) } - fmt.Fprintf(d.out, "[debug-github] %s: %s\n", k, v) + d.logf("[debug-github] %s: %s\n", k, v) } } if req.Body != nil { bodyBytes, err := io.ReadAll(req.Body) _ = req.Body.Close() if err != nil { - fmt.Fprintf(d.out, "[debug-github] \n", err) + d.logf("[debug-github] \n", err) } else { if len(bodyBytes) > 0 { - fmt.Fprintf(d.out, "[debug-github] body: %s\n", string(bodyBytes)) + d.logf("[debug-github] body: %s\n", string(bodyBytes)) } req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) } @@ -115,24 +121,24 @@ func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := d.base.RoundTrip(req) if err != nil { - fmt.Fprintf(d.out, "[debug-github] <-- transport error: %v\n", err) + d.logf("[debug-github] <-- transport error: %v\n", err) return resp, err } - fmt.Fprintf(d.out, "[debug-github] <-- %d %s (%s %s)\n", resp.StatusCode, resp.Status, req.Method, req.URL) + d.logf("[debug-github] <-- %d %s (%s %s)\n", resp.StatusCode, resp.Status, req.Method, req.URL) for k, vs := range resp.Header { for _, v := range vs { - fmt.Fprintf(d.out, "[debug-github] %s: %s\n", k, v) + d.logf("[debug-github] %s: %s\n", k, v) } } if resp.Body != nil { respBytes, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { - fmt.Fprintf(d.out, "[debug-github] \n", err) + d.logf("[debug-github] \n", err) resp.Body = io.NopCloser(bytes.NewReader(nil)) } else { if len(respBytes) > 0 { - fmt.Fprintf(d.out, "[debug-github] body: %s\n", string(respBytes)) + d.logf("[debug-github] body: %s\n", string(respBytes)) } resp.Body = io.NopCloser(bytes.NewReader(respBytes)) } From a8d2e1963b61f5d8635be7293ea938be521c9d05 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 14:55:17 +0200 Subject: [PATCH 03/10] ci: fix Debug build trigger to match this branch name Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index 538e0f279..0cbc3d83a 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -3,7 +3,7 @@ name: Debug build on: push: branches: - - 4986-google-cloud-run-1 + - githbub-extra-debug workflow_dispatch: jobs: From e396a7a0cbe7ae6585f62178221798f30498d2db Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 15:00:11 +0200 Subject: [PATCH 04/10] ci: debug-build runs on any branch and produces all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger on every push (and workflow_dispatch). Cross-compile for the five platforms GoReleaser produces (linux/darwin/windows × amd64/arm64 where applicable) and bundle them into a single kosli-debug artifact. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index 0cbc3d83a..bb3f9c510 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -2,8 +2,6 @@ name: Debug build on: push: - branches: - - githbub-extra-debug workflow_dispatch: jobs: @@ -17,11 +15,19 @@ jobs: go-version-file: '.go-version' check-latest: true - - run: make build + - name: Build all platforms + run: | + mkdir -p out + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + os="${target%/*}"; arch="${target#*/}" + ext=""; [ "$os" = "windows" ] && ext=".exe" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch \ + go build -o "out/kosli-${os}-${arch}${ext}" ./cmd/kosli/ + done - uses: actions/upload-artifact@v7 with: name: kosli-debug - path: kosli + path: out/ if-no-files-found: error retention-days: 14 From 6bbcbeb9330c464171b0b219cbf81119b58becb7 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 15:15:47 +0200 Subject: [PATCH 05/10] ci: split debug-build into one artifact per platform Use a matrix so each platform builds in its own job and uploads a separate kosli-debug-- artifact, instead of one large zip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index bb3f9c510..5d43f865f 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -7,6 +7,15 @@ on: jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { os: linux, arch: amd64 } + - { os: linux, arch: arm64 } + - { os: darwin, arch: amd64 } + - { os: darwin, arch: arm64 } + - { os: windows, arch: amd64, ext: .exe } steps: - uses: actions/checkout@v6 @@ -15,19 +24,16 @@ jobs: go-version-file: '.go-version' check-latest: true - - name: Build all platforms - run: | - mkdir -p out - for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do - os="${target%/*}"; arch="${target#*/}" - ext=""; [ "$os" = "windows" ] && ext=".exe" - CGO_ENABLED=0 GOOS=$os GOARCH=$arch \ - go build -o "out/kosli-${os}-${arch}${ext}" ./cmd/kosli/ - done + - name: Build kosli-${{ matrix.os }}-${{ matrix.arch }} + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + CGO_ENABLED: 0 + run: go build -o "kosli-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}" ./cmd/kosli/ - uses: actions/upload-artifact@v7 with: - name: kosli-debug - path: out/ + name: kosli-debug-${{ matrix.os }}-${{ matrix.arch }} + path: kosli-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} if-no-files-found: error retention-days: 14 From 548f578d78d2f8b4acf80fd4181d76658ea3ba7c Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 15:17:57 +0200 Subject: [PATCH 06/10] ci: write each artifact URL to the run summary upload-artifact@v4+ returns an artifact-url output. Append a markdown link for each platform's artifact to $GITHUB_STEP_SUMMARY so the run page shows direct download links instead of having to dig into the Artifacts panel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index 5d43f865f..9e441a48e 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -31,9 +31,14 @@ jobs: CGO_ENABLED: 0 run: go build -o "kosli-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}" ./cmd/kosli/ - - uses: actions/upload-artifact@v7 + - id: upload + uses: actions/upload-artifact@v7 with: name: kosli-debug-${{ matrix.os }}-${{ matrix.arch }} path: kosli-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} if-no-files-found: error retention-days: 14 + + - name: Link artifact in run summary + run: | + echo "- [kosli-debug-${{ matrix.os }}-${{ matrix.arch }}](${{ steps.upload.outputs.artifact-url }})" >> "$GITHUB_STEP_SUMMARY" From a9236eca0e05b68e820bd3cac3f9ff51c51d1b78 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 15:20:20 +0200 Subject: [PATCH 07/10] ci: also expose nightly.link URLs in debug-build summary Add a second public-download link alongside each GitHub artifact link. nightly.link is an OAuth proxy that lets anyone download a public repo's Actions artifacts without signing in to GitHub. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index 9e441a48e..a0b6dbedb 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -41,4 +41,7 @@ jobs: - name: Link artifact in run summary run: | - echo "- [kosli-debug-${{ matrix.os }}-${{ matrix.arch }}](${{ steps.upload.outputs.artifact-url }})" >> "$GITHUB_STEP_SUMMARY" + NAME="kosli-debug-${{ matrix.os }}-${{ matrix.arch }}" + GH_URL="${{ steps.upload.outputs.artifact-url }}" + NIGHTLY_URL="https://nightly.link/${{ github.repository }}/actions/artifacts/${{ steps.upload.outputs.artifact-id }}.zip" + echo "- ${NAME}: [GitHub (sign-in)](${GH_URL}) · [nightly.link (public)](${NIGHTLY_URL})" >> "$GITHUB_STEP_SUMMARY" From 0656b87dd271ff2485a1271d38cf83c9c9fed576 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 15:35:53 +0200 Subject: [PATCH 08/10] debug(github): redact Cookie/Set-Cookie/Proxy-Authorization too The debug RoundTripper was only redacting Authorization. Add Cookie, Set-Cookie, and Proxy-Authorization to the redaction list so a corporate proxy password (Proxy-Authorization is commonly Basic in HTTPS_PROXY setups) doesn't leak into shared debug logs. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/github/github.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/github/github.go b/internal/github/github.go index 6d495ada7..3719fbd64 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -100,10 +100,7 @@ func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { d.logf("[debug-github] --> %s %s\n", req.Method, req.URL) for k, vs := range req.Header { for _, v := range vs { - if strings.EqualFold(k, "Authorization") { - v = redactAuthHeader(v) - } - d.logf("[debug-github] %s: %s\n", k, v) + d.logf("[debug-github] %s: %s\n", k, redactSensitiveHeader(k, v)) } } if req.Body != nil { @@ -127,7 +124,7 @@ func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { d.logf("[debug-github] <-- %d %s (%s %s)\n", resp.StatusCode, resp.Status, req.Method, req.URL) for k, vs := range resp.Header { for _, v := range vs { - d.logf("[debug-github] %s: %s\n", k, v) + d.logf("[debug-github] %s: %s\n", k, redactSensitiveHeader(k, v)) } } if resp.Body != nil { @@ -146,6 +143,21 @@ func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { return resp, nil } +// redactSensitiveHeader hides credentials in headers that commonly carry +// them so debug output is safe to paste into bug reports. Authorization +// keeps the last 4 chars of the token so the user can spot truncation +// or whitespace issues; cookie/proxy-auth values are fully replaced. +func redactSensitiveHeader(name, value string) string { + switch strings.ToLower(name) { + case "authorization": + return redactAuthHeader(value) + case "cookie", "set-cookie", "proxy-authorization": + return "***" + default: + return value + } +} + // redactAuthHeader hides all but the last 4 chars of the credential so // debug output is safe to paste into bug reports while still letting the // user spot truncation/whitespace issues. From eaab5417423b147b8ad2b2f3a9cfd783b64d3c29 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 15:36:42 +0200 Subject: [PATCH 09/10] ci: remove temporary debug-build workflow This workflow was added to produce ad-hoc kosli-debug binaries while debugging the GitHub auth issue. The debug logging itself remains in the github transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/debug-build.yml | 47 ------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/debug-build.yml diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml deleted file mode 100644 index a0b6dbedb..000000000 --- a/.github/workflows/debug-build.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Debug build - -on: - push: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - { os: linux, arch: amd64 } - - { os: linux, arch: arm64 } - - { os: darwin, arch: amd64 } - - { os: darwin, arch: arm64 } - - { os: windows, arch: amd64, ext: .exe } - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: '.go-version' - check-latest: true - - - name: Build kosli-${{ matrix.os }}-${{ matrix.arch }} - env: - GOOS: ${{ matrix.os }} - GOARCH: ${{ matrix.arch }} - CGO_ENABLED: 0 - run: go build -o "kosli-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}" ./cmd/kosli/ - - - id: upload - uses: actions/upload-artifact@v7 - with: - name: kosli-debug-${{ matrix.os }}-${{ matrix.arch }} - path: kosli-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} - if-no-files-found: error - retention-days: 14 - - - name: Link artifact in run summary - run: | - NAME="kosli-debug-${{ matrix.os }}-${{ matrix.arch }}" - GH_URL="${{ steps.upload.outputs.artifact-url }}" - NIGHTLY_URL="https://nightly.link/${{ github.repository }}/actions/artifacts/${{ steps.upload.outputs.artifact-id }}.zip" - echo "- ${NAME}: [GitHub (sign-in)](${GH_URL}) · [nightly.link (public)](${NIGHTLY_URL})" >> "$GITHUB_STEP_SUMMARY" From ff4cef43277aa5bd000601aafb770c1595b0fa51 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Thu, 30 Apr 2026 16:01:12 +0200 Subject: [PATCH 10/10] test(github): cover redactAuthHeader and redactSensitiveHeader These are pure security-relevant helpers in the debug RoundTripper. Add table tests covering the branches: scheme + long/short/4-char token, no-scheme long/short/empty values, the case-insensitive Authorization match, the fully-redacted families (Cookie, Set-Cookie, Proxy-Authorization), pass-through for non-sensitive headers, and explicit assertion that X-OAuth-Scopes is intentionally not redacted (useful for diagnosing GitHub permission failures). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/github/github_test.go | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/internal/github/github_test.go b/internal/github/github_test.go index e6a7280cf..3223b1781 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -129,3 +129,102 @@ func TestPREvidenceByPRNumber_ReturnsErrorAfterAllRetriesExhausted(t *testing.T) require.Error(t, err) require.Equal(t, 4, *attempts, "should have made 1 initial attempt + 3 retries") } + +func TestRedactAuthHeader(t *testing.T) { + for _, tc := range []struct { + name string + in string + want string + }{ + { + name: "scheme + long token keeps last 4", + in: "Bearer ghp_abcdef1234567890ABCD", + want: "Bearer ***ABCD", + }, + { + name: "scheme + short token is fully redacted", + in: "token ab", + want: "token ***", + }, + { + name: "scheme + 4-char token is fully redacted", + in: "Bearer abcd", + want: "Bearer ***", + }, + { + name: "no scheme, long value keeps last 4", + in: "nospaceshort", + want: "***hort", + }, + { + name: "no scheme, short value is fully redacted", + in: "abc", + want: "***", + }, + { + name: "empty value is fully redacted", + in: "", + want: "***", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, redactAuthHeader(tc.in)) + }) + } +} + +func TestRedactSensitiveHeader(t *testing.T) { + for _, tc := range []struct { + name string + headerName string + value string + want string + }{ + { + name: "authorization is redacted via redactAuthHeader", + headerName: "Authorization", + value: "Bearer ghp_abcdef1234567890ABCD", + want: "Bearer ***ABCD", + }, + { + name: "authorization match is case-insensitive", + headerName: "AUTHORIZATION", + value: "Bearer ghp_abcdef1234567890ABCD", + want: "Bearer ***ABCD", + }, + { + name: "cookie is fully redacted", + headerName: "Cookie", + value: "session=abc; csrf=xyz", + want: "***", + }, + { + name: "set-cookie is fully redacted", + headerName: "Set-Cookie", + value: "session=abc; Path=/; HttpOnly", + want: "***", + }, + { + name: "proxy-authorization is fully redacted", + headerName: "Proxy-Authorization", + value: "Basic dXNlcjpwYXNz", + want: "***", + }, + { + name: "non-sensitive header is returned unchanged", + headerName: "Content-Type", + value: "application/json", + want: "application/json", + }, + { + name: "x-oauth-scopes is intentionally not redacted", + headerName: "X-OAuth-Scopes", + value: "repo, read:org", + want: "repo, read:org", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, redactSensitiveHeader(tc.headerName, tc.value)) + }) + } +}