Skip to content

fix(github): hide every request of a redacted target, and read an empty repository as unproven - #589

Merged
Vivswan merged 2 commits into
mainfrom
fix/github-redacted-trace
Nov 12, 2026
Merged

Vivswan merged 2 commits into
mainfrom
fix/github-redacted-trace

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

redacted target -> request -> path names a masked /repos/<owner>/<name>? -> yes: <redacted>
                                                                         -> no:  printed in the clear
$ bun capture-run.ts
::debug::GET <redacted> -> 200 (8ms)
::debug::request
::debug::GET /orgs/secret-org - 200 with id undefined in 4ms
::debug::GET /orgs/secret-org -> 200 (8ms)
...
::debug::GET <redacted> -> 200 (7ms)
::debug::request
::debug::GET /apps/deploy-gate - 200 with id undefined in 3ms
::debug::GET /apps/deploy-gate -> 200 (8ms)
::debug::request
::debug::<redacted>
::debug::PUT <redacted> -> 200 (8ms)
$ bun capture-client.ts
::error::acme/app: reading acme/app:.github/settings.yml failed: 409 Git Repository is empty.. This is not a permission problem; re-run, and retry later if it persists
::debug::rate limit on GET /repos/acme/app; retry 1/2 after 3001s
::debug::rate limit on POST /graphql; retry 1/2 after 0s
::debug::rate limit on POST /graphql; retry 2/2 after 0s
::debug::rate limit on POST /graphql; retry 3/2 after 0s

After

redacted target -> its channel hands the engine the client's redacted view -> every request is marked
                -> a marked request in flight seals the trace -> <redacted>, whatever the route
$ bun capture-run.ts
::debug::GET <redacted> -> 200 (7ms)
::debug::<redacted>
::debug::<redacted>
::debug::GET <redacted> -> 200 (7ms)
...
::debug::GET <redacted> -> 200 (7ms)
::debug::<redacted>
::debug::<redacted>
::debug::GET <redacted> -> 200 (7ms)
::debug::<redacted>
::debug::<redacted>
::debug::PUT <redacted> -> 200 (8ms)
$ bun capture-client.ts
::error::acme/app: cannot prove .github/settings.yml is absent: reading the default branch ref heads/main returned 409. Grant the token Contents: read on this repository, or initialize its default branch; a repository whose file cannot be read never receives the defaults. To stop managing it instead, remove acme/app from the "repos" input
::debug::rate limit on GET /repos/acme/app; giving up: the wait of 3001s is over the 60s cap
::debug::rate limit on POST /graphql; retry 1/2 after 0s
::debug::rate limit on POST /graphql; retry 2/2 after 0s
::debug::rate limit on POST /graphql; giving up: the 2 retries are spent

How

  • The mark: RequestMark and ListOptions gain the optional redactTrace, an additive public field.
  • The channel: a redacted target's channel hands the engine a client view that marks every request.
  • The seal: a marked request in flight collapses every trace line, the plugins' included.
  • Cost: a shown and a redacted target under one owner each send the owner probe.
  • Empty repository, throttle: a 409 ref read is inconclusive, and the throttle line is written where the retry is decided.
  • Library: @octokit/plugin-throttling, covers the retry decision, which stays with it.

Proof

  • Flow tests: five runs over the e2e mock server assert a private target's trace holds only redacted forms.
  • Client tests: the mark on six routes, the unproven value for an empty repository, the throttle lines.
  • Re-expected: two tests pinned the slug-keyed hold and its refusal of a slug-free path.
  • Totals: bun test test/github, 138 pass, 0 fail.
