diff --git a/actions/setup/js/generate_aw_info.cjs b/actions/setup/js/generate_aw_info.cjs index a8d266a0328..89b97a6ff5a 100644 --- a/actions/setup/js/generate_aw_info.cjs +++ b/actions/setup/js/generate_aw_info.cjs @@ -9,6 +9,7 @@ const { validateContextVariables } = require("./validate_context_variables.cjs") const validateLockdownRequirements = require("./validate_lockdown_requirements.cjs"); const { writeMergedModelsJSON } = require("./merge_frontmatter_models.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_CONFIG } = require("./error_codes.cjs"); /** * Generate aw_info.json with workflow run metadata. @@ -52,11 +53,23 @@ async function main(core, ctx) { } // Build awInfo from env vars (compile-time) + context (runtime) + const model = process.env.GH_AW_INFO_MODEL || ""; + + // Reject model names that contain an unresolved GitHub Actions expression. + // This can happen when a vars.* expression was used for the model in the + // workflow frontmatter but the variable is not defined in the repository, + // causing GitHub Actions to pass the literal expression string at runtime. + if (/\$\{\{/.test(model)) { + const message = `${ERR_CONFIG}: GH_AW_INFO_MODEL contains an unresolved GitHub Actions expression: ${JSON.stringify(model)}`; + core.setFailed(message); + throw new Error(message); + } + /** @type {Record} */ const awInfo = { engine_id: process.env.GH_AW_INFO_ENGINE_ID || "", engine_name: process.env.GH_AW_INFO_ENGINE_NAME || "", - model: process.env.GH_AW_INFO_MODEL || "", + model, version: process.env.GH_AW_INFO_VERSION || "", agent_version: process.env.GH_AW_INFO_AGENT_VERSION || "", workflow_name: process.env.GH_AW_INFO_WORKFLOW_NAME || "", diff --git a/actions/setup/js/generate_aw_info.test.cjs b/actions/setup/js/generate_aw_info.test.cjs index 489590169fe..c0721ecc26d 100644 --- a/actions/setup/js/generate_aw_info.test.cjs +++ b/actions/setup/js/generate_aw_info.test.cjs @@ -230,6 +230,57 @@ describe("generate_aw_info.cjs", () => { expect(awInfo.steps.firewall).toBe("squid"); }); + it("should fail when model name contains an unresolved GitHub Actions expression", async () => { + process.env.GH_AW_INFO_MODEL = "${{ vars.COPILOT_MODEL }}"; + + await expect(main(mockCore, mockContext)).rejects.toThrow("ERR_CONFIG"); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("ERR_CONFIG")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("unresolved GitHub Actions expression")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining(JSON.stringify("${{ vars.COPILOT_MODEL }}"))); + }); + + it("should fail when model name contains a partial unresolved expression", async () => { + process.env.GH_AW_INFO_MODEL = "${{ vars.MODEL || 'gpt-4' }}"; + + await expect(main(mockCore, mockContext)).rejects.toThrow("ERR_CONFIG"); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("ERR_CONFIG")); + }); + + it("should not fail when model name does not contain unresolved expressions", async () => { + const cases = [ + { label: "plain string", model: "gpt-4o" }, + { label: "empty string", model: "" }, + ]; + + for (const { label, model } of cases) { + process.env.GH_AW_INFO_MODEL = model; + + try { + await main(mockCore, mockContext); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(awInfo.model).toBe(model); + } catch (error) { + throw new Error(`${label} case failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + }); + + it("should not fail when model is a resolved experiment variant string", async () => { + // Workflows using experiment variants set model to a GitHub Actions expression in frontmatter + // (e.g. model: "${{ needs.activation.outputs.model_size }}"). GitHub Actions evaluates + // that expression before passing the value as an environment variable, so by the time + // generate_aw_info.cjs runs, GH_AW_INFO_MODEL holds the resolved variant string. + process.env.GH_AW_INFO_MODEL = "claude-haiku-4.5"; + + await main(mockCore, mockContext); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(awInfo.model).toBe("claude-haiku-4.5"); + }); + it("should fail when a numeric context field contains non-numeric data", async () => { const maliciousContext = { ...mockContext,