Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/operate/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Every API call the action makes is traced as a debug line: method, path, request
What the trace never shows:

- **The token.** The authorization header is not part of the trace line, and a token stored as a repository secret is masked by the runner wherever it appears in output.
- **A redacted private target's detail.** For redacted private targets in multi-repo mode, the traced path collapses to `<redacted>` and the payload is dropped entirely.
- **A redacted private target's detail.** For a redacted private target, whether a single-repository, multi-repo, or snapshot run reaches it, the traced path collapses to `<redacted>` and the payload is dropped entirely.

Failures do not need debug mode; every error already carries the API's message and the fix, so reach for the trace when you need to see the requests that succeeded.

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ console.log(parsed.success, SECTION_KEYS.length, schema.$schema);
| `DEFAULT_API_VERSION` | const | The `X-GitHub-Api-Version` the action pins |
| `ApiError` | type | A failed request as the port returns it: `status`, `message`, `body` |
| `GraphqlOp` | type | A GraphQL operation as the port takes it |
| `RequestMark` | type | The per-request options; `carriesSecret` marks a payload holding a resolved secret |
| `ListOptions` | type | What `tryList` takes beside the path: `perPage` (the page size, `PAGE_SIZE` when omitted) and `until` (ends the walk after the first page it accepts) |
| `RequestMark` | type | The per-request options; `carriesSecret` marks a payload holding a resolved secret, `redactTrace` a request serving a redacted target, whose route and payload stay out of the trace |
| `ListOptions` | type | What `tryList` takes beside the path: `perPage` (the page size, `PAGE_SIZE` when omitted), `until` (ends the walk after the first page it accepts), and `redactTrace` (as on `RequestMark`) |
| `PAGE_SIZE` | const | The page size a list walk asks for unless the endpoint caps lower: 100, GitHub's maximum |
| `classifyApiError` | function | Which `ApiErrorKind` an `ApiError` is, the one judgment the two predicates below read |
| `ApiErrorKind` | type | `"rate-limit"`, `"permission"` (any other 403, and every 404: a fine-grained token answers 404 for a resource it denies, so an absent one lands here too), or `"other"` |
Expand Down
3 changes: 2 additions & 1 deletion src/flows/multi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ async function processTarget(ctx: {
injectMarker: boolean;
channel: TargetChannel;
}): Promise<TargetResult> {
const { api, target, defaults, cfg, injectMarker, channel } = ctx;
const { target, defaults, cfg, injectMarker, channel } = ctx;
const api = channel.client(ctx.api);
const fail = (richMessage: string): TargetResult => targetFailure(channel.io, richMessage);

// Injection needs the typed labels, so it follows validation; the injected document is validated again inside.
Expand Down
6 changes: 6 additions & 0 deletions src/flows/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { Target } from "../discovery/targets.js";
import type { RunOutcome } from "../engine/outcome.js";
import { type GitHubClient, redactedClient } from "../github/api.js";
import type { RepoVisibility } from "../github/repo-visibility.js";
import { type SlugKey, slugKey } from "../github/slug.js";
import { type CollectedLine, type Io, prefixedIo } from "../io.js";
Expand Down Expand Up @@ -243,6 +244,9 @@ export interface TargetChannel {
/** Sink for lines that already name their source (validation warnings): unprefixed, or the same capture. */
unprefixed: Io;

/** The client the target's requests go through: `api` itself in the clear, its redactedClient() view when redacted. */
client(api: GitHubClient): GitHubClient;

/** Seals or opens the target's end state: everything in its detail but the slug, which the channel holds. */
close(end: Omit<TargetDetail, "slug">): TargetOutcome["detail"];
}
Expand All @@ -252,6 +256,7 @@ export function publicChannel(io: Io, slug: string, attributed: boolean): Target
display: slug,
io: prefixedIo(io, attributed ? `${slug}: ` : ""),
unprefixed: io,
client: (api) => api,
close: ({ outcomes, note, file }) => ({ slug, outcomes, note, file }),
};
}
Expand All @@ -262,6 +267,7 @@ export function redactedChannel(io: Io, slug: string, display: string): TargetCh
display,
io: capture.io,
unprefixed: capture.io,
client: redactedClient,
close: ({ outcomes, note, file }) =>
markPrivate({ slug, outcomes, note, file, transcript: capture.drain() }),
};
Expand Down
2 changes: 1 addition & 1 deletion src/flows/single.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ async function runTarget(
async () =>
engineOutcome(
await runForRepo(
api,
channel.client(api),
{
repo: cfg.repo,
settings: injected.settings,
Expand Down
4 changes: 2 additions & 2 deletions src/flows/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ async function snapshotFile(
channel,
() =>
snapshotTarget({
api,
api: channel.client(api),
repo: cfg.repo,
cfg,
path: cfg.snapshotFile,
Expand Down Expand Up @@ -333,7 +333,7 @@ async function snapshotDir(
channel,
() =>
snapshotTarget({
api,
api: channel.client(api),
repo: target.repo,
cfg,
path: located.path,
Expand Down
127 changes: 83 additions & 44 deletions src/github/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* The GitHubClient port and its one implementation, GitHubApi, on @octokit/core with the request-log, retry,
* throttling, and paginate-rest plugins; what a failed request becomes is api-error.ts's, and what a trace line may
* show is trace-redaction.ts's. A payload's JSON body is its own serialization (redactSecretPayloadSafe), never an
* endpoint typing that could drop an unknown field.
* The GitHubClient port, its view for a redacted target (redactedClient), and its one implementation, GitHubApi, on
* @octokit/core with the request-log, retry, throttling, and paginate-rest plugins; what a failed request becomes is
* api-error.ts's, and what a trace line may show is trace-redaction.ts's. A payload's JSON body is its own
* serialization (redactSecretPayloadSafe), never an endpoint typing that could drop an unknown field.
*/

import { Octokit } from "@octokit/core";
Expand Down Expand Up @@ -35,12 +35,7 @@
throttleGroups,
} from "./scheduler.js";
import { redactSecretPayloadSafe } from "./secret-scan.js";
import {
redactingOctokitLog,
repoSlugOf,
type TraceIo,
TraceRedaction,
} from "./trace-redaction.js";
import { redactingOctokitLog, type TraceIo, TraceRedaction } from "./trace-redaction.js";

/** The single source for the header default here, the action.yml `api-version` default, and the inputs fallback; the action-yml contract test asserts the three stay equal. */
export const DEFAULT_API_VERSION = "2022-11-28";
Expand All @@ -55,14 +50,18 @@
readonly query: string;
}

/**
* `carriesSecret` marks a request whose payload or variables hold a resolved secret. The engine sets it from the act of
* resolving (engine/execute.ts) and withholds the request's error on its own side of this port whatever the client
* answers (sections/contract/requests.ts), so a caller-supplied client cannot leak an echoed value into an outcome or
* a report; GitHubApi honors the mark too, beside its field-name scan, for its direct callers.
*/
/** What a caller knows about a request that the client cannot read off it. */
export interface RequestMark {
/**

Check warning on line 55 in src/github/api.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

src/github/api.ts:55: one-line-comment
* The payload or variables hold a resolved secret. The engine sets it from the act of resolving (engine/execute.ts)
* and withholds the request's error on its own side of this port whatever the client answers
* (sections/contract/requests.ts), so a caller-supplied client cannot leak an echoed value into an outcome or a
* report; GitHubApi honors the mark too, beside its field-name scan, for its direct callers.
*/
carriesSecret?: boolean;

/** The request serves a redacted target, so its route and payload stay out of the trace; redactedClient() sets it. */
redactTrace?: boolean;
}

/**
Expand All @@ -76,7 +75,7 @@
/** The page size every list walk asks GitHub for, GitHub's documented maximum, unless the endpoint caps lower. */
export const PAGE_SIZE = 100;

export interface ListOptions {
export interface ListOptions extends Pick<RequestMark, "redactTrace"> {
/** Items per page; PAGE_SIZE when omitted. An endpoint with a documented cap below it passes the cap. */
perPage?: number | undefined;

Expand All @@ -85,15 +84,11 @@
}

export interface GitHubClient {
/**
* `redactTrace` holds the request's `/repos/<owner>/<repo>` slug redacted for the request's duration, for the
* visibility probe, which must not leak the slug before it knows whether the repository is private.
*/
tryRequest(
method: string,
path: string,
payload?: unknown,
options?: RequestMark & { accept?: string; raw?: boolean; redactTrace?: boolean },
options?: RequestMark & { accept?: string; raw?: boolean },
): Promise<ClientAnswer<unknown>>;

/**
Expand All @@ -116,7 +111,34 @@
): Promise<ClientAnswer<Record<string, unknown>>>;
}

/** The primary and secondary limits are handled identically but for the log `label`, so one factory keeps them from drifting. */
/** One view per client, so what a section memoizes per client (the owner gate's probe) spans every redacted target of a run. */
const redactedViews = new WeakMap<GitHubClient, GitHubClient>();

/**
* The view of `api` a redacted target's requests go through: each carries `redactTrace`, so none prints its route or
* payload, whatever section sends it and whether or not the route names the repository.
*/
export function redactedClient(api: GitHubClient): GitHubClient {
const known = redactedViews.get(api);
if (known !== undefined) {
return known;
}

const view: GitHubClient = {
tryRequest: (method, path, payload, options) =>
api.tryRequest(method, path, payload, { ...options, redactTrace: true }),
tryList: (path, options) => api.tryList(path, { ...options, redactTrace: true }),
tryGraphql: (op, variables, slug, options) =>
api.tryGraphql(op, variables, slug, { ...options, redactTrace: true }),
};
redactedViews.set(api, view);
return view;
}

/**
* The primary and secondary limits are handled identically but for the log `label`, so one factory keeps them from
* drifting. The line is written where the answer is decided, so it never announces a retry the answer refuses.
*/
function throttleCallback(
label: string,
trace: TraceRedaction,
Expand All @@ -127,10 +149,21 @@
retryCount: number,
) => boolean {
return (retryAfter, options, _octokit, retryCount) => {
trace.debug(
`${label} on ${options.method} ${trace.path(options.url).path}; retry ${retryCount + 1}/${MAX_RETRIES} after ${retryAfter}s`,
);
return retryAfter <= MAX_RETRY_WAIT_S && retryCount < MAX_RETRIES;
const limited = `${label} on ${options.method} ${trace.path(options.url).path}`;
if (retryCount >= MAX_RETRIES) {
trace.debug(`${limited}; giving up: the ${MAX_RETRIES} retries are spent`);
return false;
}

if (retryAfter > MAX_RETRY_WAIT_S) {
trace.debug(
`${limited}; giving up: the wait of ${retryAfter}s is over the ${MAX_RETRY_WAIT_S}s cap`,
);
return false;
}

trace.debug(`${limited}; retry ${retryCount + 1}/${MAX_RETRIES} after ${retryAfter}s`);
return true;
};
}

Expand Down Expand Up @@ -276,25 +309,31 @@
});
}

async tryRequest(
/** The port's three methods share one rule: a request marked `redactTrace` seals the trace until it settles. */
tryRequest(
method: string,
path: string,
payload?: unknown,
options?: RequestMark & { accept?: string; raw?: boolean; redactTrace?: boolean },
options?: RequestMark & { accept?: string; raw?: boolean },
): Promise<ClientAnswer<unknown>> {
if (!options?.redactTrace) {
return this.request(method, path, payload, options);
}
const slug = repoSlugOf(path);
if (slug === undefined) {
throw new Error(`BUG: redactTrace needs a /repos/<owner>/<repo> path, got ${path}`);
}
const release = this.trace.hold(slug);
try {
return await this.request(method, path, payload, options);
} finally {
release();
}
return this.trace.during(options?.redactTrace === true, () =>
this.request(method, path, payload, options),
);
}

tryList(path: string, options?: ListOptions): Promise<ClientAnswer<unknown[]>> {
return this.trace.during(options?.redactTrace === true, () => this.list(path, options));
}

tryGraphql(
op: GraphqlOp,
variables: Readonly<Record<string, unknown>>,
slug: string,
options?: RequestMark,
): Promise<ClientAnswer<Record<string, unknown>>> {
return this.trace.during(options?.redactTrace === true, () =>
this.graphql(op, variables, slug, options),
);
}

/**
Expand All @@ -305,7 +344,7 @@
* is noted before the plugin reads it as an empty list (GitHub's answer for an empty repository) and surfaces as the
* error it is.
*/
async tryList(path: string, options?: ListOptions): Promise<ClientAnswer<unknown[]>> {
private async list(path: string, options?: ListOptions): Promise<ClientAnswer<unknown[]>> {
const pages: unknown[] = [];
let conflict: OctokitHttpError | undefined;
let current = path;
Expand Down Expand Up @@ -495,7 +534,7 @@
* any errors[] entry, even beside partial data -> { error }, so a section never acts on a half-answered query
* `extensions.warnings` (legacy node-ID notices) -> the debug trace only
*/
async tryGraphql(
private async graphql(
op: GraphqlOp,
variables: Readonly<Record<string, unknown>>,
slug: string,
Expand Down
9 changes: 7 additions & 2 deletions src/github/repo-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
* -> that branch's git ref is read, which needs Contents: read and succeeds whether or not the file exists
* -> the token could have read the file, so it is missing
*
* A failed ref read leaves the proof inconclusive: a denied grant, or an empty repository whose branch has no commit.
* A refused ref read leaves the proof inconclusive: a denied grant (403, 404), or an empty repository (409, no commit yet).
*/

import type { GitHubClient } from "./api.js";
import { type ApiError, classifyApiError } from "./api-error.js";

/** The status of a ref read on a repository with no commit. */
const EMPTY_REPOSITORY = 409;

export async function getRepoFile(
api: GitHubClient,
slug: string,
Expand Down Expand Up @@ -64,7 +67,9 @@ export async function getRepoFile(
if (!("error" in refProbe)) {
return { missing: true };
}
if (classifyApiError(refProbe.error) !== "permission") {
const inconclusive =
refProbe.error.status === EMPTY_REPOSITORY || classifyApiError(refProbe.error) === "permission";
if (!inconclusive) {
return { error: refProbe.error };
}
return {
Expand Down
2 changes: 1 addition & 1 deletion src/github/repo-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function createVisibilityResolver(

async function probe(api: GitHubClient, slug: string): Promise<RepoVisibility> {
// The probe decides redaction, so its own trace (and any throttle-callback trace) must fail closed before the answer
// is known: redactTrace holds the slug redacted for the request's duration.
// is known: the redactTrace mark seals the trace until the request settles.
const result = await api.tryRequest("GET", `/repos/${slug}`, undefined, { redactTrace: true });
if ("failed" in result || "error" in result) {
return "unknown";
Expand Down
Loading
Loading