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
60 changes: 56 additions & 4 deletions actions/setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,30 @@
if (ref) return ref;
}

// 3. Explicit pull_request_number on the sample arguments.
const argNumber = Number(entry.arguments.pull_request_number);
if (Number.isFinite(argNumber) && argNumber > 0) {
const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: argNumber });
// 3. PR number from sample arguments, workflow_dispatch inputs, or config target.
const pullNumber =
toPositivePullRequestNumber(entry.arguments.pull_request_number) ||
toPositivePullRequestNumber(payload?.inputs?.pull_request_number) ||
toPositivePullRequestNumber(payload?.client_payload?.pull_request_number) ||
readConfiguredTargetPullRequestNumber(entry.tool);
if (pullNumber) {
const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

client_payload.pull_request_number is user-controlled and accepted without authorization checks: repository_dispatch events let any actor with write access (or a workflow that forwards external input) set client_payload freely. Accepting it here as a trusted PR number source can steer the replay to a different PR branch than intended.

💡 Details

payload?.client_payload?.pull_request_number is on the same priority level as the explicit sample arguments and workflow_dispatch inputs. Unlike workflow_dispatch.inputs, client_payload is entirely caller-controlled and there is no structural guarantee it comes from a trusted workflow step. If an attacker can send a repository_dispatch, they could redirect sample replay to an arbitrary PR head ref.

Consider documenting the trust boundary clearly, or move the client_payload fallback behind the config-target fallback so it is the last resort — or require an explicit opt-in flag before trusting it.

if (ref) return ref;
}

return null;
}

/**
* Convert unknown value to a positive pull request number, or null.
* @param {any} value
* @returns {number|null}
*/
function toPositivePullRequestNumber(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

toPositivePullRequestNumber accepts non-integer floats: Number("1.5") is 1.5, which passes the isFinite && > 0 guard and gets forwarded as a PR number — an invalid value that will cause the GitHub API call to fail.

💡 Suggested fix
function toPositivePullRequestNumber(value) {
  const n = Number(value);
  return Number.isFinite(n) && Number.isInteger(n) && n > 0 ? n : null;
}

Rejects "1.5", 1.9, etc. before they reach the GitHub API.

}

/**
* Read the configured `target-repo` for a given safe-output tool from the
* safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH). Returns an empty
Expand Down Expand Up @@ -291,6 +305,44 @@
return "";
}

/**
* Read configured `target` for a safe-output tool and coerce it into a PR number.
* Supports plain numeric values and `${ENV_VAR}` placeholders.
* @param {string} tool
* @returns {number|null}
*/
function readConfiguredTargetPullRequestNumber(tool) {
const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
if (!configPath || !configPath.trim()) {
return null;
}

const toolKey = typeof tool === "string" ? tool.replace(/-/g, "_") : "";

try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
const config = parsed && typeof parsed === "object" ? Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])) : {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] Duplicated config-parsing logic — readConfiguredTargetPullRequestNumber repeats the same Object.fromEntries key-normalisation block already in readConfiguredTargetRepo. This dense one-liner is harder to maintain and test in isolation.

💡 Suggested refactor

Extract a shared helper:

function parseNormalisedConfig(configPath) {
  const raw = fs.readFileSync(configPath, "utf8");
  const parsed = JSON.parse(raw);
  if (!parsed || typeof parsed !== "object") return {};
  return Object.fromEntries(
    Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])
  );
}

Both readers call parseNormalisedConfig(configPath) — cuts duplication and makes each function a single-purpose.

@copilot please address this.

const toolConfig = toolKey && config && typeof config === "object" ? config[toolKey] : null;
const target = toolConfig && typeof toolConfig === "object" ? toolConfig.target : null;

if (typeof target === "number") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Config file is re-read and re-parsed on every call: fs.readFileSync + JSON.parse runs synchronously each time readConfiguredTargetPullRequestNumber is called. In a batch run over many entries this will hammer the filesystem for no reason.

💡 Suggested fix

Cache the parsed result at module scope, similar to readConfiguredTargetRepo:

let _configCache = undefined; // undefined = not yet loaded; null = failed/missing

function _loadConfig() {
  if (_configCache !== undefined) return _configCache;
  const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
  if (!configPath?.trim()) return (_configCache = null);
  try {
    const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
    _configCache = parsed && typeof parsed === "object" ? parsed : null;
  } catch {
    _configCache = null;
  }
  return _configCache;
}

Then readConfiguredTargetPullRequestNumber just calls _loadConfig() instead of re-reading the file.

return toPositivePullRequestNumber(target);
}
if (typeof target === "string") {
const trimmed = target.trim();
const envMatch = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed);
if (envMatch) {
return toPositivePullRequestNumber(process.env[envMatch[1]]);
Comment on lines +334 to +336

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] ${ENV_VAR} placeholders that expand to an empty string or non-numeric value silently return null and skip all remaining fallbacks — there is no warning logged. If a CI operator misconfigures the env var they will see a silent no-op with no indication of why the branch derivation failed.

