Skip to content
Draft
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
41 changes: 40 additions & 1 deletion .github/workflows/warden.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
pull_request:
types: [opened, synchronize, reopened]

concurrency:
group: warden-${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
review:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -39,15 +43,50 @@
with:
mode: analyze

- name: Verify cancelled findings
if: ${{ cancelled() }}
env:
FINDINGS_FILE: ${{ steps.warden-analyze.outputs.findings-file }}
run: |
if [ -z "$FINDINGS_FILE" ]; then
echo "Analyze was cancelled without producing a findings file" >&2
exit 1
fi

FINDINGS_FILE="$FINDINGS_FILE" node --input-type=module -e '
const fs = await import("node:fs");
const path = process.env.FINDINGS_FILE;
const findings = JSON.parse(fs.readFileSync(path, "utf8"));
if (findings.outcome !== "cancelled") {
throw new Error(`Expected cancelled outcome, received ${findings.outcome}`);
}
if (!fs.existsSync(`${path}.done`)) {
throw new Error(`Missing finalization marker: ${path}.done`);
}
console.log(`Preserved ${findings.summary.totalFindings} finding(s)`);

Check warning on line 66 in .github/workflows/warden.yml

View check run for this annotation

@sentry/warden / warden: code-review

Cancellation after Analyze completes makes Verify reject a successful artifact

When concurrency cancellation arrives after Analyze has written its normal successful artifact but before Report runs, the job-level `cancelled()` guard executes Verify against that artifact. Because successful analyze output has no `outcome: "cancelled"`, Verify fails, and the following broad cancellation guard can archive the valid artifact as `cancelled-findings`; gate these steps on an Analyze cancellation that corresponds to a cancelled artifact rather than job cancellation alone.
Comment on lines +47 to +66

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.

Cancellation after Analyze completes makes Verify reject a successful artifact

When concurrency cancellation arrives after Analyze has written its normal successful artifact but before Report runs, the job-level cancelled() guard executes Verify against that artifact. Because successful analyze output has no outcome: "cancelled", Verify fails, and the following broad cancellation guard can archive the valid artifact as cancelled-findings; gate these steps on an Analyze cancellation that corresponds to a cancelled artifact rather than job cancellation alone.

Evidence
  • The PR concurrency group uses cancel-in-progress: true, so a newer synchronize run can cancel the prior job between the sequential Analyze and Report steps.
  • Normal runAnalyzeMode completion calls writeFindingsOutput without an outcome, while finalizeCancelledPRRun is the path that writes outcome: 'cancelled'.
  • Verify runs before Report under only cancelled(), reads the Analyze output, and throws when the successful artifact's outcome is missing.
  • Upload also uses only cancelled() plus the non-empty findings-file output, so the cancellation state remains active after Verify fails and the successful artifact can be uploaded under a cancelled name.

Identified by Warden · code-review · WMS-WUJ

'

- name: Upload cancelled findings
if: ${{ cancelled() && steps.warden-analyze.outputs.findings-file != '' }}
uses: actions/upload-artifact@v4
with:
name: cancelled-findings-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ steps.warden-analyze.outputs.findings-file }}
${{ steps.warden-analyze.outputs.findings-file }}.done
if-no-files-found: error

- uses: actions/create-github-app-token@v1
id: app-token
if: ${{ always() && steps.warden-analyze.outputs.findings-file != '' }}
with:
app-id: ${{ secrets.WARDEN_APP_ID }}
private-key: ${{ secrets.WARDEN_PRIVATE_KEY }}

- name: Report
if: ${{ always() && steps.warden-analyze.outputs.findings-file != '' && steps.app-token.outcome == 'success' }}
uses: ./
with:
mode: report
findings-file: ${{ steps.warden-analyze.outputs.findings-file }}
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.app-token.outputs.token }}
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,4 @@ runs:
INPUT_SERVICE_DATA: ${{ inputs.service-data }}
INPUT_SERVICE_MEMORY: ${{ inputs.service-memory }}
INPUT_SERVICE_TIMEOUT_MS: ${{ inputs.service-timeout-ms }}
run: node ${{ github.action_path }}/dist/action/index.js
run: exec node "${{ github.action_path }}/dist/action/index.js"
34 changes: 34 additions & 0 deletions packages/warden/src/action/cancellation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { ActionCancellation, createActionSignalHandler } from './cancellation.js';