Technical details
  • capture-run.ts: the built bundle over the e2e mock, with a public acme/app and a private secret-org/private-repo, in mode: apply.
  • capture-client.ts: runMulti and GitHubApi over a stubbed fetch that answers as GitHub does for an empty repository and for a rate limit.
  • Both scripts are printed at the end of this section and run from the repository root.
  • Where they ran: Before in an export of origin/main, After on this branch.
  • The ... line, Before: 14 lines of the same run: 5 bare request lines of the request-log plugin, 5 <redacted>, 3 GET <redacted> -> <status>, and 1 POST /graphql - 200 with id undefined in 3ms.
  • The ... line, After: the same 14 lines, now 11 <redacted> and 3 GET <redacted> -> <status>.
  • Unchanged beside it: the public target of that run prints its own owner probe, GET /orgs/acme, in both states.
  • Flow tests: test/github/redacted-target-trace.test.ts runs the flows in process against startMockServer(), in check mode and in both snapshot forms.
  • They assert: the mock received GET /orgs/secret-org with no contract violation, and the trace holds only the forms <redacted> and GET <redacted> -> <status>.
  • The mixed-fleet test asserts: the public target's two owner-probe lines print, and no line names secret-org or private-repo.
  • They do not run apply mode or the app lookup.
  • Client tests: test/github/api.test.ts asserts a marked request leaves two <redacted> plugin lines and its own redacted line on six routes, /apps/internal-deploy-bot among them, then prints legibly unmarked.
  • Empty repository: test/github/repo-file.test.ts asserts getRepoFile returns the unproven value for GitHub's 404 then 409, not the run's ::error:: line.
  • Throttle: test/github/api.test.ts asserts the request count and the throttle lines, with the wait's number folded to Ns.
  • Seen failing: under origin/main's src/, the five flow tests fail on the printed probe.
  • Seen failing: with only src/flows from origin/main under this branch's client, the same five fail.
  • The view's own test fails on origin/main for the missing export, and on a view that drops a mark.
  • e2e: bun test/e2e/run.ts --sections teams,custom_properties, 20/20.
  • e2e, by --scenario: multi-private-redact-default, multi-private-drift-redacted, multi-private-probe-denied, multi-private-self-carveout, multi-report-issue-delivery, multi-report-artifact-delivery, multi-report-secret-withheld, snapshot-private-notes-redacted, snapshot-dir-multi-repo, each 1/1.
  • TargetChannel is internal: neither src/index.ts nor src/internal.ts exports it, so client() adds no public API.
  • Call sites that hand a target's client to the engine: src/flows/multi.ts (processTarget, which feeds runForRepo and getRepoFile), src/flows/single.ts (runForRepo), src/flows/snapshot.ts (both snapshotTarget calls, which feed snapshotRepository).
  • Found by: grep -rnE "\b(runForRepo|snapshotRepository|readSnapshot|getRepoFile|processTarget|snapshotTarget)\(" src, less the engine, the sections, and the definitions.
  • Not through the channel: src/flows/library.ts and src/cli/init.ts, which have no redaction policy.
  • Not through the channel: private-report delivery in src/flows/deliver.ts, whose routes all name /repos/<slug>, which the masked slug collapses.
  • Before the decision: the visibility probe marks its own request.
  • The seal is the whole trace's: a line of an unmarked request in flight beside a marked one collapses too.
  • Why that is enough: the flows run targets one at a time.
  • Why one view per client: the owner gate memoizes its probe per client, so a view per target would probe once per redacted target.
  • Redaction has no library: it is this action's own rule.
  • Removed: TraceRedaction.hold(slug), the repoSlugOf export, the BUG: redactTrace needs a /repos/<owner>/<repo> path throw.
  • Re-expected test: redactTrace holds the slug for the request only is now six rows of a request marked as serving a redacted target leaves no route or payload in the trace.
  • Re-expected test: overlapping holds on one slug are independent is now overlapping requests for redacted targets keep the trace sealed until the last one settles.
  • Docs: docs/reference/library.md names redactTrace on the RequestMark and ListOptions rows.
  • Docs: docs/operate/troubleshooting.md says the trace collapse holds in single-repository, multi-repo, and snapshot runs.
  • Files: src/github/{api.ts,trace-redaction.ts,repo-file.ts,repo-visibility.ts}, src/flows/{redact.ts,multi.ts,single.ts,snapshot.ts}, test/github/{api.test.ts,repo-file.test.ts,redacted-target-trace.test.ts,stub.ts}, docs/{reference/library.md,operate/troubleshooting.md}.