💡 Suggestion

Add a core.debug or core.warning when the env var is set but doesn't resolve to a positive integer:

if (envMatch) {
  const resolved = toPositivePullRequestNumber(process.env[envMatch[1]]);
  if (!resolved) {
    core.debug(`apply_samples: env var ${envMatch[1]} did not resolve to a PR number (value: ${process.env[envMatch[1]]})`);
  }
  return resolved;
}

This makes misconfiguration visible in the Actions debug log without changing behaviour.

@copilot please address this.

}
return toPositivePullRequestNumber(trimmed);
}
} catch (err) {
core.debug(`apply_samples: could not read target from ${configPath}: ${getErrorMessage(err)}`);
}
return null;
}

/**
* Resolve the on-disk working directory in which a sample's patch should be

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Config parse errors are silently swallowed at debug level: a malformed or truncated GH_AW_SAFE_OUTPUTS_CONFIG_PATH file causes readConfiguredTargetPullRequestNumber to return null with only a core.debug log — indistinguishable at runtime from "config not set". Operators will have no idea their config is broken.

💡 Suggested fix

Emit at least core.warning (not core.debug) for JSON.parse failures so the problem surfaces in the action log without blocking execution:

} catch (err) {
  core.warning(`apply_samples: could not read target from ${configPath}: ${getErrorMessage(err)}`);
}

Keep debug for the "file not found" / env-var-not-set path, but use warning when the file exists and is unparseable.

* staged (branch created + patch committed).
Expand Down Expand Up @@ -659,7 +711,7 @@

if (require.main === module) {
main().catch(err => {
core.setFailed(err && err.stack ? err.stack : String(err));

Check warning on line 714 in actions/setup/js/apply_samples.cjs

View workflow job for this annotation

GitHub Actions / lint-js

Prefer getErrorMessage(err) from error_helpers.cjs. The `err.stack` ternary surfaces noisy stack frames; getErrorMessage() returns a clean, consistent message

Check warning on line 714 in actions/setup/js/apply_samples.cjs

View workflow job for this annotation

GitHub Actions / lint-js

Prefer getErrorMessage(err) from error_helpers.cjs. The `err.stack` ternary surfaces noisy stack frames; getErrorMessage() returns a clean, consistent message
});
}

Expand Down
45 changes: 45 additions & 0 deletions actions/setup/js/apply_samples.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,51 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re
}
});

it("derives push_to_pull_request_branch branch from workflow_dispatch inputs.pull_request_number", async () => {
const workspace = makeTempDir("gh-aw-prestage-push-dispatch-input-");
initRepo(workspace, "main");

const headRef = "feat/dispatch-pr-number";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ head: { ref: headRef } }),
});

const eventPath = path.join(workspace, "event.json");
fs.writeFileSync(
eventPath,
JSON.stringify({
inputs: { pull_request_number: "123" },
})
);

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevEvent = process.env.GITHUB_EVENT_PATH;
const prevRepo = process.env.GITHUB_REPOSITORY;
process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
process.env.GITHUB_EVENT_PATH = eventPath;
process.env.GITHUB_REPOSITORY = "owner/repo";
try {
const entry = {
tool: "push_to_pull_request_branch",
arguments: { message: "Push update" },
sidecars: { patch: newFileDiff("dispatch-input.txt", "via workflow_dispatch input\n") },
};
await preStagePatch(entry, 0, workspace);
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/owner/repo/pulls/123"), expect.anything());
} finally {
fetchSpy.mockRestore();
if (prevBase === undefined) delete process.env.GH_AW_CUSTOM_BASE_BRANCH;
else process.env.GH_AW_CUSTOM_BASE_BRANCH = prevBase;
if (prevEvent === undefined) delete process.env.GITHUB_EVENT_PATH;
else process.env.GITHUB_EVENT_PATH = prevEvent;
if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY;
else process.env.GITHUB_REPOSITORY = prevRepo;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The test covers only the happy path (valid numeric string "123"). There are no tests for the new fallback priority order or edge cases in readConfiguredTargetPullRequestNumber.

💡 Missing coverage to consider
  1. Priority order: arguments.pull_request_number should win over inputs.pull_request_number — a test with both present would confirm this.
  2. Config-target path: A test where neither arguments nor event inputs carry a PR number, but the safe-outputs config target does (both numeric literal and ${ENV_VAR} form).
  3. Invalid / zero values: pull_request_number: "0", "abc" and "" should all return null and not cause an unhandled branch.

These are the exact edge cases where the original bug could silently regress.

@copilot please address this.

}
});

it("derives push_to_pull_request_branch branch from explicit arguments.pull_request_number when no event payload exists", async () => {
// Resolution path 3: no GITHUB_EVENT_PATH, but the sample entry carries an
// explicit `arguments.pull_request_number`. The driver should hit the PR
Expand Down
Loading