describe('Action cancellation signals', () => {
it('aborts gracefully on the first cancellation signal', () => {
const cancellation = new ActionCancellation();
const exit = vi.fn();
const handler = createActionSignalHandler({ cancellation, exit });

handler('SIGTERM');

expect(cancellation.requested).toBe(true);
expect(cancellation.signalName).toBe('SIGTERM');
expect(cancellation.abortController.signal.aborted).toBe(true);
expect(cancellation.exitCode).toBe(143);
expect(exit).not.toHaveBeenCalled();
});

it('ignores duplicate delivery before forcing a later exit', () => {
const cancellation = new ActionCancellation();
const exit = vi.fn();
let now = 1_000;
const handler = createActionSignalHandler({ cancellation, exit, now: () => now });

handler('SIGINT');
now += 100;
handler('SIGTERM');
expect(exit).not.toHaveBeenCalled();

now += 1_000;
handler('SIGTERM');
expect(exit).toHaveBeenCalledWith(143);
});
});
57 changes: 57 additions & 0 deletions packages/warden/src/action/cancellation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export type ActionCancelSignal = 'SIGINT' | 'SIGTERM';

const DEFAULT_DUPLICATE_SIGNAL_WINDOW_MS = 750;

/** Run-scoped cancellation state shared by the Action entrypoint and workflows. */
export class ActionCancellation {
readonly abortController = new AbortController();
signalName: ActionCancelSignal | undefined;

get requested(): boolean {
return this.signalName !== undefined;
}

request(signalName: ActionCancelSignal): boolean {
if (this.requested) return false;

this.signalName = signalName;
this.abortController.abort(new Error(`Action cancelled by ${signalName}`));
return true;
}

get exitCode(): number {
return this.signalName === 'SIGTERM' ? 143 : 130;
}
}

interface ActionSignalHandlerOptions {
cancellation: ActionCancellation;
now?: () => number;
exit?: (code: number) => void;
duplicateWindowMs?: number;
}

/** Create a shared SIGINT/SIGTERM handler with graceful-first, force-second behavior. */
export function createActionSignalHandler(
options: ActionSignalHandlerOptions
): (signalName: ActionCancelSignal) => void {
const duplicateWindowMs = options.duplicateWindowMs ?? DEFAULT_DUPLICATE_SIGNAL_WINDOW_MS;
const now = options.now ?? (() => Date.now());
const exit = options.exit ?? ((code) => process.exit(code));
let lastSignalAt = 0;

return (signalName) => {
const receivedAt = now();
if (options.cancellation.requested && receivedAt - lastSignalAt < duplicateWindowMs) {
return;
}

lastSignalAt = receivedAt;
if (!options.cancellation.request(signalName)) {
exit(signalName === 'SIGTERM' ? 143 : 130);
return;
}

console.warn(`Cancellation requested by ${signalName}; finalizing partial results`);
};
}
43 changes: 34 additions & 9 deletions packages/warden/src/action/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,47 @@
import { initSentry, flushSentry } from '../sentry.js';
import { ActionFailedError } from './workflow/base.js';
import { runAction } from './runner.js';
import { ActionCancellation, createActionSignalHandler } from './cancellation.js';

