Skip to content
Open
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
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,44 @@ To leave comments and approvals on your PRs, Droid needs a GitHub token. There a

### Core Inputs

| Input | Purpose |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| `factory_api_key` | **Required.** Grants Droid Exec permission to run via Factory. |
| `github_token` | Optional override if you prefer a custom GitHub App/token. By default the installed app token is used. |
| Input | Purpose |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `factory_api_key` | **Required.** Grants Droid Exec permission to run via Factory. |
| `github_token` | Optional override if you prefer a custom GitHub App/token. By default the installed app token is used. |
| `prompt` | Custom automation task. When set, Droid Exec runs this prompt directly on automation events (`schedule`, `workflow_dispatch`, `repository_dispatch`, `workflow_run`) and on PR/issue events that carry no `@droid` command and no automatic-review flags. Used by Factory-managed Custom CI automations. |
| `droid_args` | Additional arguments passed to the Droid CLI (e.g. `-m <model>` to pick the model for a custom `prompt` run). |

### Custom Automations

Run an arbitrary task on a schedule (or manual dispatch) by passing a `prompt`:

```yaml
name: Code Consolidation
on:
workflow_dispatch: {}
schedule:
- cron: "0 16 * * 1-5"
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: Factory-AI/droid-action@main
with:
factory_api_key: ${{ secrets.FACTORY_API_KEY }}
prompt: |
Find duplicated code across the repository, consolidate it,
and open a pull request with the changes.
droid_args: "-m claude-fable-5"
```

Explicit `@droid` commands and the `automatic_review` / `automatic_security_review` flows always take precedence; the `prompt` only runs when no command or review flow claimed the event. Workflows created from the Factory Automations UI (Custom template) generate this shape automatically.

### Review Configuration

Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ inputs:
description: "Droid Exec settings as JSON string or path to settings JSON file"
required: false
default: ""
prompt:
description: "Custom automation task prompt. When set, Droid Exec runs this prompt directly on automation events (schedule, workflow_dispatch, repository_dispatch, workflow_run) and on entity events that carry no @droid command and no automatic-review flags. Used by Factory-managed Custom CI automations."
required: false
default: ""

# Auth configuration
factory_api_key:
Expand Down Expand Up @@ -199,6 +203,7 @@ runs:
FILL_MODEL: ${{ inputs.fill_model }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
DROID_ARGS: ${{ inputs.droid_args }}
PROMPT: ${{ inputs.prompt }}
INCLUDE_SUGGESTIONS: ${{ inputs.include_suggestions }}
ALL_INPUTS: ${{ toJson(inputs) }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Avoid serializing full custom prompt into ALL_INPUTS

With the new prompt input, ALL_INPUTS: ${{ toJson(inputs) }} can now embed arbitrary multi-line free text into an environment variable even though collectActionInputsPresence() only needs default-vs-present booleans. This can create brittle failures with very large prompts (environment size limits) and increases the blast radius of sensitive prompt content, so it would be safer to compute presence directly from known inputs or omit prompt from the serialized blob.


Expand Down
86 changes: 86 additions & 0 deletions src/custom-automation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as core from "@actions/core";
import { mkdir, writeFile } from "fs/promises";
import { checkHumanActor } from "../github/validation/actor";
import { isEntityContext, type GitHubContext } from "../github/context";
import { normalizeDroidArgs } from "../utils/parse-tools";
import type { PrepareOptions, PrepareResult } from "../prepare/types";

/**
* Custom automation mode: runs the workflow's `prompt` input directly via
* Droid Exec. This is the execution path for Factory-managed Custom CI
* automations, whose task is free text authored in the Factory Automations UI
* rather than an @droid command or a review flow.
*
* Triggers whenever a non-empty `prompt` input is present. Dispatch precedence
* lives in `src/prepare/index.ts`: explicit @droid commands and the
* automatic-review flags always win, so a prompt only runs when no tag/review
* flow claimed the event.
*/
export function shouldTriggerCustomAutomation(context: GitHubContext): boolean {
return context.inputs.prompt.trim().length > 0;
}

function describeTriggeringEvent(context: GitHubContext): string {
if (!isEntityContext(context)) {
return `a \`${context.eventName}\` event`;
}
const entityKind = context.isPR ? "pull request" : "issue";
return `a \`${context.eventName}\` event on ${entityKind} #${context.entityNumber}`;
}

function buildCustomAutomationPrompt(context: GitHubContext): string {
const { repository } = context;
return `You are running as a Factory custom automation inside a GitHub Actions runner.

Environment:
- Repository: ${repository.full_name} (already checked out in the working directory)
- Triggered by: ${describeTriggeringEvent(context)}
- The \`gh\` CLI is authenticated via the GITHUB_TOKEN environment variable.
- This is a non-interactive environment: never wait for user input, and complete the task in this single run.
- If your task produces code changes, create a new branch and open a pull request with \`gh\`; do not push directly to the default branch.

Your task:

${context.inputs.prompt.trim()}
`;
}

export async function prepareCustomAutomationMode({
context,
octokit,
}: PrepareOptions): Promise<PrepareResult> {
// Entity-triggered automations (e.g. `on: pull_request` with a prompt) keep
// the same actor hygiene as the tag flows: bot-authored events only run when
// the bot is explicitly allow-listed. Automation events (schedule /
// workflow_dispatch / ...) have no meaningful actor to validate.
if (isEntityContext(context)) {
await checkHumanActor(octokit.rest, context);
}

const promptContent = buildCustomAutomationPrompt(context);

console.log("===== CUSTOM AUTOMATION PROMPT =====");
console.log(promptContent);
console.log("====================================");

const promptDir = `${process.env.RUNNER_TEMP || "/tmp"}/droid-prompts`;
await mkdir(promptDir, { recursive: true });
await writeFile(`${promptDir}/droid-prompt.txt`, promptContent);
Comment on lines +60 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] [security] Avoid logging raw custom automation prompt content

prepareCustomAutomationMode() logs the full generated prompt (including the user-provided task) to stdout. For custom automations this prompt can contain sensitive internal details, and GitHub Actions runners interpret certain log lines (for example ones starting with ::) as workflow commands. Consider avoiding verbatim logging, sanitizing ::-style sequences, and/or redacting or truncating prompt content before writing it to logs.


core.exportVariable("DROID_EXEC_RUN_TYPE", "droid-custom-automation");

// Pass user-supplied droid_args through untouched (this is how Factory's
// Custom CI automations carry their model selection, e.g. `-m <model>`).
// No tool restrictions are imposed: the task is arbitrary by design.
const normalizedUserArgs = normalizeDroidArgs(process.env.DROID_ARGS || "");
core.setOutput("droid_args", normalizedUserArgs);
core.setOutput("mcp_tools", "");

return {
branchInfo: {
baseBranch: "",
currentBranch: "",
},
mcpTools: "",
};
}
1 change: 1 addition & 0 deletions src/entrypoints/collect-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function collectActionInputsPresence(): void {
factory_api_key: "",
github_token: "",
droid_args: "",
prompt: "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Fix action input presence detection for missing keys

collectActionInputsPresence() treats missing keys as an empty string (allInputs[name] || ""), which means any key absent from toJson(inputs) but with a non-empty default gets incorrectly marked as "present" ("" !== defaultValue). Since inputDefaults includes keys that are not defined as action inputs in action.yml (e.g. mode, branch_prefix, allowed_tools), action_inputs_present can become unreliable for downstream consumers.

max_turns: "",
use_sticky_comment: "false",
experimental_allowed_domains: "",
Expand Down
7 changes: 5 additions & 2 deletions src/entrypoints/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { checkWritePermissions } from "../github/validation/permissions";
import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context";
import { shouldTriggerTag } from "../tag";
import { shouldTriggerCustomAutomation } from "../custom-automation";
import { prepare } from "../prepare";
import { collectActionInputsPresence } from "./collect-inputs";

Expand Down Expand Up @@ -42,8 +43,10 @@ async function run() {
}
}

// Check trigger conditions
const containsTrigger = shouldTriggerTag(context);
// Check trigger conditions: @droid tag / automatic review flows, or a
// custom automation `prompt` input (Factory Custom CI automations).
const containsTrigger =
shouldTriggerTag(context) || shouldTriggerCustomAutomation(context);

console.log(`Trigger result: ${containsTrigger}`);

Expand Down
7 changes: 7 additions & 0 deletions src/github/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ type BaseContext = {
securityNotifyTeam: string;
securityScanSchedule: boolean;
securityScanDays: number;
/**
* Custom automation task prompt (the `prompt` action input). When set,
* Droid Exec runs it directly instead of the tag/review flows for events
* that carry no explicit @droid command.
*/
prompt: string;
};
};

