Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion .github/workflows/agentics-maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ on:
- 'enable'
- 'update'
- 'upgrade'
- 'safe_outputs'
run_url:
description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.'
required: false
type: string
default: ''

permissions: {}

Expand Down Expand Up @@ -100,7 +106,7 @@ jobs:
await main();

run_operation:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && !github.event.repository.fork }}
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && !github.event.repository.fork }}
runs-on: ubuntu-slim
permissions:
actions: write
Expand Down Expand Up @@ -150,6 +156,51 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/run_operation_update_upgrade.cjs');
await main();

apply_safe_outputs:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'safe_outputs' && !github.event.repository.fork }}
runs-on: ubuntu-slim
permissions:
actions: read
contents: write
discussions: write
issues: write
pull-requests: write
steps:
- name: Checkout actions folder
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
actions
persist-credentials: false

- name: Setup Scripts
uses: ./actions/setup
with:
destination: ${{ runner.temp }}/gh-aw/actions

- name: Check admin/maintainer permissions
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs');
await main();

- name: Apply Safe Outputs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_AW_RUN_URL: ${{ github.event.inputs.run_url }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs');
await main();

compile-workflows:
if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }}
runs-on: ubuntu-slim
Expand Down
184 changes: 184 additions & 0 deletions actions/setup/js/apply_safe_outputs_replay.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Apply Safe Outputs Replay Driver
*
* Downloads the agent output artifact from a previous workflow run and replays
* the safe outputs, applying them to the repository.
*
* Called from the `apply_safe_outputs` job in the agentic-maintenance workflow.
*
* Required environment variables:
* GH_AW_RUN_URL - Run URL or run ID to replay safe outputs from.
* Accepts a full URL (https://github.com/{owner}/{repo}/actions/runs/{runId})
* or a plain run ID (digits only).
* GH_TOKEN - GitHub token for artifact download via `gh run download`.
*
* Optional environment variables:
* GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG - If set, overrides the auto-generated handler config.
*/

const fs = require("fs");
const path = require("path");

const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_CONFIG, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs");
const { AGENT_OUTPUT_FILENAME, TMP_GH_AW_PATH } = require("./constants.cjs");

/**
* Parse a run ID from a run URL or plain run ID string.
*
* Accepts:
* - A plain run ID: "23560193313"
* - A full run URL: "https://github.com/{owner}/{repo}/actions/runs/{runId}"
* - A run URL with job: "https://github.com/{owner}/{repo}/actions/runs/{runId}/job/{jobId}"
*
* @param {string} runUrl - The run URL or run ID to parse
* @returns {{ runId: string, owner: string|null, repo: string|null }} Parsed components
*/
function parseRunUrl(runUrl) {
if (!runUrl || typeof runUrl !== "string") {
throw new Error(`${ERR_VALIDATION}: run_url is required`);
}

const trimmed = runUrl.trim();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseRunUrl trims whitespace but doesn't treat a whitespace-only input as missing; e.g. " " will bypass the required check and produce a confusing "Cannot parse run ID" error (with an empty value). Consider validating trimmed.length > 0 and throwing the same "run_url is required" validation error after trimming.

Suggested change
const trimmed = runUrl.trim();
const trimmed = runUrl.trim();
if (trimmed.length === 0) {
throw new Error(`${ERR_VALIDATION}: run_url is required`);
}

Copilot uses AI. Check for mistakes.

// Check if it's a plain run ID (digits only)
if (/^\d+$/.test(trimmed)) {
return { runId: trimmed, owner: null, repo: null };
}

// Parse a full GitHub Actions URL
// Pattern: https://github.com/{owner}/{repo}/actions/runs/{runId}[/job/{jobId}]
const match = trimmed.match(/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)/);
if (match) {
return { runId: match[3], owner: match[1], repo: match[2] };
Comment on lines +54 to +56
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The run URL parsing regex is not anchored and will match any string containing github.com/<owner>/<repo>/actions/runs/<id> (e.g. https://example.com/github.com/...). To avoid accepting malformed inputs, parse with new URL(...) (or anchor the regex) and require hostname === 'github.com' (and the expected path structure).

Suggested change
const match = trimmed.match(/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)/);
if (match) {
return { runId: match[3], owner: match[1], repo: match[2] };
try {
const url = new URL(trimmed);
// Ensure we only accept real GitHub URLs
if (url.hostname !== "github.com") {
throw new Error("Not a github.com URL");
}
// Extract owner, repo, and run ID from the pathname
// Anchored pattern: /{owner}/{repo}/actions/runs/{runId}[/job/{jobId}][/]?
const pathMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)(?:\/job\/[^/]+)?\/?$/);
if (pathMatch) {
return { runId: pathMatch[3], owner: pathMatch[1], repo: pathMatch[2] };
}
} catch {
// Fall through to unified error below

Copilot uses AI. Check for mistakes.
}

throw new Error(`${ERR_VALIDATION}: Cannot parse run ID from: ${trimmed}. Expected a plain run ID (digits only) or a GitHub Actions run URL (https://github.com/{owner}/{repo}/actions/runs/{runId}).`);
}