// Prints the action's stdout for a two-target run. From the repository root: bun capture-run.ts
const root = process.cwd();
const { runScenario } = await import(`${root}/test/e2e/runner/runner.ts`);
const { parseScenario } = await import(`${root}/test/e2e/scenario.ts`);

const teams = [{ name: "reviewers", permission: "push" }];
const protection = { enforce_admins: true, force_push_bypassers: ["app/deploy-gate"] };
const scenario = parseScenario(
  {
    name: "capture-redacted-trace",
    settings: {},
    inputs: { mode: "apply" },
    repos: {
      "acme/app": { settings: { teams } },
      "secret-org/private-repo": {
        settings: { teams, branches: [{ name: "main", protection }] },
        live_state: { repo: { private: true, visibility: "private" }, branches: ["main"] },
      },
    },
    token_permissions: { administration: "write", contents: "read", org_members: "read" },
    expect: { exit_code: 0 },
  },
  "capture-run.ts",
);
console.log((await runScenario(scenario)).stdout);
// Prints what a run says of an empty repository, then the throttle lines. From the repository root: bun capture-client.ts
const root = process.cwd();
const { DEFAULT_DISCOVERY_FILTERS } = await import(`${root}/src/discovery/discover.ts`);
const { SectionSelection } = await import(`${root}/src/engine/section-selection.ts`);
const { runMulti } = await import(`${root}/src/flows/multi.ts`);
const { silentIo } = await import(`${root}/src/io.ts`);
const { api, stubFetch } = await import(`${root}/test/github/stub.ts`);

const answer = (status: number, body: unknown, headers: Record<string, string> = {}) => () =>
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
const io = {
  ...silentIo(),
  annotate: (level: string, message: string) => console.log(`::${level}::${message}`),
  debug: (line: string) => {
    if (line.includes("rate limit on")) {
      console.log(`::debug::${line}`);
    }
  },
};

// An empty repository as GitHub answers it: the visibility probe, the contents read, the repository, the ref read.
stubFetch([
  answer(200, { private: false, visibility: "public" }),
  answer(404, { message: "This repository is empty." }),
  answer(200, { default_branch: "main" }),
  answer(409, { message: "Git Repository is empty." }),
]);
const fleet = { reposDir: "", reposInput: "acme/app", defaultsFile: "", adminOwner: "acme" };
const filters = { discoveryFilters: DEFAULT_DISCOVERY_FILTERS, discoveryFiltersSet: [] };
const report = { privateRepos: "redact", privateReport: "none", reportPublicKey: "", selfSlug: "acme/admin", runUrl: "" };
const mode = { mode: "check", onMissingPermission: "fail", sections: SectionSelection.ALL };
await runMulti(api(io), { ...mode, ...report, ...fleet, ...filters }, io);

// A primary rate limit whose reset is 3000 s away.
const reset = String(Math.floor(Date.now() / 1000) + 3000);
const limit = { "x-ratelimit-remaining": "0", "x-ratelimit-reset": reset };
stubFetch([answer(403, { message: "API rate limit exceeded for user ID 1." }, limit)]);
await api(io).tryRequest("GET", "/repos/acme/app");

// A GraphQL RATE_LIMITED on every attempt.
stubFetch([answer(200, { data: null, errors: [{ type: "RATE_LIMITED", message: "slow down" }] })]);
const op = { name: "RepoId", kind: "read", query: "query RepoId { viewer { id } }" };
await api(io).tryGraphql(op, {}, "acme/app");

…ty repository as unproven

A redacted target's debug trace printed any request whose path named no repository: the owner probe GET /orgs/<owner> the teams and custom_properties sections send, and the app lookup GET /apps/<slug> the branches section sends. The trace collapsed a path only when it found a masked /repos/<owner>/<name> slug in it.