Expand Down Expand Up @@ -161,6 +167,7 @@ export function parseGitHubContext(): GitHubContext {
1,
parseInt(process.env.SECURITY_SCAN_DAYS ?? "7", 10) || 7,
),
prompt: process.env.PROMPT ?? "",
},
};

Expand Down
1 change: 1 addition & 0 deletions src/github/utils/command-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const baseContext: Omit<ParsedGitHubContext, "eventName" | "payload"> = {
securityNotifyTeam: "",
securityScanSchedule: false,
securityScanDays: 7,
prompt: "",
},
entityNumber: 1,
isPR: true,
Expand Down
26 changes: 23 additions & 3 deletions src/prepare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,32 @@
*/

import type { PrepareOptions, PrepareResult } from "./types";
import { prepareTagExecution } from "../tag";
import { shouldTriggerTag, prepareTagExecution } from "../tag";
import {
shouldTriggerCustomAutomation,
prepareCustomAutomationMode,
} from "../custom-automation";

export async function prepare(options: PrepareOptions): Promise<PrepareResult> {
const { context } = options;

console.log(`Preparing tag execution for event: ${context.eventName}`);
// Explicit @droid commands and the automatic-review flags always win; the
// custom automation prompt only runs when no tag/review flow claimed the
// event (schedule / workflow_dispatch runs, or entity events with no
// command).
if (shouldTriggerTag(context)) {
console.log(`Preparing tag execution for event: ${context.eventName}`);
return prepareTagExecution(options);
}

return prepareTagExecution(options);
if (shouldTriggerCustomAutomation(context)) {
console.log(
`Preparing custom automation execution for event: ${context.eventName}`,
);
return prepareCustomAutomationMode(options);
}

throw new Error(
`No execution mode matched event: ${context.eventName}. This indicates a trigger-detection bug in the prepare entrypoint.`,
);
}
1 change: 1 addition & 0 deletions test/create-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("prepareContext", () => {
securityNotifyTeam: "",
securityScanSchedule: false,
securityScanDays: 7,
prompt: "",
},
} as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("generateSecurityReportPrompt", () => {
securityNotifyTeam: "@org/security-team",
securityScanSchedule: false,
securityScanDays: 7,
prompt: "",
},
},
};
Expand Down
Loading
Loading