/**
* Download the agent artifact from a workflow run using `gh run download`.
*
* @param {string} runId - The workflow run ID
* @param {string} destDir - Destination directory for the downloaded artifact
* @param {string|null} repoSlug - Optional repository slug (owner/repo)
* @returns {Promise<string>} Path to the downloaded agent_output.json file
*/
async function downloadAgentArtifact(runId, destDir, repoSlug) {
core.info(`Downloading agent artifact from run ${runId}...`);

fs.mkdirSync(destDir, { recursive: true });

const args = ["run", "download", runId, "--name", "agent", "--dir", destDir];
if (repoSlug) {
args.push("--repo", repoSlug);
}

const exitCode = await exec.exec("gh", args);
if (exitCode !== 0) {
throw new Error(`${ERR_SYSTEM}: Failed to download agent artifact from run ${runId}`);
}

const outputFile = path.join(destDir, AGENT_OUTPUT_FILENAME);
if (!fs.existsSync(outputFile)) {
throw new Error(`${ERR_SYSTEM}: Agent output file not found at ${outputFile} after download. Ensure run ${runId} has an "agent" artifact containing ${AGENT_OUTPUT_FILENAME}.`);
}
Comment on lines +70 to +88
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadAgentArtifact downloads into a shared directory (/tmp/gh-aw). If a previous file is already present and a run's artifact is missing agent_output.json, the stale file could be mistakenly reused because only existsSync is checked. Consider downloading into a unique temp directory per replay (e.g. fs.mkdtempSync) or deleting any preexisting agent_output.json in destDir before running gh run download.

Copilot uses AI. Check for mistakes.

core.info(`✓ Agent artifact downloaded to ${outputFile}`);
return outputFile;
}

/**
* Build a handler config from the items present in the agent output file.
* Each item type found in the output is enabled (with an empty config object).
*
* @param {string} agentOutputFile - Path to the agent_output.json file
* @returns {Object} Handler config keyed by normalized type name
*/
function buildHandlerConfigFromOutput(agentOutputFile) {
const content = fs.readFileSync(agentOutputFile, "utf8");
const validatedOutput = JSON.parse(content);

if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) {
core.info("No items found in agent output; handler config will be empty");
return {};
}

const config = {};
for (const item of validatedOutput.items) {
if (item.type && typeof item.type === "string") {
// Normalize type: convert dashes to underscores (mirrors safe_outputs_append.cjs)
const normalizedType = item.type.replace(/-/g, "_");
config[normalizedType] = {};
}
Comment on lines +101 to +116
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-generating GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG as { type: {} } enables handlers but loses the original per-handler policy config (e.g. allowed_files, protected_files_policy, label/title constraints). For code-push handlers, an empty config defaults to allowing all files (see actions/setup/js/manifest_file_helpers.cjs:174-200), which can make a replay more permissive than the original run. Consider storing the original handler config in the artifact and replaying it, or failing closed for policy-sensitive handlers unless an explicit config override is provided.

Copilot uses AI. Check for mistakes.
}

core.info(`Handler config built from ${validatedOutput.items.length} item(s): ${Object.keys(config).join(", ")}`);
return config;
}

/**
* Download the agent artifact from a previous run and apply the safe outputs.
*
* @returns {Promise<void>}
*/
async function main() {
const runUrl = process.env.GH_AW_RUN_URL;
if (!runUrl) {
core.setFailed(`${ERR_CONFIG}: GH_AW_RUN_URL environment variable is required but not set`);
return;
}

core.info(`Applying safe outputs from run: ${runUrl}`);

// Parse run ID and optional owner/repo from the URL
let runId, owner, repo;
try {
({ runId, owner, repo } = parseRunUrl(runUrl));
} catch (error) {
core.setFailed(getErrorMessage(error));
return;
}

core.info(`Parsed run ID: ${runId}`);

// Determine repo slug: prefer URL-parsed value, fall back to current repo context
const repoSlug = owner && repo ? `${owner}/${repo}` : `${context.repo.owner}/${context.repo.repo}`;
core.info(`Target repository: ${repoSlug}`);

// Download the agent artifact into /tmp/gh-aw/
const destDir = TMP_GH_AW_PATH;
let agentOutputFile;
try {
agentOutputFile = await downloadAgentArtifact(runId, destDir, owner && repo ? repoSlug : null);
} catch (error) {
core.setFailed(getErrorMessage(error));
return;
}

// Set GH_AW_AGENT_OUTPUT so the handler manager can find the output file
process.env.GH_AW_AGENT_OUTPUT = agentOutputFile;
core.info(`Set GH_AW_AGENT_OUTPUT=${agentOutputFile}`);

// Auto-build GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG from the output if not already set
if (!process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) {
try {
const handlerConfig = buildHandlerConfigFromOutput(agentOutputFile);
process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG = JSON.stringify(handlerConfig);
core.info("Auto-configured GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG from agent output");
} catch (error) {
core.setFailed(`Failed to build handler config: ${getErrorMessage(error)}`);
return;
}
}

// Apply safe outputs via the handler manager
core.info("Applying safe outputs...");
const { main: runHandlerManager } = require("./safe_output_handler_manager.cjs");
await runHandlerManager();
}

module.exports = { main, parseRunUrl, buildHandlerConfigFromOutput };
Loading
Loading