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
1 change: 1 addition & 0 deletions .github/workflows/agentic-token-audit.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/agentic-token-trend-audit.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/api-consumption-report.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/audit-workflows.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/copilot-pr-nlp-analysis.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/copilot-session-insights.lock.yml

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

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

1 change: 1 addition & 0 deletions .github/workflows/daily-code-metrics.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-experiment-report.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-firewall-report.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-issues-report.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-news.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-performance-summary.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-repo-chronicle.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/daily-security-observability.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/docs-noob-tester.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/github-mcp-structural-analysis.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/org-health-report.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/portfolio-analyst.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/prompt-clustering-analysis.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/python-data-charts.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/stale-repo-identifier.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/weekly-editors-health-check.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/weekly-issue-summary.lock.yml

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

16 changes: 8 additions & 8 deletions actions/setup/js/upload_assets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ async function main() {

core.info(`Found ${uploadItems.length} upload-asset item(s)`);

// Derive the base directory from GH_AW_AGENT_OUTPUT when available.
// In the upload_assets job, the agent artifact (including safeoutputs/assets/)
// is downloaded to the same parent directory as agent_output.json, which may
// differ from RUNNER_TEMP when the download path is explicitly set to /tmp/gh-aw/.
const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
const baseDir = agentOutputFile ? path.dirname(agentOutputFile) : path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw");
const assetsDir = path.join(baseDir, "safeoutputs", "assets");
// Read the staged-assets directory directly. The upload_assets job's
// download-artifact step writes the safe-outputs assets artifact to this exact
// directory, and the Go generator passes the same path via GH_AW_ASSETS_DIR, so
// producer and consumer can never disagree on the location. The literal fallback
// matches constants.TmpGhAwAssetsDir for robustness if the env var is unset.
const assetsDir = process.env.GH_AW_ASSETS_DIR || "/tmp/gh-aw/safeoutputs/assets";
core.info(`Reading staged assets from: ${assetsDir}`);
let uploadCount = 0;
let missingAssetCount = 0;
let hasChanges = false;
Expand Down Expand Up @@ -130,7 +130,7 @@ async function main() {
return;
}

// Check if file exists in artifacts
// Check if file exists in the staged-assets directory
const assetSourcePath = path.join(assetsDir, fileName);
if (!fs.existsSync(assetSourcePath)) {
core.warning(`${ERR_SYSTEM}: Asset file not found: ${assetSourcePath} — skipping`);
Expand Down
37 changes: 36 additions & 1 deletion actions/setup/js/upload_assets.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ const mockCore = { debug: vi.fn(), info: vi.fn(), notice: vi.fn(), warning: vi.f
getAssetsDir = () => path.join(tempBase, "safeoutputs", "assets"),
executeScript = async () => ((global.core = mockCore), (global.exec = mockExec), await eval(`(async () => { ${uploadAssetsScript}; await main(); })()`));
(beforeEach(() => {
(vi.clearAllMocks(), delete process.env.GH_AW_ASSETS_BRANCH, delete process.env.GH_AW_AGENT_OUTPUT, delete process.env.GH_AW_SAFE_OUTPUTS_STAGED);
(vi.clearAllMocks(), delete process.env.GH_AW_ASSETS_BRANCH, delete process.env.GH_AW_AGENT_OUTPUT, delete process.env.GH_AW_ASSETS_DIR, delete process.env.GH_AW_SAFE_OUTPUTS_STAGED);
tempBase = fs.mkdtempSync(path.join("/tmp", "test-gh-aw-"));
process.env.GH_AW_ASSETS_DIR = path.join(tempBase, "safeoutputs", "assets");
const scriptPath = path.join(__dirname, "upload_assets.cjs");
((uploadAssetsScript = fs.readFileSync(scriptPath, "utf8")), (mockExec = { exec: vi.fn().mockResolvedValue(0) }));
}),
Expand Down Expand Up @@ -175,5 +176,39 @@ const mockCore = { debug: vi.fn(), info: vi.fn(), notice: vi.fn(), warning: vi.f
fs.existsSync(path.join(process.cwd(), presentTargetFile)) && fs.unlinkSync(path.join(process.cwd(), presentTargetFile));
});
});
describe("staging directory resolution", () => {
it("should read assets from the GH_AW_ASSETS_DIR directory", async () => {
process.env.GH_AW_ASSETS_BRANCH = "assets/test-workflow";
process.env.GH_AW_SAFE_OUTPUTS_STAGED = "false";
// Point GH_AW_ASSETS_DIR at a custom directory (distinct from the
// agent-output dir) to confirm the consumer reads exactly the
// directory the download step wrote to — no search, no derivation.
const customAssetsDir = fs.mkdtempSync(path.join("/tmp", "test-gh-aw-assets-"));
process.env.GH_AW_ASSETS_DIR = customAssetsDir;
const assetSourcePath = path.join(customAssetsDir, "chart.png");
fs.writeFileSync(assetSourcePath, "chart content");
const crypto = require("crypto"),
fileContent = fs.readFileSync(assetSourcePath),
targetFile = "chart-uploaded.png";
setAgentOutput({
items: [{ type: "upload_asset", fileName: "chart.png", sha: crypto.createHash("sha256").update(fileContent).digest("hex"), size: fileContent.length, targetFileName: targetFile, url: "https://example.com/chart.png" }],
});
mockExec.exec.mockImplementation(async (command, args) => {
const fullCommand = Array.isArray(args) ? `${command} ${args.join(" ")}` : command;
if (fullCommand.includes("rev-parse")) throw new Error("Branch does not exist");
return 0;
});
try {
await executeScript();
expect(mockCore.setFailed).not.toHaveBeenCalled();

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 asserts setFailed was not called and upload_count equals 1, which is the right outcome check. Consider also asserting that core.info was called with the expected candidate paths — this locks in the diagnostic logging that will help future debugging if the mismatch recurs.

💡 Example assertion
const infoSearchCall = mockCore.info.mock.calls.find(
  ([msg]) => msg.startsWith("Searching for staged assets in:")
);
expect(infoSearchCall).toBeDefined();
expect(infoSearchCall[0]).toContain(runnerTempBase);

This also guards against accidental removal of the info log, which is valuable for diagnosing future environment-specific path mismatches.

const uploadCountCall = mockCore.setOutput.mock.calls.find(call => "upload_count" === call[0]);
expect(uploadCountCall).toBeDefined();
uploadCountCall && expect(uploadCountCall[1]).toBe("1");
} finally {
fs.existsSync(customAssetsDir) && fs.rmSync(customAssetsDir, { recursive: !0, force: !0 });
fs.existsSync(path.join(process.cwd(), targetFile)) && fs.unlinkSync(path.join(process.cwd(), targetFile));
}
});

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 regression test covers the core fix, but the priority order (agent-output dir → RUNNER_TEMP → /tmp) is an implicit contract with no test to guard it.

💡 Suggested additional test case

Add a second it that stages the file in both the agent-output directory and RUNNER_TEMP, then asserts the agent-output-relative copy was the one used (e.g. via SHA or a sentinel byte). Without this, a future reordering of candidateBaseDirs would pass all existing tests silently.

it("should prefer agent-output dir over RUNNER_TEMP when both contain the asset", async () => {
  // stage one file in tempBase/safeoutputs/assets/ (agent output dir)
  // stage a different file at runnerTempBase/gh-aw/safeoutputs/assets/
  // assert the agent-output-dir version was uploaded (check its SHA)
});

});
}));
}));
9 changes: 9 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ const TmpGhAwDirSlash = TmpGhAwDir + "/"
// TmpGhAwAgentDir is the agent working directory in the /tmp/gh-aw tree.
const TmpGhAwAgentDir = TmpGhAwDir + "/agent/"

