diff --git a/.github/aw/schemas/agentic-workflow.json b/.github/aw/schemas/agentic-workflow.json index 8212fc1068..bf7e90070c 100644 --- a/.github/aw/schemas/agentic-workflow.json +++ b/.github/aw/schemas/agentic-workflow.json @@ -3441,8 +3441,8 @@ }, { "type": "string", - "pattern": "^[0-9]+[dDwWmMyY]$", - "description": "Relative time (e.g., '7d', '2w', '1m', '1y')" + "pattern": "^[0-9]+[hHdDwWmMyY]$", + "description": "Relative time (e.g., '2h', '7d', '2w', '1m', '1y')" } ], "description": "Time until the issue expires and should be automatically closed. Supports integer (days) or relative time format. When set, a maintenance workflow will be generated." @@ -3599,11 +3599,11 @@ }, { "type": "string", - "pattern": "^[0-9]+[dDwWmMyY]$", - "description": "Relative time (e.g., '7d', '2w', '1m', '1y')" + "pattern": "^[0-9]+[hHdDwWmMyY]$", + "description": "Relative time (e.g., '2h', '7d', '2w', '1m', '1y')" } ], - "description": "Time until the discussion expires and should be automatically closed. Supports integer (days) or relative time format like '7d' (7 days), '2w' (2 weeks), '1m' (1 month), '1y' (1 year). When set, a maintenance workflow will be generated." + "description": "Time until the discussion expires and should be automatically closed. Supports integer (days) or relative time format like '2h' (2 hours), '7d' (7 days), '2w' (2 weeks), '1m' (1 month), '1y' (1 year). When set, a maintenance workflow will be generated." } }, "additionalProperties": false, @@ -3969,8 +3969,8 @@ }, { "type": "string", - "pattern": "^[0-9]+[dDwWmMyY]$", - "description": "Relative time (e.g., '7d', '2w', '1m', '1y')" + "pattern": "^[0-9]+[hHdDwWmMyY]$", + "description": "Relative time (e.g., '2h', '7d', '2w', '1m', '1y')" } ], "description": "Time until the pull request expires and should be automatically closed (only for same-repo PRs without target-repo). Supports integer (days) or relative time format." diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 73536b1d18..89e6705a8d 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -27,12 +27,13 @@ # # The workflow is generated when any workflow uses the 'expires' field # in create-discussions or create-issues safe-outputs configuration. +# Schedule frequency is automatically determined by the shortest expiration time. # name: Agentics Maintenance on: schedule: - - cron: "0 0 * * *" # Daily at midnight UTC + - cron: "37 */2 * * *" # Every 2 hours (based on minimum expires: 1 days) workflow_dispatch: permissions: {} diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index e0a4c91c3f..f1609648e0 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -39,7 +39,7 @@ safe-outputs: add-comment: hide-older-comments: true create-issue: - expires: 1d + expires: 2h add-labels: allowed: [smoke-claude] messages: diff --git a/.github/workflows/smoke-codex-firewall.md b/.github/workflows/smoke-codex-firewall.md index d0a56e964d..8f900923c0 100644 --- a/.github/workflows/smoke-codex-firewall.md +++ b/.github/workflows/smoke-codex-firewall.md @@ -22,7 +22,7 @@ safe-outputs: add-comment: hide-older-comments: true create-issue: - expires: 1d + expires: 2h add-labels: allowed: [smoke-codex-firewall] hide-comment: diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index 1409eb3cf5..1a0bdd0408 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -33,7 +33,7 @@ safe-outputs: add-comment: hide-older-comments: true create-issue: - expires: 1d + expires: 2h add-labels: allowed: [smoke-codex] hide-comment: diff --git a/.github/workflows/smoke-copilot-playwright.md b/.github/workflows/smoke-copilot-playwright.md index cb5bc93a4c..23663efe0e 100644 --- a/.github/workflows/smoke-copilot-playwright.md +++ b/.github/workflows/smoke-copilot-playwright.md @@ -47,7 +47,7 @@ safe-outputs: add-comment: hide-older-comments: true create-issue: - expires: 1d + expires: 2h add-labels: allowed: [smoke-copilot] messages: diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 3a11270914..d80ba64737 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -30,7 +30,7 @@ safe-outputs: add-comment: hide-older-comments: true create-issue: - expires: 1d + expires: 2h add-labels: allowed: [smoke-copilot] messages: diff --git a/.github/workflows/smoke-detector.md b/.github/workflows/smoke-detector.md index 205c55bd5a..64a85c3276 100644 --- a/.github/workflows/smoke-detector.md +++ b/.github/workflows/smoke-detector.md @@ -39,7 +39,7 @@ safe-outputs: target: "*" hide-older-comments: true create-issue: - expires: 1d + expires: 2h title-prefix: "[smoke-detector] " labels: [smoke-test, investigation] messages: diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index d8aa3f44ff..346034e0a6 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -559,7 +559,6 @@ async function main(config = {}) { // Add metadata for tracking (includes comment ID, item number, and repo info) // This is used by the handler manager to track comments with unresolved temp IDs try { - // @ts-ignore - Add tracking metadata to comment object (works with both REST and GraphQL responses) comment._tracking = { commentId: comment.id, itemNumber: itemNumber, diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index 20e86f6505..61d30cbfd5 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -5,7 +5,7 @@ const { processSafeOutput } = require("./safe_output_processor.cjs"); const { validateLabels } = require("./safe_output_validator.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -async function main(handlerConfig = {}) { +async function main(config = {}) { // Use shared processor for common steps const result = await processSafeOutput( { @@ -17,9 +17,9 @@ async function main(handlerConfig = {}) { supportsIssue: true, envVars: { // Config values now passed via config object, not env vars - allowed: undefined, - maxCount: undefined, - target: undefined, + allowed: null, + maxCount: null, + target: null, }, }, { @@ -38,7 +38,7 @@ async function main(handlerConfig = {}) { return content; }, }, - handlerConfig // Pass handler config as third parameter + config // Pass handler config as third parameter ); if (!result.success) { diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 20d4592b1d..76eab6cf9d 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -99,7 +99,7 @@ async function loadHandlers(config) { * * @param {Map} messageHandlers - Map of message handler functions * @param {Array} messages - Array of safe output messages - * @returns {Promise<{success: boolean, results: Array, temporaryIdMap: Object, outputsWithUnresolvedIds: Array}>} + * @returns {Promise<{success: boolean, results: Array, temporaryIdMap: Map, pendingUpdates: Array}>} */ async function processMessages(messageHandlers, messages) { const results = []; @@ -387,7 +387,6 @@ async function processSyntheticUpdates(github, context, trackedOutputs, temporar const contentToCheck = getContentToCheck(tracked.type, tracked.message); // Check if the content still has unresolved IDs (some may now be resolved) - // @ts-ignore - hasUnresolvedTemporaryIds handles null values const stillHasUnresolved = hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap); const resolvedCount = temporaryIdMap.size - tracked.originalTempIdMapSize; @@ -398,7 +397,6 @@ async function processSyntheticUpdates(github, context, trackedOutputs, temporar try { // Replace temporary ID references with resolved values - // @ts-ignore - replaceTemporaryIdReferences handles null values const updatedContent = replaceTemporaryIdReferences(contentToCheck, temporaryIdMap, tracked.result.repo); // Update based on the original type diff --git a/actions/setup/js/update_runner.cjs b/actions/setup/js/update_runner.cjs index a70efb7c00..cc1df2563b 100644 --- a/actions/setup/js/update_runner.cjs +++ b/actions/setup/js/update_runner.cjs @@ -30,9 +30,8 @@ const { getErrorMessage } = require("./error_helpers.cjs"); * @property {boolean} supportsStatus - Whether this type supports status updates * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace) * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview - * @property {(github: any, context: any, targetNumber: number, updateData: any, handlerConfig?: any) => Promise} executeUpdate - Function to execute the update API call + * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item - * @property {any} [handlerConfig] - Optional handler configuration object passed from handler manager */ /** diff --git a/docs/src/content/docs/guides/security.md b/docs/src/content/docs/guides/security.md index 8c6d07805c..ef54d0822b 100644 --- a/docs/src/content/docs/guides/security.md +++ b/docs/src/content/docs/guides/security.md @@ -248,6 +248,52 @@ mcp-servers: The compiler generates per-tool Squid proxies; MCP egress is forced through iptables. Only listed domains are reachable. Applies to `mcp.container` stdio servers only. +#### Automatic GitHub Lockdown on Public Repositories + +When using the GitHub MCP tool in public repositories, lockdown mode is **automatically enabled by default** to prevent accidental data leakage. This security feature restricts the GitHub token from accessing private repositories, ensuring that workflows running in public repositories cannot inadvertently expose sensitive information. + +**How Automatic Detection Works:** + +The system automatically detects repository visibility at workflow runtime: + +- **Public repositories**: Lockdown mode is automatically enabled. The GitHub MCP server limits surfaced content to items authored by users with push access to the repository. +- **Private/internal repositories**: Lockdown mode is automatically disabled since there's no risk of exposing private repository access. +- **Detection failure**: If repository visibility cannot be determined, the system defaults to lockdown mode for maximum security. + +**No Configuration Required:** + +```yaml wrap +tools: + github: + # Lockdown is automatically enabled for public repos + # No explicit configuration needed +``` + +**Manual Override (Optional):** + +You can explicitly set lockdown mode if needed: + +```yaml wrap +tools: + github: + lockdown: true # Force enable lockdown + # or + lockdown: false # Explicitly disable (use with caution in public repos) +``` + +:::caution[Disabling Lockdown in Public Repositories] +Explicitly setting `lockdown: false` in a public repository disables this security protection. Only do this if you fully understand the implications and have other controls in place to prevent data leakage. +::: + +**Security Benefits:** + +- **Prevents token scope leakage**: Even if a GitHub token has access to private repositories, lockdown mode prevents that access from being used in public repository workflows +- **Defense in depth**: Adds an additional layer of protection beyond token scoping +- **Automatic and transparent**: Works without any configuration changes +- **Safe by default**: Failures default to the most secure setting + +See also: [GitHub MCP Tool Configuration](/gh-aw/reference/tools/#github-tools-github) for complete tool configuration options. + ### Agent Security and Prompt Injection Defense #### Sanitized Context Text Usage diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 9372d61260..45953b6749 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1147,7 +1147,9 @@ tools: read-only: true # Enable lockdown mode to limit content surfaced from public repositories (only - # items authored by users with push access). Default: false + # items authored by users with push access) + # Default: Automatically enabled for public repositories, disabled for private/internal repositories + # Set explicitly to override automatic detection # (optional) lockdown: true @@ -1604,7 +1606,7 @@ safe-outputs: # Option 1: Number of days until expires expires: 1 - # Option 2: Relative time (e.g., '7d', '2w', '1m', '1y') + # Option 2: Relative time (e.g., '2h', '7d', '2w', '1m', '1y'; hours <24 = 1 day) expires: "example-value" # Option 2: Enable issue creation with default configuration @@ -1737,7 +1739,7 @@ safe-outputs: # Option 1: Number of days until expires expires: 1 - # Option 2: Relative time (e.g., '7d', '2w', '1m', '1y') + # Option 2: Relative time (e.g., '2h', '7d', '2w', '1m', '1y'; hours <24 = 1 day) expires: "example-value" # Option 2: Enable discussion creation with default configuration @@ -2027,7 +2029,7 @@ safe-outputs: # Option 1: Number of days until expires expires: 1 - # Option 2: Relative time (e.g., '7d', '2w', '1m', '1y') + # Option 2: Relative time (e.g., '2h', '7d', '2w', '1m', '1y'; hours <24 = 1 day) expires: "example-value" # Option 2: Enable pull request creation with default configuration diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index e280351644..0d03e2b53f 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -21,36 +21,57 @@ The agent requests issue creation; a separate job with `issues: write` creates i ## Available Safe Output Types -| Output Type | Key | Description | Max | Cross-Repo | -|-------------|-----|-------------|-----|-----------| -| [**Create Issue**](#issue-creation-create-issue) | `create-issue:` | Create GitHub issues | 1 | ✅ | -| [**Close Issue**](#close-issue-close-issue) | `close-issue:` | Close issues with comment | 1 | ✅ | -| [**Add Comment**](#comment-creation-add-comment) | `add-comment:` | Post comments on issues, PRs, or discussions | 1 | ✅ | -| [**Hide Comment**](#hide-comment-hide-comment) | `hide-comment:` | Hide comments on issues, PRs, or discussions | 5 | ✅ | -| [**Update Issue**](#issue-updates-update-issue) | `update-issue:` | Update issue status, title, or body | 1 | ✅ | -| [**Update PR**](#pull-request-updates-update-pull-request) | `update-pull-request:` | Update PR title or body | 1 | ✅ | -| [**Link Sub-Issue**](#link-sub-issue-link-sub-issue) | `link-sub-issue:` | Link issues as sub-issues | 1 | ✅ | -| [**Update Project**](#project-board-updates-update-project) | `update-project:` | Manage GitHub Projects boards and campaign labels | 10 | ❌ | -| [**Add Labels**](#add-labels-add-labels) | `add-labels:` | Add labels to issues or PRs | 3 | ✅ | -| [**Add Reviewer**](#add-reviewer-add-reviewer) | `add-reviewer:` | Add reviewers to pull requests | 3 | ✅ | -| [**Assign Milestone**](#assign-milestone-assign-milestone) | `assign-milestone:` | Assign issues to milestones | 1 | ✅ | -| [**Create PR**](#pull-request-creation-create-pull-request) | `create-pull-request:` | Create pull requests with code changes | 1 | ✅ | -| [**Close PR**](#close-pull-request-close-pull-request) | `close-pull-request:` | Close pull requests without merging | 10 | ✅ | -| [**PR Review Comments**](#pr-review-comments-create-pull-request-review-comment) | `create-pull-request-review-comment:` | Create review comments on code lines | 10 | ✅ | -| [**Create Discussion**](#discussion-creation-create-discussion) | `create-discussion:` | Create GitHub discussions | 1 | ✅ | -| [**Close Discussion**](#close-discussion-close-discussion) | `close-discussion:` | Close discussions with comment and resolution | 1 | ✅ | -| [**Update Discussion**](#discussion-updates-update-discussion) | `update-discussion:` | Update discussion title, body, or labels | 1 | ✅ | -| [**Create Agent Task**](#agent-task-creation-create-agent-task) | `create-agent-task:` | Create Copilot agent tasks | 1 | ✅ | -| [**Assign to Agent**](#assign-to-agent-assign-to-agent) | `assign-to-agent:` | Assign Copilot agents to issues | 1 | ✅ | -| [**Assign to User**](#assign-to-user-assign-to-user) | `assign-to-user:` | Assign users to issues | 1 | ✅ | -| [**Push to PR Branch**](#push-to-pr-branch-push-to-pull-request-branch) | `push-to-pull-request-branch:` | Push changes to PR branch | 1 | ❌ | -| [**Update Release**](#release-updates-update-release) | `update-release:` | Update GitHub release descriptions | 1 | ✅ | -| [**Upload Assets**](#asset-uploads-upload-asset) | `upload-asset:` | Upload files to orphaned git branch | 10 | ❌ | -| [**Code Scanning Alerts**](#code-scanning-alerts-create-code-scanning-alert) | `create-code-scanning-alert:` | Generate SARIF security advisories | unlimited | ❌ | -| [**No-Op**](#no-op-logging-noop) | `noop:` | Log completion message for transparency (auto-enabled) | 1 | ❌ | -| [**Missing Tool**](#missing-tool-reporting-missing-tool) | `missing-tool:` | Report missing tools (auto-enabled) | unlimited | ❌ | - -Custom safe output types: [Custom Safe Output Jobs](/gh-aw/guides/custom-safe-outputs/). +:::note +Most safe output types support cross-repository operations. Exceptions are noted below. +::: + +### Issues & Discussions + +- [**Create Issue**](#issue-creation-create-issue) (`create-issue`) — Create GitHub issues (max: 1) +- [**Update Issue**](#issue-updates-update-issue) (`update-issue`) — Update issue status, title, or body (max: 1) +- [**Close Issue**](#close-issue-close-issue) (`close-issue`) — Close issues with comment (max: 1) +- [**Link Sub-Issue**](#link-sub-issue-link-sub-issue) (`link-sub-issue`) — Link issues as sub-issues (max: 1) +- [**Create Discussion**](#discussion-creation-create-discussion) (`create-discussion`) — Create GitHub discussions (max: 1) +- [**Update Discussion**](#discussion-updates-update-discussion) (`update-discussion`) — Update discussion title, body, or labels (max: 1) +- [**Close Discussion**](#close-discussion-close-discussion) (`close-discussion`) — Close discussions with comment and resolution (max: 1) + +### Pull Requests + +- [**Create PR**](#pull-request-creation-create-pull-request) (`create-pull-request`) — Create pull requests with code changes (max: 1) +- [**Update PR**](#pull-request-updates-update-pull-request) (`update-pull-request`) — Update PR title or body (max: 1) +- [**Close PR**](#close-pull-request-close-pull-request) (`close-pull-request`) — Close pull requests without merging (max: 10) +- [**PR Review Comments**](#pr-review-comments-create-pull-request-review-comment) (`create-pull-request-review-comment`) — Create review comments on code lines (max: 10) +- [**Push to PR Branch**](#push-to-pr-branch-push-to-pull-request-branch) (`push-to-pull-request-branch`) — Push changes to PR branch (max: 1, same-repo only) + +### Labels, Assignments & Reviews + +- [**Add Comment**](#comment-creation-add-comment) (`add-comment`) — Post comments on issues, PRs, or discussions (max: 1) +- [**Hide Comment**](#hide-comment-hide-comment) (`hide-comment`) — Hide comments on issues, PRs, or discussions (max: 5) +- [**Add Labels**](#add-labels-add-labels) (`add-labels`) — Add labels to issues or PRs (max: 3) +- [**Add Reviewer**](#add-reviewer-add-reviewer) (`add-reviewer`) — Add reviewers to pull requests (max: 3) +- [**Assign Milestone**](#assign-milestone-assign-milestone) (`assign-milestone`) — Assign issues to milestones (max: 1) +- [**Assign to Agent**](#assign-to-agent-assign-to-agent) (`assign-to-agent`) — Assign Copilot agents to issues (max: 1) +- [**Assign to User**](#assign-to-user-assign-to-user) (`assign-to-user`) — Assign users to issues (max: 1) + +### Projects, Releases & Assets + +- [**Update Project**](#project-board-updates-update-project) (`update-project`) — Manage GitHub Projects boards (max: 10, same-repo only) +- [**Update Release**](#release-updates-update-release) (`update-release`) — Update GitHub release descriptions (max: 1) +- [**Upload Assets**](#asset-uploads-upload-asset) (`upload-asset`) — Upload files to orphaned git branch (max: 10, same-repo only) + +### Security & Agent Tasks + +- [**Code Scanning Alerts**](#code-scanning-alerts-create-code-scanning-alert) (`create-code-scanning-alert`) — Generate SARIF security advisories (max: unlimited, same-repo only) +- [**Create Agent Task**](#agent-task-creation-create-agent-task) (`create-agent-task`) — Create Copilot agent tasks (max: 1) + +### System Types (Auto-Enabled) + +- [**No-Op**](#no-op-logging-noop) (`noop`) — Log completion message for transparency (max: 1, same-repo only) +- [**Missing Tool**](#missing-tool-reporting-missing-tool) (`missing-tool`) — Report missing tools (max: unlimited, same-repo only) + +:::tip +Custom safe output types: [Custom Safe Output Jobs](/gh-aw/guides/custom-safe-outputs/) +::: ### Custom Safe Output Jobs (`jobs:`) @@ -73,7 +94,7 @@ safe-outputs: #### Auto-Expiration -The `expires` field auto-closes issues after a time period. Supports integers (days) or relative formats: `7d`, `2w`, `1m`, `1y`. Generates daily `agentics-maintenance.yml` workflow to close expired items. +The `expires` field auto-closes issues after a time period. Supports integers (days) or relative formats: `2h`, `7d`, `2w`, `1m`, `1y`. Generates daily `agentics-maintenance.yml` workflow to close expired items. Note: Hours less than 24 are treated as 1 day minimum since the maintenance workflow runs daily. #### Temporary IDs for Issue References @@ -237,7 +258,7 @@ Agent must provide full project URL (e.g., `https://github.com/orgs/myorg/projec ### Pull Request Creation (`create-pull-request:`) -Creates PRs with code changes. Falls back to issue if creation fails (e.g., org settings block it). `expires` field (same-repo only) auto-closes after period: integers (days) or `7d`, `2w`, `1m`, `1y`. +Creates PRs with code changes. Falls back to issue if creation fails (e.g., org settings block it). `expires` field (same-repo only) auto-closes after period: integers (days) or `2h`, `7d`, `2w`, `1m`, `1y` (hours < 24 treated as 1 day). ```yaml wrap safe-outputs: @@ -367,7 +388,7 @@ safe-outputs: ### Discussion Creation (`create-discussion:`) -Creates discussions with optional `category` (slug, name, or ID; defaults to first available). `expires` field auto-closes after period (integers or `7d`, `2w`, `1m`, `1y`) as "OUTDATED" with comment. Generates daily maintenance workflow. +Creates discussions with optional `category` (slug, name, or ID; defaults to first available). `expires` field auto-closes after period (integers or `2h`, `7d`, `2w`, `1m`, `1y`, hours < 24 treated as 1 day) as "OUTDATED" with comment. Generates daily maintenance workflow. ```yaml wrap safe-outputs: diff --git a/docs/src/content/docs/reference/tools.md b/docs/src/content/docs/reference/tools.md index ec1d0e61f6..d6c5cd743c 100644 --- a/docs/src/content/docs/reference/tools.md +++ b/docs/src/content/docs/reference/tools.md @@ -110,14 +110,25 @@ Setup: `gh aw secrets set GH_AW_GITHUB_TOKEN --value ""` **Read-Only**: Default behavior; restricts to read operations unless write operations configured. -**Lockdown**: Filter public repository content to items from users with push access. Private repos unaffected: +**Lockdown**: Automatically enabled for public repositories to prevent accidental data leakage. Filters public repository content to items from users with push access. Private repositories are unaffected. + +- **Automatic (default)**: Lockdown is automatically enabled for public repositories and disabled for private/internal repositories +- **Manual override**: Explicitly set `lockdown: true` or `lockdown: false` to override automatic detection ```yaml wrap tools: github: - lockdown: true + # Option 1: Automatic (recommended) - no configuration needed + # Lockdown automatically enabled for public repos + + # Option 2: Explicit override + lockdown: true # Force enable + # or + lockdown: false # Explicitly disable (use with caution in public repos) ``` +See [Automatic GitHub Lockdown](/gh-aw/guides/security/#automatic-github-lockdown-on-public-repositories) for security implications. + ## Playwright Tool (`playwright:`) Enables containerized browser automation with domain-based access control: diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css index a449be49b9..806f43fb7a 100644 --- a/docs/src/styles/custom.css +++ b/docs/src/styles/custom.css @@ -998,20 +998,38 @@ html { display: none !important; } - /* Force theme toggle to stay visible on mobile */ - header starlight-theme-select, - .header starlight-theme-select, - starlight-theme-select { + /* Force theme toggle and social icons to stay visible on mobile ONLY on landing page (with hero) */ + body:has(.hero) header starlight-theme-select, + body:has(.hero) .header starlight-theme-select, + body:has(.hero) starlight-theme-select { display: flex !important; visibility: visible !important; opacity: 1 !important; } + body:has(.hero) .social-icons { + display: flex !important; + visibility: visible !important; + } + + /* Hide theme toggle and social icons on mobile for non-landing pages (without hero) to prevent overlap with TOC button */ + body:not(:has(.hero)) header starlight-theme-select, + body:not(:has(.hero)) .header starlight-theme-select, + body:not(:has(.hero)) starlight-theme-select { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + } + + body:not(:has(.hero)) .social-icons { + display: none !important; + visibility: hidden !important; + } + /* Override Starlight's hiding of header right side on mobile */ header .sl-flex, header .right-group, - header [class*="right"], - .social-icons { + header [class*="right"] { display: flex !important; visibility: visible !important; } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 8212fc1068..bf7e90070c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3441,8 +3441,8 @@ }, { "type": "string", - "pattern": "^[0-9]+[dDwWmMyY]$", - "description": "Relative time (e.g., '7d', '2w', '1m', '1y')" + "pattern": "^[0-9]+[hHdDwWmMyY]$", + "description": "Relative time (e.g., '2h', '7d', '2w', '1m', '1y')" } ], "description": "Time until the issue expires and should be automatically closed. Supports integer (days) or relative time format. When set, a maintenance workflow will be generated." @@ -3599,11 +3599,11 @@ }, { "type": "string", - "pattern": "^[0-9]+[dDwWmMyY]$", - "description": "Relative time (e.g., '7d', '2w', '1m', '1y')" + "pattern": "^[0-9]+[hHdDwWmMyY]$", + "description": "Relative time (e.g., '2h', '7d', '2w', '1m', '1y')" } ], - "description": "Time until the discussion expires and should be automatically closed. Supports integer (days) or relative time format like '7d' (7 days), '2w' (2 weeks), '1m' (1 month), '1y' (1 year). When set, a maintenance workflow will be generated." + "description": "Time until the discussion expires and should be automatically closed. Supports integer (days) or relative time format like '2h' (2 hours), '7d' (7 days), '2w' (2 weeks), '1m' (1 month), '1y' (1 year). When set, a maintenance workflow will be generated." } }, "additionalProperties": false, @@ -3969,8 +3969,8 @@ }, { "type": "string", - "pattern": "^[0-9]+[dDwWmMyY]$", - "description": "Relative time (e.g., '7d', '2w', '1m', '1y')" + "pattern": "^[0-9]+[hHdDwWmMyY]$", + "description": "Relative time (e.g., '2h', '7d', '2w', '1m', '1y')" } ], "description": "Time until the pull request expires and should be automatically closed (only for same-repo PRs without target-repo). Supports integer (days) or relative time format." diff --git a/pkg/workflow/config_helpers.go b/pkg/workflow/config_helpers.go index 1552553baa..4de411a892 100644 --- a/pkg/workflow/config_helpers.go +++ b/pkg/workflow/config_helpers.go @@ -170,9 +170,10 @@ func parseAllowedLabelsFromConfig(configMap map[string]any) []string { } // parseExpiresFromConfig parses expires value from config map -// Supports both integer (days) and string formats like "7d", "2w", "1m", "1y" +// Supports both integer (days) and string formats like "2h", "7d", "2w", "1m", "1y" // Returns the number of days, or 0 if invalid or not present // Note: For uint64 values, returns 0 if the value would overflow int. +// Note: Hours less than 24 are treated as 1 day minimum since maintenance runs daily func parseExpiresFromConfig(configMap map[string]any) int { configHelpersLog.Printf("DEBUG: parseExpiresFromConfig called with configMap: %+v", configMap) if expires, exists := configMap["expires"]; exists { @@ -193,7 +194,7 @@ func parseExpiresFromConfig(configMap map[string]any) int { } return int(v) case string: - // Parse relative time specification like "7d", "2w", "1m", "1y" + // Parse relative time specification like "2h", "7d", "2w", "1m", "1y" return parseRelativeTimeSpec(v) } } @@ -201,9 +202,10 @@ func parseExpiresFromConfig(configMap map[string]any) int { } // parseRelativeTimeSpec parses a relative time specification string -// Supports: d (days), w (weeks), m (months ~30 days), y (years ~365 days) -// Examples: "7d" = 7 days, "2w" = 14 days, "1m" = 30 days, "1y" = 365 days +// Supports: h (hours), d (days), w (weeks), m (months ~30 days), y (years ~365 days) +// Examples: "2h" = 0 days (treated as 1 day min), "7d" = 7 days, "2w" = 14 days, "1m" = 30 days, "1y" = 365 days // Returns 0 if the format is invalid +// Note: Hours less than 24 are treated as 1 day minimum since maintenance runs daily func parseRelativeTimeSpec(spec string) int { configHelpersLog.Printf("DEBUG: parseRelativeTimeSpec called with spec: %s", spec) if spec == "" { @@ -225,6 +227,17 @@ func parseRelativeTimeSpec(spec string) int { // Convert to days based on unit switch unit { + case "h", "H": + // Convert hours to days + // Since maintenance workflow runs daily, treat any hours < 24 as 1 day + days := num / 24 + if days < 1 { + days = 1 + configHelpersLog.Printf("Converted %d hours to 1 day (minimum for daily maintenance)", num) + } else { + configHelpersLog.Printf("Converted %d hours to %d days", num, days) + } + return days case "d", "D": return num // days case "w", "W": diff --git a/pkg/workflow/config_helpers_test.go b/pkg/workflow/config_helpers_test.go new file mode 100644 index 0000000000..60fa6c6d91 --- /dev/null +++ b/pkg/workflow/config_helpers_test.go @@ -0,0 +1,241 @@ +package workflow + +import ( + "testing" +) + +func TestParseRelativeTimeSpec(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + // Hours - should convert to days (minimum 1 day) + { + name: "2 hours", + input: "2h", + expected: 1, // 2 hours = 1 day (minimum) + }, + { + name: "12 hours", + input: "12h", + expected: 1, // 12 hours = 1 day (minimum) + }, + { + name: "23 hours", + input: "23h", + expected: 1, // 23 hours = 1 day (minimum) + }, + { + name: "24 hours", + input: "24h", + expected: 1, // 24 hours = 1 day + }, + { + name: "48 hours", + input: "48h", + expected: 2, // 48 hours = 2 days + }, + { + name: "72 hours", + input: "72h", + expected: 3, // 72 hours = 3 days + }, + { + name: "uppercase hours", + input: "2H", + expected: 1, + }, + // Days + { + name: "1 day", + input: "1d", + expected: 1, + }, + { + name: "7 days", + input: "7d", + expected: 7, + }, + { + name: "uppercase days", + input: "7D", + expected: 7, + }, + // Weeks + { + name: "1 week", + input: "1w", + expected: 7, + }, + { + name: "2 weeks", + input: "2w", + expected: 14, + }, + { + name: "uppercase weeks", + input: "2W", + expected: 14, + }, + // Months + { + name: "1 month", + input: "1m", + expected: 30, + }, + { + name: "3 months", + input: "3m", + expected: 90, + }, + { + name: "uppercase months", + input: "3M", + expected: 90, + }, + // Years + { + name: "1 year", + input: "1y", + expected: 365, + }, + { + name: "2 years", + input: "2y", + expected: 730, + }, + { + name: "uppercase years", + input: "2Y", + expected: 730, + }, + // Invalid inputs + { + name: "empty string", + input: "", + expected: 0, + }, + { + name: "invalid unit", + input: "7x", + expected: 0, + }, + { + name: "no number", + input: "d", + expected: 0, + }, + { + name: "negative number", + input: "-7d", + expected: 0, + }, + { + name: "zero", + input: "0d", + expected: 0, + }, + { + name: "non-numeric", + input: "abcd", + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseRelativeTimeSpec(tt.input) + if result != tt.expected { + t.Errorf("parseRelativeTimeSpec(%q) = %d, expected %d", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseExpiresFromConfig(t *testing.T) { + tests := []struct { + name string + config map[string]any + expected int + }{ + // Integer formats + { + name: "integer days", + config: map[string]any{"expires": 7}, + expected: 7, + }, + { + name: "int64", + config: map[string]any{"expires": int64(14)}, + expected: 14, + }, + { + name: "float64", + config: map[string]any{"expires": float64(21)}, + expected: 21, + }, + // String formats with hours + { + name: "2 hours string", + config: map[string]any{"expires": "2h"}, + expected: 1, // 2 hours = 1 day (minimum) + }, + { + name: "24 hours string", + config: map[string]any{"expires": "24h"}, + expected: 1, + }, + { + name: "48 hours string", + config: map[string]any{"expires": "48h"}, + expected: 2, + }, + // String formats with other units + { + name: "7 days string", + config: map[string]any{"expires": "7d"}, + expected: 7, + }, + { + name: "2 weeks string", + config: map[string]any{"expires": "2w"}, + expected: 14, + }, + { + name: "1 month string", + config: map[string]any{"expires": "1m"}, + expected: 30, + }, + { + name: "1 year string", + config: map[string]any{"expires": "1y"}, + expected: 365, + }, + // Missing or invalid + { + name: "no expires field", + config: map[string]any{}, + expected: 0, + }, + { + name: "invalid string", + config: map[string]any{"expires": "invalid"}, + expected: 0, + }, + { + name: "wrong type", + config: map[string]any{"expires": true}, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseExpiresFromConfig(tt.config) + if result != tt.expected { + t.Errorf("parseExpiresFromConfig(%v) = %d, expected %d", tt.config, result, tt.expected) + } + }) + } +} diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 7e9745bb47..ecadfbd611 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -11,29 +11,61 @@ import ( var maintenanceLog = logger.New("workflow:maintenance_workflow") +// generateMaintenanceCron generates a cron schedule based on the minimum expires value +// Schedule runs at minimum required frequency (every 1-24 days) at a non-obvious minute +// to avoid load spikes. Returns cron expression and description. +func generateMaintenanceCron(minExpiresDays int) (string, string) { + // Use a pseudo-random but deterministic minute (37) to avoid load spikes at :00 + minute := 37 + + // Determine frequency based on minimum expires value + // Run at least as often as the shortest expiration, but max daily + if minExpiresDays <= 1 { + // For 1 day or less, run every 2 hours + return fmt.Sprintf("%d */2 * * *", minute), "Every 2 hours" + } else if minExpiresDays <= 2 { + // For 2 days, run every 6 hours + return fmt.Sprintf("%d */6 * * *", minute), "Every 6 hours" + } else if minExpiresDays <= 4 { + // For 3-4 days, run every 12 hours + return fmt.Sprintf("%d */12 * * *", minute), "Every 12 hours" + } + + // For 5+ days, run daily + return fmt.Sprintf("%d %d * * *", minute, 0), "Daily" +} + // GenerateMaintenanceWorkflow generates the agentics-maintenance.yml workflow // if any workflows use the expires field for discussions func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, verbose bool) error { maintenanceLog.Print("Checking if maintenance workflow is needed") // Check if any workflow uses expires field for discussions or issues + // and track the minimum expires value to determine schedule frequency hasExpires := false + minExpires := 0 // Track minimum expires value in days for _, workflowData := range workflowDataList { if workflowData.SafeOutputs != nil { // Check for expired discussions if workflowData.SafeOutputs.CreateDiscussions != nil { if workflowData.SafeOutputs.CreateDiscussions.Expires > 0 { hasExpires = true - maintenanceLog.Printf("Workflow %s has expires field set to %d days for discussions", workflowData.Name, workflowData.SafeOutputs.CreateDiscussions.Expires) - break + expires := workflowData.SafeOutputs.CreateDiscussions.Expires + maintenanceLog.Printf("Workflow %s has expires field set to %d days for discussions", workflowData.Name, expires) + if minExpires == 0 || expires < minExpires { + minExpires = expires + } } } // Check for expired issues if workflowData.SafeOutputs.CreateIssues != nil { if workflowData.SafeOutputs.CreateIssues.Expires > 0 { hasExpires = true - maintenanceLog.Printf("Workflow %s has expires field set to %d days for issues", workflowData.Name, workflowData.SafeOutputs.CreateIssues.Expires) - break + expires := workflowData.SafeOutputs.CreateIssues.Expires + maintenanceLog.Printf("Workflow %s has expires field set to %d days for issues", workflowData.Name, expires) + if minExpires == 0 || expires < minExpires { + minExpires = expires + } } } } @@ -44,7 +76,11 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s return nil } - maintenanceLog.Print("Generating maintenance workflow for expired discussions and issues") + maintenanceLog.Printf("Generating maintenance workflow for expired discussions and issues (minimum expires: %d days)", minExpires) + + // Generate cron schedule based on minimum expires value + cronSchedule, scheduleDesc := generateMaintenanceCron(minExpires) + maintenanceLog.Printf("Maintenance schedule: %s (%s)", cronSchedule, scheduleDesc) // Create the maintenance workflow content using strings.Builder var yaml strings.Builder @@ -57,7 +93,8 @@ Or use the gh-aw CLI directly: ./gh-aw compile --validate --verbose The workflow is generated when any workflow uses the 'expires' field -in create-discussions or create-issues safe-outputs configuration.` +in create-discussions or create-issues safe-outputs configuration. +Schedule frequency is automatically determined by the shortest expiration time.` header := GenerateWorkflowHeader("", "pkg/workflow/maintenance_workflow.go", customInstructions) yaml.WriteString(header) @@ -66,7 +103,7 @@ in create-discussions or create-issues safe-outputs configuration.` on: schedule: - - cron: "0 0 * * *" # Daily at midnight UTC + - cron: "` + cronSchedule + `" # ` + scheduleDesc + ` (based on minimum expires: ` + fmt.Sprintf("%d", minExpires) + ` days) workflow_dispatch: permissions: {} diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go new file mode 100644 index 0000000000..06e5f2b00b --- /dev/null +++ b/pkg/workflow/maintenance_workflow_test.go @@ -0,0 +1,69 @@ +package workflow + +import ( + "testing" +) + +func TestGenerateMaintenanceCron(t *testing.T) { + tests := []struct { + name string + minExpiresDays int + expectedCron string + expectedDesc string + }{ + { + name: "1 day or less - every 2 hours", + minExpiresDays: 1, + expectedCron: "37 */2 * * *", + expectedDesc: "Every 2 hours", + }, + { + name: "2 days - every 6 hours", + minExpiresDays: 2, + expectedCron: "37 */6 * * *", + expectedDesc: "Every 6 hours", + }, + { + name: "3 days - every 12 hours", + minExpiresDays: 3, + expectedCron: "37 */12 * * *", + expectedDesc: "Every 12 hours", + }, + { + name: "4 days - every 12 hours", + minExpiresDays: 4, + expectedCron: "37 */12 * * *", + expectedDesc: "Every 12 hours", + }, + { + name: "5 days - daily", + minExpiresDays: 5, + expectedCron: "37 0 * * *", + expectedDesc: "Daily", + }, + { + name: "7 days - daily", + minExpiresDays: 7, + expectedCron: "37 0 * * *", + expectedDesc: "Daily", + }, + { + name: "30 days - daily", + minExpiresDays: 30, + expectedCron: "37 0 * * *", + expectedDesc: "Daily", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cron, desc := generateMaintenanceCron(tt.minExpiresDays) + if cron != tt.expectedCron { + t.Errorf("generateMaintenanceCron(%d) cron = %q, expected %q", tt.minExpiresDays, cron, tt.expectedCron) + } + if desc != tt.expectedDesc { + t.Errorf("generateMaintenanceCron(%d) desc = %q, expected %q", tt.minExpiresDays, desc, tt.expectedDesc) + } + }) + } +}