diff --git a/actions/setup/js/check_version_updates.cjs b/actions/setup/js/check_version_updates.cjs index 4b848a07131..2d3b9856e0d 100644 --- a/actions/setup/js/check_version_updates.cjs +++ b/actions/setup/js/check_version_updates.cjs @@ -18,9 +18,12 @@ const { withRetry, isTransientError } = require("./error_recovery.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs"); const CONFIG_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json"; const FETCH_TIMEOUT_MS = 120_000; +const BLOCKED_VERSION_ISSUE_TITLE_PREFIX = "[aw] Workflows blocked by compile-agentic"; +const GITHUB_API_VERSION = "2022-11-28"; /** * Parse an official version string (must be in vMAJOR.MINOR.PATCH format). @@ -58,6 +61,149 @@ function compareVersions(a, b) { return 0; } +/** + * Build the stable issue title used to deduplicate blocked compiler notifications. + * + * @param {string} compiledVersion + * @returns {string} + */ +function buildBlockedVersionIssueTitle(compiledVersion) { + return `${BLOCKED_VERSION_ISSUE_TITLE_PREFIX} ${compiledVersion}`; +} + +/** + * Return a Markdown-safe inline-code representation. + * + * @param {string} value + * @returns {string} + */ +function markdownCode(value) { + return `\`${String(value).replace(/`/g, "\\`")}\``; +} + +/** + * Build a link to the current workflow run when the GitHub Actions context is available. + * + * @returns {string} + */ +function getRunUrl() { + const repoFullName = process.env.GITHUB_REPOSITORY || (typeof context !== "undefined" && context.repo ? `${context.repo.owner}/${context.repo.repo}` : ""); + const runId = process.env.GITHUB_RUN_ID || String(typeof context !== "undefined" && context.runId ? context.runId : ""); + if (!repoFullName || !runId) { + return ""; + } + const serverUrl = process.env.GITHUB_SERVER_URL || (typeof context !== "undefined" ? context.serverUrl : "") || "https://github.com"; + return `${serverUrl}/${repoFullName}/actions/runs/${runId}`; +} + +/** + * Build the body for the blocked compiler notification issue. + * + * @param {string} compiledVersion + * @returns {string} + */ +function buildBlockedVersionIssueBody(compiledVersion) { + const workflowName = process.env.GH_AW_WORKFLOW_NAME || (typeof context !== "undefined" ? context.workflow : "") || "unknown"; + const runUrl = getRunUrl(); + return renderTemplateFromFile(getPromptPath("blocked_compiler_version_issue.md"), { + compiled_version: compiledVersion, + compiled_version_code: markdownCode(compiledVersion), + workflow_name_code: markdownCode(workflowName), + run_url_line: runUrl ? `- Run: ${runUrl}` : "", + }); +} + +/** + * Find an existing open blocked-version issue for this compiler version. + * + * NOTE: This relies on the GitHub search index, which is eventually consistent. + * During a repo-wide blocked-version outage, many activation runs can fire close + * together; two runs can both observe `items: []` before the index catches up and + * both proceed to create an issue, producing duplicates. This is accepted as a + * best-effort tradeoff (consistent with other dedup lookups in this codebase, e.g. + * handle_agent_failure.cjs) rather than a correctness guarantee. The call is wrapped + * in withRetry so transient failures and secondary rate limits (more likely during a + * fan-out of many concurrent activation failures) don't silently drop the lookup. + * + * @param {string} owner + * @param {string} repo + * @param {string} compiledVersion + * @returns {Promise<{number: number, html_url: string} | null>} + */ +async function findExistingBlockedVersionIssue(owner, repo, compiledVersion) { + const title = buildBlockedVersionIssueTitle(compiledVersion); + const result = await withRetry( + () => + github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:issue is:open in:title "${title}"`, + per_page: 10, + }), + {}, + "search for existing blocked compiler version issue" + ); + const existing = result.data.items.find(item => item.title === title && !item.pull_request); + return existing ? { number: existing.number, html_url: existing.html_url } : null; +} + +/** + * Best-effort issue notification for blocked compiler versions. Failures here must not + * mask the primary blocked-version error. + * + * @param {string} compiledVersion + * @returns {Promise} + */ +async function reportBlockedVersionIssue(compiledVersion) { + if (process.env.GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE === "false") { + core.info("Blocked compiler version issue reporting is disabled"); + return; + } + if (typeof github === "undefined" || typeof context === "undefined" || !github.rest?.issues || !github.rest?.search || !context.repo) { + core.info("GitHub issue APIs are unavailable; skipping blocked compiler version issue notification"); + return; + } + + const { owner, repo } = context.repo; + const title = buildBlockedVersionIssueTitle(compiledVersion); + const body = buildBlockedVersionIssueBody(compiledVersion); + + try { + const existing = await findExistingBlockedVersionIssue(owner, repo, compiledVersion); + if (existing) { + const updatedIssue = await withRetry( + () => + github.rest.issues.update({ + owner, + repo, + issue_number: existing.number, + title, + body, + headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, + }), + {}, + "update blocked compiler version issue" + ); + core.info(`Updated blocked compiler version issue #${updatedIssue.data.number}: ${updatedIssue.data.html_url}`); + return; + } + + const newIssue = await withRetry( + () => + github.rest.issues.create({ + owner, + repo, + title, + body, + headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, + }), + {}, + "create blocked compiler version issue" + ); + core.info(`Created blocked compiler version issue #${newIssue.data.number}: ${newIssue.data.html_url}`); + } catch (err) { + core.warning(`Could not create or update blocked compiler version issue: ${getErrorMessage(err)}`); + } +} + /** * @typedef {object} UpdateConfig * @property {string[]} [blockedVersions] @@ -128,6 +274,7 @@ async function main() { .addRaw("This version has been revoked, typically due to a security issue.\n\n") .addRaw("**Action required:** Update `gh-aw` to the latest version and recompile your workflow with `gh aw compile`.\n"); await core.summary.write(); + await reportBlockedVersionIssue(compiledVersion); core.setFailed(`Blocked compile-agentic version: ${compiledVersion} is in the blocked versions list. Update gh-aw to the latest version and recompile your workflow.`); return; } @@ -157,4 +304,12 @@ async function main() { core.info(`✅ Version check passed: ${compiledVersion}`); } -module.exports = { main }; +module.exports = { + buildBlockedVersionIssueBody, + buildBlockedVersionIssueTitle, + compareVersions, + findExistingBlockedVersionIssue, + main, + parseVersion, + reportBlockedVersionIssue, +}; diff --git a/actions/setup/js/check_version_updates.test.cjs b/actions/setup/js/check_version_updates.test.cjs index e83627dbe4e..40b6235a36f 100644 --- a/actions/setup/js/check_version_updates.test.cjs +++ b/actions/setup/js/check_version_updates.test.cjs @@ -1,5 +1,8 @@ // @ts-check import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { syncRuntimePromptTemplates } from "./test_prompt_templates.js"; + +syncRuntimePromptTemplates(import.meta.url); describe("check_version_updates", () => { let mockCore; @@ -23,11 +26,18 @@ describe("check_version_updates", () => { }; global.core = mockCore; + global.github = {}; + global.context = { repo: { owner: "owner", repo: "repo" }, workflow: "Test workflow", runId: 123 }; mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); delete process.env.GH_AW_COMPILED_VERSION; + delete process.env.GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE; + delete process.env.GH_AW_WORKFLOW_NAME; + delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_RUN_ID; + delete process.env.GITHUB_SERVER_URL; vi.resetModules(); @@ -38,6 +48,9 @@ describe("check_version_updates", () => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.clearAllMocks(); + delete global.core; + delete global.github; + delete global.context; }); /** @@ -336,6 +349,138 @@ describe("check_version_updates", () => { expect(mockCore.summary.write).toHaveBeenCalled(); }); + it("should create a deduplicated issue when version is blocked", async () => { + process.env.GH_AW_COMPILED_VERSION = "v1.0.0"; + process.env.GH_AW_WORKFLOW_NAME = "Blocked workflow"; + process.env.GITHUB_REPOSITORY = "owner/repo"; + process.env.GITHUB_RUN_ID = "456"; + mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" })); + const searchMock = vi.fn().mockResolvedValue({ data: { items: [] } }); + const createMock = vi.fn().mockResolvedValue({ data: { number: 42, html_url: "https://github.com/owner/repo/issues/42" } }); + global.github = { + rest: { + search: { issuesAndPullRequests: searchMock }, + issues: { create: createMock, update: vi.fn() }, + }, + }; + + await runMain(); + + expect(searchMock).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:issue is:open in:title "[aw] Workflows blocked by compile-agentic v1.0.0"', + per_page: 10, + }); + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + title: "[aw] Workflows blocked by compile-agentic v1.0.0", + body: expect.stringContaining("Workflow: `Blocked workflow`"), + }) + ); + expect(createMock.mock.calls[0][0].body).toContain("https://github.com/owner/repo/actions/runs/456"); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version")); + }); + + it("should update an existing blocked version issue when version is blocked", async () => { + process.env.GH_AW_COMPILED_VERSION = "v1.0.0"; + mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" })); + const searchMock = vi.fn().mockResolvedValue({ + data: { + items: [{ number: 7, html_url: "https://github.com/owner/repo/issues/7", title: "[aw] Workflows blocked by compile-agentic v1.0.0" }], + }, + }); + const updateMock = vi.fn().mockResolvedValue({ data: { number: 7, html_url: "https://github.com/owner/repo/issues/7" } }); + const createMock = vi.fn(); + global.github = { + rest: { + search: { issuesAndPullRequests: searchMock }, + issues: { create: createMock, update: updateMock }, + }, + }; + + await runMain(); + + expect(updateMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + issue_number: 7, + title: "[aw] Workflows blocked by compile-agentic v1.0.0", + }) + ); + expect(createMock).not.toHaveBeenCalled(); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version")); + }); + + it("should continue failing the version check when blocked issue creation fails", async () => { + process.env.GH_AW_COMPILED_VERSION = "v1.0.0"; + mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" })); + global.github = { + rest: { + search: { issuesAndPullRequests: vi.fn().mockResolvedValue({ data: { items: [] } }) }, + issues: { create: vi.fn().mockRejectedValue(new Error("Resource not accessible by integration")), update: vi.fn() }, + }, + }; + + await runMain(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not create or update blocked compiler version issue")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version")); + }); + + it("should skip issue notification when github APIs are unavailable", async () => { + process.env.GH_AW_COMPILED_VERSION = "v1.0.0"; + mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" })); + global.github = {}; // no rest.search / rest.issues + + await runMain(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GitHub issue APIs are unavailable")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version")); + }); + + it("should retry the issue search on a transient error before creating an issue", async () => { + process.env.GH_AW_COMPILED_VERSION = "v1.0.0"; + mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" })); + const searchMock = vi + .fn() + .mockRejectedValueOnce(new Error("secondary rate limit exceeded")) + .mockResolvedValueOnce({ data: { items: [] } }); + const createMock = vi.fn().mockResolvedValue({ data: { number: 42, html_url: "https://github.com/owner/repo/issues/42" } }); + global.github = { + rest: { + search: { issuesAndPullRequests: searchMock }, + issues: { create: createMock, update: vi.fn() }, + }, + }; + + await runMain(); + + expect(searchMock).toHaveBeenCalledTimes(2); + expect(createMock).toHaveBeenCalledTimes(1); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version")); + }); + + it("should skip blocked version issue creation when disabled", async () => { + process.env.GH_AW_COMPILED_VERSION = "v1.0.0"; + process.env.GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE = "false"; + mockFetchSuccess(JSON.stringify({ blockedVersions: ["v1.0.0"], minimumVersion: "" })); + const createMock = vi.fn(); + global.github = { + rest: { + search: { issuesAndPullRequests: vi.fn() }, + issues: { create: createMock, update: vi.fn() }, + }, + }; + + await runMain(); + + expect(createMock).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith("Blocked compiler version issue reporting is disabled"); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Blocked compile-agentic version")); + }); + // --------------------------------------------------------------------------- // Minimum version cases // --------------------------------------------------------------------------- diff --git a/actions/setup/md/blocked_compiler_version_issue.md b/actions/setup/md/blocked_compiler_version_issue.md new file mode 100644 index 00000000000..e243722def3 --- /dev/null +++ b/actions/setup/md/blocked_compiler_version_issue.md @@ -0,0 +1,17 @@ + + +## Agentic workflows are blocked + +This repository has one or more workflows compiled with {compiled_version_code}, which is in the blocked versions list. + +Activation fails before the agent, safe outputs, and conclusion jobs can run. + +### Latest blocked run + +- Workflow: {workflow_name_code} +{run_url_line} +### Action required + +Update `gh-aw` to the latest version and recompile the affected workflows with `gh aw compile`. + +This issue is updated by the activation-stage version check when another blocked run is detected. diff --git a/docs/adr/59747-report-blocked-compiler-version-at-activation.md b/docs/adr/59747-report-blocked-compiler-version-at-activation.md new file mode 100644 index 00000000000..fbc259347d3 --- /dev/null +++ b/docs/adr/59747-report-blocked-compiler-version-at-activation.md @@ -0,0 +1,52 @@ +# ADR-59747: Report blocked compiler versions at activation + +**Date**: 2026-09-09 +**Status**: Draft +**Deciders**: gh-aw maintainers + +--- + +### Context + +Compiled `gh aw` workflows currently detect blocked compiler versions during the `activation` stage. When that check fails, downstream jobs that normally handle failure reporting are skipped, which creates a silent repository-wide outage for affected workflows. The PR description and linked issue #59600 show that this failure mode can persist without human notice because the only existing notifier runs later in the workflow graph. The implementation therefore needs an explicit decision about whether blocked-version notification should be handled directly in activation, and under what permissions and failure semantics. + +### Decision + +We will report blocked compiler versions from the activation-stage version check itself, before the workflow exits with the existing hard failure. The activation logic will create or update a deduplicated GitHub issue keyed by blocked compiler version, pass workflow/reporting context into that step, and grant `issues: write` only when that reporting path can execute. We chose this approach because it closes the silent-outage gap at the only point that always observes the blocked-version condition, while keeping notification failures non-fatal so they do not hide the primary compatibility error. + +### Alternatives Considered + +#### Alternative 1: Keep failure reporting only in downstream conclusion handling + +The project could continue relying on the existing downstream failure-reporting job or script. This was considered because it avoids adding notification logic to activation and keeps reporting centralized. It was not chosen because the linked issue and PR both show that blocked-version failures happen before those downstream paths can run, so this design cannot report the exact outage it most needs to surface. + +#### Alternative 2: Introduce only a pre-block warning mechanism + +Another option would be to add softer warnings, such as deprecation notices or future-block annotations, before versions enter the blocked list. This was considered because warnings could reduce the number of hard outages and might improve upgrade lead time. It was not chosen for this PR because the immediate problem is the current silent failure once a version is already blocked, and warning-only behavior would not restore visibility for repositories already affected. + +#### Alternative 3: Add a separate non-agentic watchdog workflow + +The repository could provide a standalone watchdog workflow that checks compiled versions outside the agentic activation path. This was considered because an external monitor would remain runnable even when agentic workflows are blocked. It was not chosen here because it adds operational overhead, requires consumers to install and maintain another workflow, and does not fix the built-in reporting path for existing compiled workflows. + +### Consequences + +#### Positive +- Blocked compiler versions become visible through a deduplicated issue even when activation fails before downstream jobs start. +- The notification logic runs at the only guaranteed observation point for this failure mode, reducing the chance of silent repo-wide outages. +- Limiting `issues: write` to activation only when needed narrows permission scope while preserving automated reporting. + +#### Negative +- Activation-stage version checking becomes more complex because it now owns issue lookup/update behavior in addition to compatibility validation. +- The workflow needs additional permission wiring and input propagation, which increases compiler and generated workflow surface area. +- Issue creation/update can fail independently, requiring best-effort error handling and tests to ensure the root blocked-version error remains the primary failure. +- The activation-stage notification only honors a literal `false` for `safe-outputs.report-failure-as-issue`; it does not participate in that setting's category-filter arrays (e.g. `["!blocked_version"]`), since those categories describe the downstream conclusion job's own failure taxonomy and are not available at the activation stage. Workflows relying on category filtering to suppress specific conclusion-job failure types will still receive blocked-version issues unless they disable reporting outright. + +#### Neutral +- Generated workflows now pass workflow name and reporting policy into the blocked-version check step. +- Notification deduplication is based on a stable issue title derived from the blocked compiler version. +- The existing hard failure message for blocked versions remains unchanged after reporting is attempted. +- Workflow authors can set `on.report-blocked-version: false` to suppress only the activation-stage notification issue, independent of `check-for-updates` (which disables the whole check and is not allowed in strict mode) and `safe-outputs.report-failure-as-issue` (which also gates the notification). This gives a narrower, always-strict-mode-safe off-switch dedicated to this notification, mirroring the pattern used by `on.stale-check: false`. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index 08158627ecd..17f5f2ec857 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -365,6 +365,11 @@ "desc": "Controls the stale lock file check in the activation job.", "enum": ["full"], "leaf": true + }, + "report-blocked-version": { + "type": "boolean", + "desc": "Controls whether the activation job creates or updates a notification issue when the workflow is compiled with a blocked compile-agentic version.", + "leaf": true } } }, diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 0c3531c5341..ba7db0ed898 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1575,6 +1575,15 @@ on: # Format 2: string stale-check: "full" + # Controls whether the activation job creates or updates a notification issue + # when the workflow is compiled with a blocked compile-agentic version. Set to + # false to suppress only that notification issue while keeping the + # blocked-version check itself (and its hard failure) active. This is + # independent of 'check-for-updates' (which disables the whole check) and + # 'safe-outputs.report-failure-as-issue' (which also gates the notification). + # (optional) + report-blocked-version: true + # ⚠️ Experimental. Agent Plugins to install after the agentic engine. Each GitHub # repository reference must include a ref that the compiler resolves to a commit # SHA. Using this field emits a compile-time warning. Entries may also be objects diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index f69a61f083d..6c44536cd68 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2433,6 +2433,12 @@ } ], "description": "Controls the stale lock file check in the activation job. Set to false to disable the check, true (default) to enable frontmatter hash checking, or \"full\" to check both frontmatter and body hashes. Use \"full\" when prompt-body edits should also trigger recompilation detection. Useful when the workflow source files are managed outside the default GitHub repo context (e.g. cross-repo org rulesets) and the stale check is not needed (set false), or when comprehensive drift detection is required (set \"full\")." + }, + "report-blocked-version": { + "type": "boolean", + "default": true, + "description": "Controls whether the activation job creates or updates a notification issue when the workflow is compiled with a blocked compile-agentic version. Set to false to suppress only that notification issue while keeping the blocked-version check itself (and its hard failure) active. This is independent of 'check-for-updates' (which disables the whole check) and 'safe-outputs.report-failure-as-issue' (which also gates the notification).", + "examples": [true, false] } }, "additionalProperties": false, diff --git a/pkg/workflow/compiler_activation_permissions.go b/pkg/workflow/compiler_activation_permissions.go index 24ef26bb3ad..262b8010f2a 100644 --- a/pkg/workflow/compiler_activation_permissions.go +++ b/pkg/workflow/compiler_activation_permissions.go @@ -38,6 +38,13 @@ func activationJobNeedsAppToken(ctx *activationJobBuildContext) bool { func buildActivationAppTokenPermissions(ctx *activationJobBuildContext) *Permissions { appPerms := NewPermissions() + addActivationAppInteractionPermissions(appPerms, ctx) + addActivationAppLabelAndGuardrailPermissions(appPerms, ctx) + addActivationAppInferredPermissions(appPerms, ctx) + return appPerms +} + +func addActivationAppInteractionPermissions(appPerms *Permissions, ctx *activationJobBuildContext) { addActivationInteractionPermissions( appPerms, activationInteractionPermissionsOptions{ @@ -86,13 +93,9 @@ func buildActivationAppTokenPermissions(ctx *activationJobBuildContext) *Permiss }, ) } - // Keep this aligned with addActivationLabelPermissions: app-token scopes are - // computed separately from GITHUB_TOKEN scopes because app-token permissions - // only apply to steps using the minted app token, while label permissions in - // addActivationLabelPermissions are only for GITHUB_TOKEN execution paths. - // This intentionally mirrors addActivationLabelPermissions without the - // ActivationGitHubApp == nil guard because this function runs only when - // activationJobNeedsAppToken confirms app-token minting is enabled. +} + +func addActivationAppLabelAndGuardrailPermissions(appPerms *Permissions, ctx *activationJobBuildContext) { if ctx.shouldRemoveLabel { if slices.Contains(ctx.filteredLabelEvents, "issues") || slices.Contains(ctx.filteredLabelEvents, "pull_request") { appPerms.Set(PermissionIssues, PermissionWrite) @@ -107,15 +110,14 @@ func buildActivationAppTokenPermissions(ctx *activationJobBuildContext) *Permiss if hasMaxDailyAICGuardrail(ctx.data) { appPerms.Set(PermissionActions, PermissionRead) } - // Add GitHub App-only permissions inferred from activation job gh CLI commands so the - // minted App token includes the scopes those commands require (e.g. codespaces: read - // for `gh codespace list`). Only App-only scopes are passed here. +} + +func addActivationAppInferredPermissions(appPerms *Permissions, ctx *activationJobBuildContext) { for scope, level := range ctx.activationInferredPerms { if IsGitHubAppOnlyScope(scope) { appPerms.Set(scope, level) } } - return appPerms } // buildActivationPermissions builds activation job permissions from workflow features and selected interactions. @@ -142,6 +144,9 @@ func (c *Compiler) buildActivationBasePermissions(ctx *activationJobBuildContext if isSteeringIssueEnabled(ctx.data) { permsMap[PermissionIssues] = PermissionWrite } + if c.activationBlockedVersionIssueEnabled(ctx) { + permsMap[PermissionIssues] = PermissionWrite + } addActivationInteractionPermissionsMap(permsMap, activationInteractionPermissionsOptions{ onSection: ctx.data.On, hasReaction: ctx.hasReaction, @@ -162,6 +167,48 @@ func (c *Compiler) buildActivationBasePermissions(ctx *activationJobBuildContext return permsMap } +// activationBlockedVersionIssueEnabled reports whether the activation-stage +// blocked-version check may create/update a notification issue, and is used +// to conservatively grant issues: write at compile time. It reuses +// conclusionReportFailureAsIssueEnabled, which only detects a literal "false" +// for safe-outputs.report-failure-as-issue. It intentionally does NOT honor +// that setting's category-filter arrays (ReportFailureAsIssueCategories / +// ReportFailureAsIssueExcludedCategories, e.g. report-failure-as-issue: +// ["!blocked_version"]): those categories describe failures detected by the +// downstream conclusion job and are not available this early in the +// workflow. A workflow that only wants to suppress blocked-version +// notifications must set report-failure-as-issue to a literal false, or set +// on.report-blocked-version: false (see ReportBlockedVersionDisabled), which +// is a narrower toggle that only affects this notification and does not +// disable check-for-updates or its hard failure. +func (c *Compiler) activationBlockedVersionIssueEnabled(ctx *activationJobBuildContext) bool { + return !ctx.data.UpdateCheckDisabled && !ctx.data.ReportBlockedVersionDisabled && IsReleasedVersion(c.version) && conclusionReportFailureAsIssueEnabled(ctx.data) +} + +// activationBlockedVersionReportAsIssueValue returns the templatable +// report-failure-as-issue value to embed in the blocked-version check step's +// environment. Unlike activationBlockedVersionIssueEnabled (used to +// conservatively grant issues: write at compile time), this preserves runtime +// expressions (e.g. "${{ inputs.report-failure-as-issue }}") instead of +// collapsing them to a compile-time boolean, so the value is only resolved +// once GitHub Actions evaluates the expression at runtime. Defaults to "true" +// when report-failure-as-issue is unset. When on.report-blocked-version: false +// is set in frontmatter, this always returns a literal "false", overriding any +// report-failure-as-issue value, since that flag is a dedicated off-switch for +// this notification. +func activationBlockedVersionReportAsIssueValue(ctx *activationJobBuildContext) *string { + if ctx.data.ReportBlockedVersionDisabled { + v := "false" + return &v + } + if ctx.data.SafeOutputs == nil || ctx.data.SafeOutputs.ReportFailureAsIssue == nil { + v := "true" + return &v + } + v := ctx.data.SafeOutputs.ReportFailureAsIssue.String() + return &v +} + func (c *Compiler) addCentralizedCommandActivationPermissions(permsMap map[PermissionScope]PermissionLevel, ctx *activationJobBuildContext) { // For centralized slash_command workflows, the compiled "on" section only contains // workflow_dispatch, so addActivationInteractionPermissionsMap above cannot detect the diff --git a/pkg/workflow/compiler_activation_steps.go b/pkg/workflow/compiler_activation_steps.go index ae60cadcf94..b88fae9ed29 100644 --- a/pkg/workflow/compiler_activation_steps.go +++ b/pkg/workflow/compiler_activation_steps.go @@ -2,6 +2,7 @@ package workflow import ( "fmt" + "path" "strings" "github.com/github/gh-aw/pkg/constants" @@ -192,6 +193,8 @@ func (c *Compiler) addActivationVersionCheckStep(ctx *activationJobBuildContext) ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", ctx.data))) ctx.steps = append(ctx.steps, " env:\n") ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_COMPILED_VERSION: \"%s\"\n", c.version)) + ctx.steps = append(ctx.steps, buildTemplatableBoolEnvVar("GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE", activationBlockedVersionReportAsIssueValue(ctx))...) + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", ctx.data.Name)) ctx.steps = append(ctx.steps, " with:\n") ctx.steps = append(ctx.steps, " script: |\n") ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("check_version_updates.cjs")) @@ -214,66 +217,87 @@ func frontmatterSkillStepName(skill string, stepNumber int) string { } func (c *Compiler) addActivationSkillInstallSteps(ctx *activationJobBuildContext) { - skillRefs := append([]SkillReference(nil), ctx.data.SkillReferences...) - if len(skillRefs) == 0 && len(ctx.data.Skills) > 0 { - skillRefs = make([]SkillReference, 0, len(ctx.data.Skills)) - for _, skill := range ctx.data.Skills { - if strings.TrimSpace(skill) == "" { - continue - } - skillRefs = append(skillRefs, SkillReference{Skill: skill}) - } - } + skillRefs := activationSkillReferences(ctx.data) if len(skillRefs) == 0 { return } engineID := resolveActivationEngineID(ctx.data) - skillDir := engineConfigBaseDirForRegistry(c.engineRegistry, engineID) + "/skills" + skillDir := path.Join(engineConfigBaseDirForRegistry(c.engineRegistry, engineID), "skills") skillInstallAgentName := "" if engine, err := c.engineRegistry.GetEngine(strings.ToLower(engineID)); err == nil { skillInstallAgentName = engine.GetGHSkillAgentName() } - ctx.steps = append(ctx.steps, " - name: Upgrade gh CLI for frontmatter skills\n") - ctx.steps = append(ctx.steps, fmt.Sprintf(" run: bash \"${RUNNER_TEMP}/gh-aw/actions/ensure_gh_cli_min_version.sh\" \"%s\"\n", constants.GhSkillsMinVersion)) + c.addActivationSkillUpgradeStep(ctx) for i, skillRef := range skillRefs { - tokenExpr := c.resolveActivationToken(ctx.data) - if skillRef.GitHubToken != "" { - tokenExpr = skillRef.GitHubToken - } - if skillRef.GitHubApp != nil { - stepNumber := i + 1 - stepID := fmt.Sprintf("frontmatter-skill-app-token-%d", stepNumber) - ctx.steps = append(ctx.steps, c.buildGitHubAppTokenMintStepWithMeta( - skillRef.GitHubApp, - nil, - "", - "", - fmt.Sprintf("Generate GitHub App token for frontmatter skill %d", stepNumber), - stepID, - )...) - stepTokenExpr := fmt.Sprintf("${{ steps.%s.outputs.token }}", stepID) - if skillRef.GitHubApp.shouldIgnoreMissingKey() { - tokenExpr = combineTokenExpressions(stepTokenExpr, c.resolveActivationToken(ctx.data)) - } else { - tokenExpr = stepTokenExpr - } + c.addActivationSingleSkillInstallStep(ctx, skillRef, i+1, engineID, skillInstallAgentName, skillDir) + } + + c.addActivationSkillFailureCollectionStep(ctx) +} + +func activationSkillReferences(data *WorkflowData) []SkillReference { + skillRefs := append([]SkillReference(nil), data.SkillReferences...) + if len(skillRefs) > 0 || len(data.Skills) == 0 { + return skillRefs + } + skillRefs = make([]SkillReference, 0, len(data.Skills)) + for _, skill := range data.Skills { + if strings.TrimSpace(skill) == "" { + continue } - ctx.steps = append(ctx.steps, fmt.Sprintf(" - name: %s\n", frontmatterSkillStepName(skillRef.Skill, i+1))) - ctx.steps = append(ctx.steps, " env:\n") - ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_TOKEN: %s\n", tokenExpr)) - ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_INFO_ENGINE_ID", engineID)) - ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_GH_SKILL_AGENT_NAME", skillInstallAgentName)) - ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_SKILL_DIR", skillDir)) - ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_FRONTMATTER_SKILLS", skillRef.Skill)) - ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", ctx.data))) - ctx.steps = append(ctx.steps, " with:\n") - ctx.steps = append(ctx.steps, " script: |\n") - ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("install_frontmatter_skills.cjs")) + skillRefs = append(skillRefs, SkillReference{Skill: skill}) } + return skillRefs +} + +func (c *Compiler) addActivationSkillUpgradeStep(ctx *activationJobBuildContext) { + ctx.steps = append(ctx.steps, " - name: Upgrade gh CLI for frontmatter skills\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" run: bash \"${RUNNER_TEMP}/gh-aw/actions/ensure_gh_cli_min_version.sh\" \"%s\"\n", constants.GhSkillsMinVersion)) +} + +func (c *Compiler) addActivationSingleSkillInstallStep(ctx *activationJobBuildContext, skillRef SkillReference, stepNumber int, engineID, skillInstallAgentName, skillDir string) { + tokenExpr := c.resolveFrontmatterSkillToken(ctx, skillRef, stepNumber) + ctx.steps = append(ctx.steps, fmt.Sprintf(" - name: %s\n", frontmatterSkillStepName(skillRef.Skill, stepNumber))) + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_TOKEN: %s\n", tokenExpr)) + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_INFO_ENGINE_ID", engineID)) + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_GH_SKILL_AGENT_NAME", skillInstallAgentName)) + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_SKILL_DIR", skillDir)) + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", "GH_AW_FRONTMATTER_SKILLS", skillRef.Skill)) + ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", ctx.data))) + ctx.steps = append(ctx.steps, " with:\n") + ctx.steps = append(ctx.steps, " script: |\n") + ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("install_frontmatter_skills.cjs")) +} + +func (c *Compiler) resolveFrontmatterSkillToken(ctx *activationJobBuildContext, skillRef SkillReference, stepNumber int) string { + tokenExpr := c.resolveActivationToken(ctx.data) + if skillRef.GitHubToken != "" { + tokenExpr = skillRef.GitHubToken + } + if skillRef.GitHubApp == nil { + return tokenExpr + } + stepID := fmt.Sprintf("frontmatter-skill-app-token-%d", stepNumber) + ctx.steps = append(ctx.steps, c.buildGitHubAppTokenMintStepWithMeta( + skillRef.GitHubApp, + nil, + "", + "", + fmt.Sprintf("Generate GitHub App token for frontmatter skill %d", stepNumber), + stepID, + )...) + stepTokenExpr := fmt.Sprintf("${{ steps.%s.outputs.token }}", stepID) + if skillRef.GitHubApp.shouldIgnoreMissingKey() { + return combineTokenExpressions(stepTokenExpr, c.resolveActivationToken(ctx.data)) + } + return stepTokenExpr +} +func (c *Compiler) addActivationSkillFailureCollectionStep(ctx *activationJobBuildContext) { // Collect skill install failures written by each install step into a shared file. // Runs with if: always() so failures are captured even if a prior step was unexpectedly hard-failed. ctx.steps = append(ctx.steps, " - name: Collect skill install failures\n") diff --git a/pkg/workflow/compiler_activation_steps_test.go b/pkg/workflow/compiler_activation_steps_test.go index 2ac9059fb2f..7ca108e69b8 100644 --- a/pkg/workflow/compiler_activation_steps_test.go +++ b/pkg/workflow/compiler_activation_steps_test.go @@ -257,9 +257,60 @@ func TestActivationStepsAddVersionCheckStep(t *testing.T) { steps := strings.Join(ctx.steps, "") assert.Contains(t, steps, "Check compile-agentic version") assert.Contains(t, steps, "GH_AW_COMPILED_VERSION: \"v1.2.3\"") + assert.Contains(t, steps, "GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE: \"true\"") + assert.Contains(t, steps, "GH_AW_WORKFLOW_NAME: \"\"") assert.Contains(t, steps, "check_version_updates.cjs") }) + t.Run("disables blocked version issue reporting when failure issues are disabled", func(t *testing.T) { + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + compiler := newActivationStepsTestCompiler("v1.2.3") + ctx := newActivationStepsTestContext(&WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ReportFailureAsIssue: templatableBoolPtr("false")}, + }) + + compiler.addActivationVersionCheckStep(ctx) + + steps := strings.Join(ctx.steps, "") + assert.Contains(t, steps, "GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE: \"false\"") + }) + + t.Run("disables blocked version issue reporting when on.report-blocked-version is false", func(t *testing.T) { + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + compiler := newActivationStepsTestCompiler("v1.2.3") + ctx := newActivationStepsTestContext(&WorkflowData{ + ReportBlockedVersionDisabled: true, + }) + + compiler.addActivationVersionCheckStep(ctx) + + steps := strings.Join(ctx.steps, "") + assert.Contains(t, steps, "GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE: \"false\"") + }) + + t.Run("report-blocked-version: false overrides a templated report-failure-as-issue value", func(t *testing.T) { + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + compiler := newActivationStepsTestCompiler("v1.2.3") + ctx := newActivationStepsTestContext(&WorkflowData{ + ReportBlockedVersionDisabled: true, + SafeOutputs: &SafeOutputsConfig{ReportFailureAsIssue: templatableBoolPtr("${{ inputs.report-failure-as-issue }}")}, + }) + + compiler.addActivationVersionCheckStep(ctx) + + steps := strings.Join(ctx.steps, "") + assert.Contains(t, steps, "GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE: \"false\"") + }) + t.Run("skips version check for dev builds", func(t *testing.T) { compiler := newActivationStepsTestCompiler("dev") ctx := newActivationStepsTestContext(&WorkflowData{}) @@ -270,6 +321,46 @@ func TestActivationStepsAddVersionCheckStep(t *testing.T) { }) } +func TestActivationBlockedVersionIssuePermissions(t *testing.T) { + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + t.Run("adds issues write for released compiler version checks", func(t *testing.T) { + compiler := newActivationStepsTestCompiler("v1.2.3") + ctx := newActivationStepsTestContext(&WorkflowData{}) + + permissions, err := compiler.buildActivationPermissions(ctx) + + require.NoError(t, err) + assert.Contains(t, permissions, "issues: write") + }) + + t.Run("omits issues write when failure issue reporting is disabled", func(t *testing.T) { + compiler := newActivationStepsTestCompiler("v1.2.3") + ctx := newActivationStepsTestContext(&WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ReportFailureAsIssue: templatableBoolPtr("false")}, + }) + + permissions, err := compiler.buildActivationPermissions(ctx) + + require.NoError(t, err) + assert.NotContains(t, permissions, "issues: write") + }) + + t.Run("omits issues write when on.report-blocked-version is false", func(t *testing.T) { + compiler := newActivationStepsTestCompiler("v1.2.3") + ctx := newActivationStepsTestContext(&WorkflowData{ + ReportBlockedVersionDisabled: true, + }) + + permissions, err := compiler.buildActivationPermissions(ctx) + + require.NoError(t, err) + assert.NotContains(t, permissions, "issues: write") + }) +} + func TestActivationStepsAddSkillInstallSteps(t *testing.T) { compiler := newActivationStepsTestCompiler("") diff --git a/pkg/workflow/event_validation.go b/pkg/workflow/event_validation.go index 7480e2c26d8..8bb960efa39 100644 --- a/pkg/workflow/event_validation.go +++ b/pkg/workflow/event_validation.go @@ -90,6 +90,7 @@ var ghAwOnSectionKeys = map[string]bool{ "labels": true, "needs": true, "reaction": true, + "report-blocked-version": true, "roles": true, "skip-author-associations": true, "skip-if-match": true, diff --git a/pkg/workflow/frontmatter_on_section_cleanup.go b/pkg/workflow/frontmatter_on_section_cleanup.go index c90fae67152..bf12efe6fb0 100644 --- a/pkg/workflow/frontmatter_on_section_cleanup.go +++ b/pkg/workflow/frontmatter_on_section_cleanup.go @@ -7,7 +7,7 @@ import ( "github.com/github/gh-aw/pkg/setutil" ) -// commentOutProcessedFieldsInOnSection comments out draft, max-stack, fork, forks, names, labels, manual-approval, cooldown, stop-after, skip-if-match, skip-if-no-match, skip-roles, reaction, lock-for-agent, steps, permissions, needs, restore-memory, and stale-check fields in the on section +// commentOutProcessedFieldsInOnSection comments out draft, max-stack, fork, forks, names, labels, manual-approval, cooldown, stop-after, skip-if-match, skip-if-no-match, skip-roles, reaction, lock-for-agent, steps, permissions, needs, restore-memory, stale-check, and report-blocked-version fields in the on section // These fields are processed separately and should be commented for documentation // Exception: names fields in sections with __gh_aw_native_label_filter__ marker in frontmatter are NOT commented out func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmatter map[string]any) string { @@ -409,6 +409,8 @@ func (s *onSectionCleanupState) commentSimpleTopLevelField(info onSectionLine) ( return true, " # GitHub token used for reactions and status comments in activation" case strings.HasPrefix(info.trimmed, "stale-check:"): return true, " # Stale-check processed as frontmatter hash check step in activation job" + case strings.HasPrefix(info.trimmed, "report-blocked-version:"): + return true, " # Report-blocked-version processed as activation-stage notification toggle" default: return false, "" } diff --git a/pkg/workflow/report_blocked_version_test.go b/pkg/workflow/report_blocked_version_test.go new file mode 100644 index 00000000000..4582f0d4b8d --- /dev/null +++ b/pkg/workflow/report_blocked_version_test.go @@ -0,0 +1,110 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReportBlockedVersionFrontmatterFlag tests that on.report-blocked-version: false +// suppresses the blocked-version notification issue (env var + issues: write permission) +// without disabling the version check step itself, unlike check-for-updates: false. +func TestReportBlockedVersionFrontmatterFlag(t *testing.T) { + baseWorkflowMD := `--- +engine: copilot +on: + issues: + types: [opened] +--- +Test workflow for blocked-version reporting. +` + disabledWorkflowMD := `--- +engine: copilot +on: + issues: + types: [opened] + report-blocked-version: false +--- +Test workflow for blocked-version reporting disabled. +` + enabledExplicitWorkflowMD := `--- +engine: copilot +on: + issues: + types: [opened] + report-blocked-version: true +--- +Test workflow for blocked-version reporting explicitly enabled. +` + + tests := []struct { + name string + workflowMD string + wantReportTrue bool + }{ + { + name: "reporting enabled when report-blocked-version not set (default)", + workflowMD: baseWorkflowMD, + wantReportTrue: true, + }, + { + name: "reporting disabled when report-blocked-version: false", + workflowMD: disabledWorkflowMD, + wantReportTrue: false, + }, + { + name: "reporting enabled when report-blocked-version: true explicitly", + workflowMD: enabledExplicitWorkflowMD, + wantReportTrue: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "report-blocked-version-test") + testFile := filepath.Join(tmpDir, "test-workflow.md") + require.NoError(t, os.WriteFile(testFile, []byte(tt.workflowMD), 0644), "Should write workflow file") + + compiler := NewCompiler(WithVersion("v1.2.3")) + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + err := compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Workflow should compile without errors") + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err, "Lock file should be readable") + lockStr := string(lockContent) + + // The version check step itself must always be present; only the + // notification issue reporting is toggled by report-blocked-version. + assert.Contains(t, lockStr, "Check compile-agentic version", + "Version check step should always be present regardless of report-blocked-version") + + if tt.wantReportTrue { + assert.Contains(t, lockStr, "GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE: \"true\"") + assert.Contains(t, lockStr, "issues: write") + } else { + assert.Contains(t, lockStr, "GH_AW_BLOCKED_VERSION_REPORT_AS_ISSUE: \"false\"") + } + + // Verify report-blocked-version is commented out in the generated lock file when present + if strings.Contains(tt.workflowMD, "report-blocked-version:") { + assert.NotContains(t, lockStr, "\n report-blocked-version:", + "report-blocked-version should be commented out in the lock file, not left as an active YAML key") + assert.Contains(t, lockStr, "# report-blocked-version:", + "report-blocked-version should appear as a comment in the lock file") + } + }) + } +} diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 093c1967c1f..10c29211503 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -201,6 +201,9 @@ func (c *Compiler) buildInitialWorkflowData( // Populate stale-check flag: disabled when on.stale-check: false is set in frontmatter; // full mode when on.stale-check: full is set. + // Populate report-blocked-version flag: disabled when on.report-blocked-version: false is + // set in frontmatter (suppresses only the activation-stage notification issue, independent + // of check-for-updates and safe-outputs.report-failure-as-issue). if onVal, ok := result.Frontmatter["on"]; ok { if onMap, ok := onVal.(map[string]any); ok { if staleCheck, ok := onMap["stale-check"]; ok { @@ -210,6 +213,11 @@ func (c *Compiler) buildInitialWorkflowData( workflowData.StaleCheckFull = true } } + if reportBlockedVersion, ok := onMap["report-blocked-version"]; ok { + if boolVal, ok := reportBlockedVersion.(bool); ok && !boolVal { + workflowData.ReportBlockedVersionDisabled = true + } + } } } diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index 6976552913b..05917fac461 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -189,6 +189,7 @@ type WorkflowData struct { UpdateCheckDisabled bool // true when check-for-updates: false is set in frontmatter (disables version check step in activation job) StaleCheckDisabled bool // true when on.stale-check: false is set in frontmatter (disables frontmatter hash check step in activation job) StaleCheckFull bool // true when on.stale-check: full is set in frontmatter (enables body hash check alongside frontmatter hash check) + ReportBlockedVersionDisabled bool // true when on.report-blocked-version: false is set in frontmatter (disables the blocked-version notification issue in activation, independent of check-for-updates and safe-outputs.report-failure-as-issue) EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps ServicePortExpressions string // comma-separated ${{ job.services[''].ports[''] }} expressions for AWF --allow-host-service-ports RunInstallScripts bool // true when runtimes.node.run-install-scripts: true is set (main workflow and/or imports); disables --ignore-scripts on generated npm install steps