// TmpGhAwAssetsDir is the directory the upload_assets job downloads the
// safe-outputs assets artifact into. It is the single source of truth shared by
// the download step (path:) and the upload_assets.cjs consumer script, so the
// two never disagree on where staged assets live.
const TmpGhAwAssetsDir = TmpGhAwDir + "/safeoutputs/assets"

// TmpGhAwAssetsDirSlash is TmpGhAwAssetsDir with a trailing slash.
const TmpGhAwAssetsDirSlash = TmpGhAwAssetsDir + "/"

// AgentStdioLogPath is the path for capturing agent standard I/O log output.
const AgentStdioLogPath = TmpGhAwDir + "/agent-stdio.log"

Expand Down
7 changes: 5 additions & 2 deletions pkg/workflow/publish_assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,20 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string,
preSteps = append(preSteps, fmt.Sprintf(" uses: %s\n", c.getActionPin("actions/download-artifact")))
preSteps = append(preSteps, " with:\n")
preSteps = append(preSteps, fmt.Sprintf(" name: %ssafe-outputs-assets\n", assetsArtifactPrefix))
preSteps = append(preSteps, " path: /tmp/gh-aw/safeoutputs/assets/\n")
preSteps = append(preSteps, fmt.Sprintf(" path: %s\n", constants.TmpGhAwAssetsDirSlash))

// Step 4: List files
preSteps = append(preSteps, " - name: List downloaded asset files\n")
preSteps = append(preSteps, " continue-on-error: true\n") // Continue if no assets were uploaded
preSteps = append(preSteps, " run: |\n")
preSteps = append(preSteps, " echo \"Downloaded asset files:\"\n")
preSteps = append(preSteps, " find /tmp/gh-aw/safeoutputs/assets/ -maxdepth 1 -ls\n")
preSteps = append(preSteps, fmt.Sprintf(" find %s -maxdepth 1 -ls\n", constants.TmpGhAwAssetsDirSlash))

// Build custom environment variables specific to upload-assets
var customEnvVars []string
// Single source of truth for the staged-assets directory: the consumer script
// reads exactly the directory the download step above wrote to.
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_ASSETS_DIR: %q\n", constants.TmpGhAwAssetsDir))
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_ASSETS_BRANCH: %q\n", data.SafeOutputs.UploadAssets.BranchName))
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_ASSETS_MAX_SIZE_KB: %d\n", data.SafeOutputs.UploadAssets.MaxSizeKB))
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_ASSETS_ALLOWED_EXTS: %q\n", strings.Join(data.SafeOutputs.UploadAssets.AllowedExts, ",")))
Expand Down
Loading