The run now says which requests serve a redacted target. A redacted target's channel hands the engine the client's redacted view (redactedClient), which marks every request redactTrace, and GitHubApi seals the whole trace while a marked request is in flight, so the request-log plugin's lines collapse with the client's own. The four places a flow hands a target's client to the engine (single, multi, snapshot-file, snapshot-dir) go through the channel. The slug-keyed hold and its refusal of a slug-free path are gone.

An empty repository answers its ref read 409 "Git Repository is empty.", which getRepoFile read as a failure and told the operator to re-run. It is now the inconclusive proof the file already had for a denied grant, which names the uninitialized branch.

The throttle callback printed "retry N/2 after Ns" before deciding, so the line announced a retry for a wait over the 60 s cap and for the attempt after the last retry. It now writes the line where it decides: a retry, or giving up and why.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 01:22
@github-actions

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 6 warning(s).

File Size Tier Cap
src/engine/diff.ts:56 11 comment lines warn 10
src/github/api-error.ts:9 11 comment lines warn 10
src/problem.ts:73 18 comment lines warn 10
src/sections/contract/errors.ts:13 11 comment lines warn 10
src/sections/contract/module.ts:55 12 comment lines warn 10
src/sections/shared/list/decl.ts:265 14 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Member shape check

2 finding(s).

File Line Rule
src/github/api.ts 55 one-line-comment
test/sections/contract/validate-hook.test.ts 80 all-or-none

Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in .member-shape-allow.local with a # reason.

4 generated file(s) skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the trace-redaction and repository-handling changes.

No open issues 🎉

What changed in this PR

Strengthens private-target trace redaction across the action's execution flows and improves handling of empty repositories and rate-limit diagnostics.

Changes:

  • Marks all requests serving redacted targets and suppresses routes and payloads throughout their execution, including plugin logs.
  • Treats an empty repository's ref-read 409 as inconclusive, preventing defaults from being applied without proven file absence.
  • Aligns throttle messages with actual retry decisions and adds regression coverage.
File Description
test/​github/​stub.ts Adds reusable JSON response and trace-timing helpers.
test/​github/​repo-file.test.ts Covers empty repositories as inconclusive file reads.
test/​github/​redacted-target-trace.test.ts Tests private-target traces across all four flow paths.
test/​github/​api.test.ts Covers request marks, overlapping redaction, client views, and retry diagnostics.
src/​github/​trace-redaction.ts Seals traces while marked requests are in flight, retaining persistent masks.
src/​github/​repo-file.ts Classifies ref-read 409 responses as inconclusive.
src/​github/​api.ts Adds cached redacted client views and accurate throttle messages.
src/​flows/​snapshot.ts Uses channel-selected clients for both snapshot forms.
src/​flows/​single.ts Passes the channel-selected client to single-repository execution.
src/​flows/​redact.ts Adds client selection to public and redacted target channels.
src/​flows/​multi.ts Uses the channel-selected client for target reads and execution.

Review effort: Balanced

The flow tests answered GitHub with a hand-written route table. They now run against the e2e mock server (startMockServer), which already serves the visibility probe, the contents read, the owner probe, and the teams list, and records every request. The four flows and the mixed-fleet control still fail on the unfixed flow lines.

The library reference names redactTrace on the RequestMark and ListOptions rows, and the troubleshooting page says the trace collapse holds in single-repository, multi-repo, and snapshot runs.

Comments only in src/: RequestMark documents both of its members, the redacted constant is commented beside its neighbour, and the visibility probe's comment says what the mark does now that the slug-keyed hold is gone.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 01:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues require changes before approval.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 02:16
@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Nov 12, 2026
@Vivswan
Vivswan merged commit 924cafe into main Nov 12, 2026
33 checks passed
@Vivswan
Vivswan deleted the fix/github-redacted-trace branch November 12, 2026 04:41
This was referenced Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants