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
5 changes: 5 additions & 0 deletions .changeset/fix-apply-samples-siderepo-target-repo.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions actions/setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,19 @@ async function derivePrHeadRef(entry) {
return directRef.trim();
}

// Determine the target repo for any API lookups. Prefer the entry's repo if
// the sample sets one (cross-repo workflows), otherwise fall back to
// GITHUB_REPOSITORY.
const repoSlug = (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) || process.env.GITHUB_REPOSITORY || "";
// Determine the target repo for any API lookups.
// Resolution order:
// a. entry.arguments.repo — explicit per-sample override (cross-repo workflows).
// b. target-repo from the safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH)
// for the tool — covers siderepo workflow_dispatch where the sample arguments
// carry `pull_request_number` but not a `repo` override (issue #41292).
// c. GITHUB_REPOSITORY — host repo fallback.
let repoSlug = "";
if (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) {
repoSlug = entry.arguments.repo.trim();
} else {
repoSlug = readConfiguredTargetRepo(entry.tool) || process.env.GITHUB_REPOSITORY || "";
}
const [owner, repo] = repoSlug.split("/");
if (!owner || !repo) return null;

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

it("prefers explicit arguments.repo over safe-outputs target-repo when deriving PR branch", async () => {
const workspace = makeTempDir("gh-aw-prestage-push-explicit-repo-");
initRepo(workspace, "main");

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

const configPath = path.join(workspace, "config.json");
fs.writeFileSync(
configPath,
JSON.stringify({
push_to_pull_request_branch: { "target-repo": "githubnext/gh-aw-side-repo" },
})
);

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevEvent = process.env.GITHUB_EVENT_PATH;
const prevRepo = process.env.GITHUB_REPOSITORY;
const prevConfig = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
delete process.env.GITHUB_EVENT_PATH;
process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
process.env.GITHUB_REPOSITORY = "githubnext/gh-aw-test";
process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath;
try {
const entry = {
tool: "push_to_pull_request_branch",
arguments: {
message: "Push update",
pull_request_number: 88,
repo: "owner/explicit-repo",
},
sidecars: { patch: newFileDiff("explicit-repo.txt", "explicit repo wins\n") },
};
await preStagePatch(entry, 0, workspace);
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/owner/explicit-repo/pulls/88"), expect.anything());
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining("/repos/githubnext/gh-aw-side-repo/pulls/88"), 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) process.env.GITHUB_EVENT_PATH = prevEvent;
if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY;
else process.env.GITHUB_REPOSITORY = prevRepo;
if (prevConfig === undefined) delete process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
else process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig;
}
});

it("derives push_to_pull_request_branch branch using target-repo from safe-outputs config (issue #41292 siderepo workflow_dispatch)", async () => {
// Reproduces the siderepo failure: a workflow_dispatch provides
// `pull_request_number` but no `repo` override in the sample arguments.
// The safe-outputs config carries `target-repo: "githubnext/gh-aw-side-repo"`,
// which derivePrHeadRef must use when building the PR fetch URL.
const workspace = makeTempDir("gh-aw-prestage-push-siderepo-");
initRepo(workspace, "main");

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

const configPath = path.join(workspace, "config.json");
fs.writeFileSync(
configPath,
JSON.stringify({
push_to_pull_request_branch: { "target-repo": "githubnext/gh-aw-side-repo" },
})
);

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevEvent = process.env.GITHUB_EVENT_PATH;
const prevRepo = process.env.GITHUB_REPOSITORY;
const prevConfig = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
delete process.env.GITHUB_EVENT_PATH; // no event; rely on config
process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
process.env.GITHUB_REPOSITORY = "githubnext/gh-aw-test"; // host repo (wrong repo)
process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath;
try {
const entry = {
tool: "push_to_pull_request_branch",
// No `repo` override — agent emits only pull_request_number.
arguments: { message: "Multi-commit test push from Copilot in side repo", pull_request_number: 447 },
sidecars: { patch: newFileDiff("src/siderepo-feature.py", "# side repo\n") },
};
await preStagePatch(entry, 0, workspace);
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
// Must fetch from the side repo, NOT the host repo.
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/githubnext/gh-aw-side-repo/pulls/447"), expect.anything());
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining("/repos/githubnext/gh-aw-test/pulls/447"), expect.anything());
Comment thread
dsyme marked this conversation as resolved.
} 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) process.env.GITHUB_EVENT_PATH = prevEvent;
if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY;
else process.env.GITHUB_REPOSITORY = prevRepo;
if (prevConfig === undefined) delete process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
else process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig;
}
Comment thread
dsyme marked this conversation as resolved.
});

it("derives PR-linked issue payload branch using target-repo from safe-outputs config", async () => {
const workspace = makeTempDir("gh-aw-prestage-push-issue-siderepo-");
initRepo(workspace, "main");

const headRef = "feat/issue-side-repo";
const eventPath = path.join(workspace, "event.json");
fs.writeFileSync(
eventPath,
JSON.stringify({
issue: { number: 42, pull_request: { url: "https://api.github.com/repos/githubnext/gh-aw-side-repo/pulls/42" } },
})
);

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

const configPath = path.join(workspace, "config.json");
fs.writeFileSync(
configPath,
JSON.stringify({
push_to_pull_request_branch: { "target-repo": "githubnext/gh-aw-side-repo" },
})
);

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevEvent = process.env.GITHUB_EVENT_PATH;
const prevRepo = process.env.GITHUB_REPOSITORY;
const prevConfig = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
process.env.GITHUB_EVENT_PATH = eventPath;
process.env.GITHUB_REPOSITORY = "githubnext/gh-aw-test";
process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath;
try {
const entry = {
tool: "push_to_pull_request_branch",
arguments: { message: "Push update" },
sidecars: { patch: newFileDiff("issue-side-repo.txt", "issue side repo\n") },
};
await preStagePatch(entry, 0, workspace);
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/githubnext/gh-aw-side-repo/pulls/42"), expect.anything());
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining("/repos/githubnext/gh-aw-test/pulls/42"), 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;
if (prevConfig === undefined) delete process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH;
else process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig;
}
});

it("is a no-op when the sample tool isn't in the patch-sidecar set", async () => {
// We assert this at the driver level (PATCH_SIDECAR_TOOLS gate in main()),
// but preStagePatch itself should also be a no-op when called with an
Expand Down
52 changes: 52 additions & 0 deletions pkg/workflow/schemas/github-workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,18 @@
},
{
"required": ["run"]
},
{
"required": ["wait"]
},
{
"required": ["wait-all"]
},
{
"required": ["cancel"]
},
{
"required": ["parallel"]
}
],
"properties": {
Expand Down Expand Up @@ -574,6 +586,46 @@
"$ref": "#/definitions/expressionSyntax"
}
]
},
"background": {
"$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsbackground",
"description": "Runs a step asynchronously so the job continues to the next step without waiting for it to finish. You can use background on steps that use run or uses. To reference a background step from wait or cancel, give it an id. A maximum of 10 background steps can run concurrently in a single job.",
"type": "boolean"
},
"wait": {
"$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepswait",
"description": "Pauses the job until one or more background steps complete. Provide a single step id as a string, or multiple step ids as an array. After a wait step completes, the outputs of the referenced background steps become available to subsequent steps.",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
}
]
},
"wait-all": {
"$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepswait-all",
"description": "Pauses the job until all active background steps complete. The wait-all keyword takes no arguments.",
"type": ["boolean", "null"]
},
"cancel": {
"$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscancel",
"description": "Gracefully terminates a running background step. The runner sends the step's process a termination signal (SIGTERM) so it can clean up. The cancel keyword targets a single background step by its id.",
"type": "string"
},
"parallel": {
"$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsparallel",
"description": "Runs a group of steps concurrently, then waits for all of them to finish before continuing. Every step in the group runs as a background step, with an implicit wait at the end of the group.",
"type": "array",
"items": {
"$ref": "#/definitions/step"
},
"minItems": 1
}
}
},
Expand Down
Loading