async function flushActionTelemetry(): Promise<void> {
if (!(await flushSentry())) {
const CANCELLATION_TELEMETRY_FLUSH_TIMEOUT_MS = 3_000;

async function flushActionTelemetry(timeoutMs?: number): Promise<void> {
if (!(await flushSentry(timeoutMs))) {
console.warn('::warning::Timed out while flushing Sentry telemetry');
}
}

initSentry('action');
runAction()
.then(() => flushActionTelemetry())
.catch(async (error) => {
async function main(): Promise<void> {
const cancellation = new ActionCancellation();
const handleSignal = createActionSignalHandler({ cancellation });
const onSigint = () => handleSignal('SIGINT');
const onSigterm = () => handleSignal('SIGTERM');
process.on('SIGINT', onSigint);
process.on('SIGTERM', onSigterm);

try {
await runAction(cancellation);
await flushActionTelemetry(
cancellation.requested ? CANCELLATION_TELEMETRY_FLUSH_TIMEOUT_MS : undefined,
);
if (cancellation.requested) {
process.exitCode = cancellation.exitCode;
}
} catch (error) {
if (error instanceof ActionFailedError) {
console.error(`::error::${error.message}`);
} else {
console.error(`::error::Unexpected error: ${error}`);
}
await flushActionTelemetry();
process.exit(1);
});
await flushActionTelemetry(
cancellation.requested ? CANCELLATION_TELEMETRY_FLUSH_TIMEOUT_MS : undefined,
);
process.exitCode = cancellation.requested ? cancellation.exitCode : 1;
} finally {
process.off('SIGINT', onSigint);
process.off('SIGTERM', onSigterm);
}
}

initSentry('action');
void main();
48 changes: 46 additions & 2 deletions packages/warden/src/action/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock('./workflow/schedule.js', () => ({
}));

import { runAction } from './runner.js';
import { ActionCancellation } from './cancellation.js';

const baseInputs: ActionInputs = {
anthropicApiKey: 'test-api-key',
Expand Down Expand Up @@ -79,7 +80,8 @@ describe('runAction without telemetry', () => {
expect(mocks.runScheduleWorkflow).toHaveBeenCalledWith(
mocks.octokit,
baseInputs,
'/tmp/workspace'
'/tmp/workspace',
expect.any(Object),
);
});
});
Expand Down Expand Up @@ -173,7 +175,8 @@ describe('runAction', () => {
baseInputs,
'push',
'/tmp/event.json',
'/tmp/workspace'
'/tmp/workspace',
expect.any(Object),
);
expect(emit).toHaveBeenCalledWith(
'processMetric',
Expand All @@ -200,6 +203,47 @@ describe('runAction', () => {
);
});

it('records a requested cancellation as the Action outcome', async () => {
const cancellation = new ActionCancellation();
cancellation.request('SIGTERM');
const emit = spyOnClientEmit();

await runAction(cancellation);
await Sentry.flush(1000);

expect(emit).toHaveBeenCalledWith(
'processMetric',
expect.objectContaining({
name: 'warden.action.runs',
attributes: expect.objectContaining({
'warden.action.outcome': 'cancelled',
}),
}),
);
});

it('keeps the cancelled outcome when cleanup throws', async () => {
const cancellation = new ActionCancellation();
cancellation.request('SIGTERM');
const error = new Error('cleanup failed');
mocks.runScheduleWorkflow.mockRejectedValueOnce(error);
const emit = spyOnClientEmit();

await expect(runAction(cancellation)).rejects.toBe(error);
await Sentry.flush(1000);

expect(emit).toHaveBeenCalledWith(
'processMetric',
expect.objectContaining({
name: 'warden.action.runs',
attributes: expect.objectContaining({
'warden.action.outcome': 'cancelled',
}),
}),
);
expect(capturedEvents).toHaveLength(0);
});

it('attributes input parsing failures before capturing them', async () => {
const error = new Error('Invalid mode "later"');
const setTag = vi.spyOn(Sentry.getIsolationScope(), 'setTag');
Expand Down
20 changes: 15 additions & 5 deletions packages/warden/src/action/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { parseActionInputs, setupAuthEnv, validateInputs } from './inputs.js';
import { ActionFailedError, setFailed } from './workflow/base.js';
import { runPRWorkflow } from './workflow/pr-workflow.js';
import { runScheduleWorkflow } from './workflow/schedule.js';
import { ActionCancellation } from './cancellation.js';

function isPullRequestEvent(eventName: string): boolean {
return eventName === 'pull_request';
}

/** Run the GitHub Action dispatcher once. */
export async function runAction(): Promise<void> {
export async function runAction(cancellation = new ActionCancellation()): Promise<void> {
const eventName = process.env['GITHUB_EVENT_NAME'];
const actionAttributes = setGitHubActionScope(eventName);

Expand Down Expand Up @@ -48,18 +49,27 @@ export async function runAction(): Promise<void> {
if (inputs.mode !== 'run') {
setFailed(`${inputs.mode} mode is only supported for pull request workflows`);
}
await runScheduleWorkflow(octokit, inputs, repoPath);
await runScheduleWorkflow(octokit, inputs, repoPath, cancellation);
} else {
if (inputs.mode !== 'run' && !isPullRequestEvent(eventName)) {
setFailed(`${inputs.mode} mode is only supported for pull request workflows`);
}
await runPRWorkflow(octokit, inputs, eventName, eventPath, repoPath);
await runPRWorkflow(octokit, inputs, eventName, eventPath, repoPath, cancellation);
}

span.setAttribute('warden.action.outcome', 'success');
const outcome = cancellation.requested ? 'cancelled' : 'success';
span.setAttribute('warden.action.outcome', outcome);
span.setStatus({ code: SPAN_STATUS_OK });
emitActionRunMetric('success', stage);
emitActionRunMetric(outcome, stage);
} catch (error) {
if (cancellation.requested) {
span.setAttribute('warden.action.outcome', 'cancelled');
span.setAttribute('warden.action.stage', stage);
span.setStatus({ code: SPAN_STATUS_OK });
emitActionRunMetric('cancelled', stage);
throw error;
}

const { code } = classifyError(error);
span.setAttribute('warden.action.outcome', 'failure');
span.setAttribute('warden.action.stage', stage);
Expand Down
18 changes: 18 additions & 0 deletions packages/warden/src/action/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ afterEach(() => {
});

describe('Action service integration', () => {
it('preserves a cancelled findings outcome in the service envelope', () => {
const output = buildFindingsOutput([report], context, [], {
runId: 'cancelled-action-run',
timestamp: '2026-08-12T12:00:01.000Z',
outcome: 'cancelled',
});

const envelope = buildFindingsServiceRunEnvelope(output, {
url: 'https://warden.example.com',
token: 'service-token',
data: 'findings',
memory: false,
timeoutMs: 2_000,
}, 'action');

expect(envelope.outcome).toBe('cancelled');
});

it('defaults a URL-and-token-only Action setup to findings and memory', () => {
expect(resolveActionServiceOptions(inputs({
serviceUrl: 'https://warden.example.com',
Expand Down
Loading
Loading