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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@
- Do not reintroduce `@vercel/ncc` for routine builds; the current dependency set relies on package exports that ncc has previously failed to bundle correctly.
- Keep the esbuild target aligned with the action runtime in `action.yml`.

## Wait Deadlines

- Create the shared `continue-after-seconds` or `abort-after-seconds` deadline in `main.ts` after parsing inputs and before the first Actions API read.
- Treat the limit as a monotonic total-elapsed-time deadline across repository workflow lookup, workflow-run discovery, job and step reads, and sleeps.
- Start the separate `initial-wait-seconds` window when workflow-run discovery begins; do not let repository workflow lookup consume it, and keep its sleep bounded by the shared action deadline.
- Propagate the shared deadline signal and monotonic checkpoint through every potentially blocking Actions API read, including each pagination page and retry attempt.
- Keep GitHub API retry backoffs on the shared abortable, chunk-safe scheduler so a deadline does not leave an Octokit retry timer running.
- On an ordinary terminal failure, cancel the shared signal before reporting the original error; keep cancellation separate from deadline expiration and timer disposal.
- Schedule long deadlines and sleeps in bounded timer chunks; never pass a delay above Node's timer maximum directly to `setTimeout`.
- Use fake timers for deadline regression tests; do not add real sleeps.

## Docs

- Keep README inputs and outputs aligned with `action.yml` and the source input parsing.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## Unreleased

### Bug fixes 🐛

- Enforce `continue-after-seconds` and `abort-after-seconds` as total elapsed-time deadlines starting before the first Actions API read and covering workflow discovery, job and step reads, initial waiting, and polling.
- Keep the `initial-wait-seconds` discovery window anchored to workflow-run discovery so repository workflow lookup does not consume its retry opportunity, while still bounding it by the action deadline.
- Safely chunk long timer delays and check the monotonic boundary before follow-up discovery reads, pagination pages, and retries so timer overflow or delayed callbacks cannot shorten or extend the configured deadline.
- Cancel queued GitHub API retry backoffs when the shared deadline expires or an ordinary terminal API failure ends the action, instead of allowing their timers to keep the step running.
- Keep configured 5xx retries independent from the one primary-rate-limit retry, and avoid retrying primary limits with missing or malformed reset headers.

## 3.3.2

### Bug fixes 🐛
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ jobs:

To avoid waiting prolonged periods of time, you may wish to bail on a run or continuing a workflow run regardless of the status of the previous run.

The `continue-after-seconds` and `abort-after-seconds` limits measure total
elapsed waiting time. The deadline starts after inputs are parsed and before
the first Actions API read, then covers workflow and run discovery, job and
step inspection, configured API retry backoffs, the initial wait, and polling
sleeps. A deadline cancels a queued retry wait through the same request signal.

You can bail from a run using the built-in GitHub Actions [`jobs.<job_id>.timeout-minutes`](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes) setting

```diff
Expand Down Expand Up @@ -259,12 +265,12 @@ jobs:
| Name | Type | Description |
| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `token` | string | GitHub access token used for Actions API reads (defaults to `github.token`) |
| `continue-after-seconds` | number | Maximum number of seconds to wait before moving forward (unbound by default). Mutually exclusive with abort-after-seconds |
| `abort-after-seconds` | number | Maximum number of seconds to wait before aborting the job (unbound by default). Mutually exclusive with continue-after-seconds |
| `continue-after-seconds` | number | Maximum elapsed seconds from before the first Actions API read, including later API reads and sleeps, before moving forward (unbound by default). Mutually exclusive with abort-after-seconds |
| `abort-after-seconds` | number | Maximum elapsed seconds from before the first Actions API read, including later API reads and sleeps, before aborting the job (unbound by default). Mutually exclusive with continue-after-seconds |
| `poll-interval-seconds` | number | Number of seconds to wait in between checks for previous run completion (defaults to 60) |
| `same-branch-only` | boolean | Only wait on other runs from the same branch (defaults to true) |
| `branch` | string | Branch name to use for same-branch filtering (defaults to the current branch) |
| `initial-wait-seconds` | number | Total elapsed seconds within which period the action will refresh the list of current runs, if no runs were found in the first attempt |
| `initial-wait-seconds` | number | Seconds from the start of workflow-run discovery within which the action refreshes current runs when none are found initially; bounded by any continue or abort deadline |
| `job-to-wait-for` | string | Name of the workflow's job to wait for (unbound by default). |
| `step-to-wait-for` | string | Name of the step to wait for (unbound by default). Requires job-to-wait-for to be set. |
| `queue-name` | string | Custom substring used to group matching runs across workflows (defaults to the current workflow only). |
Expand Down
218 changes: 218 additions & 0 deletions __tests__/deadline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
ActionDeadline,
DeadlineReached,
MAX_TIMER_DELAY_MILLISECONDS,
type DeadlineTiming,
} from '../src/deadline';

interface ScheduledTimer {
callback: () => void;
milliseconds: number;
}

const manualTiming = () => {
let now = 0;
let nextTimer = 1;
const timers = new Map<number, ScheduledTimer>();
const scheduledDelays: number[] = [];
const clearedTimers: number[] = [];
const timing: DeadlineTiming = {
now: () => now,
setTimeout: (callback, milliseconds) => {
const timer = nextTimer++;
timers.set(timer, { callback, milliseconds });
scheduledDelays.push(milliseconds);
return timer as unknown as ReturnType<typeof setTimeout>;
},
clearTimeout: (timer) => {
const timerId = timer as unknown as number;
timers.delete(timerId);
clearedTimers.push(timerId);
},
};

return {
timing,
advanceTo: (milliseconds: number) => {
now = milliseconds;
},
deliverNextTimer: () => {
const entry = timers.entries().next().value as [number, ScheduledTimer] | undefined;
if (!entry) {
throw new Error('No timer is scheduled');
}
const [timer, scheduled] = entry;
timers.delete(timer);
scheduled.callback();
return scheduled.milliseconds;
},
scheduledDelays,
clearedTimers,
timerCount: () => timers.size,
};
};

const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
};

describe('ActionDeadline', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it.each(['continue', 'abort'] as const)(
'chunks a long %s deadline without expiring before monotonic time reaches it',
async (mode) => {
const fake = manualTiming();
const seconds = 2_147_484;
const deadline = new ActionDeadline({ mode, seconds }, fake.timing);

expect(deadline.signal?.aborted).toBe(false);
expect(fake.scheduledDelays).toEqual([MAX_TIMER_DELAY_MILLISECONDS]);

fake.advanceTo(MAX_TIMER_DELAY_MILLISECONDS);
expect(fake.deliverNextTimer()).toBe(MAX_TIMER_DELAY_MILLISECONDS);

expect(deadline.signal?.aborted).toBe(false);
expect(fake.scheduledDelays).toEqual([MAX_TIMER_DELAY_MILLISECONDS, 353]);

fake.advanceTo(seconds * 1000);
fake.deliverNextTimer();

expect(deadline.signal?.aborted).toBe(true);
await expect(deadline.race(async () => 'too late')).rejects.toEqual(
new DeadlineReached(mode, seconds),
);
expect(fake.scheduledDelays.every((delay) => delay <= MAX_TIMER_DELAY_MILLISECONDS)).toBe(
true,
);
},
);

it('clears the currently armed timer during cleanup after a chunk re-arms', () => {
const fake = manualTiming();
const deadline = new ActionDeadline({ mode: 'abort', seconds: 2_147_484 }, fake.timing);

fake.advanceTo(MAX_TIMER_DELAY_MILLISECONDS);
fake.deliverNextTimer();
expect(fake.timerCount()).toBe(1);

deadline.dispose();

expect(fake.timerCount()).toBe(0);
expect(fake.clearedTimers).toHaveLength(1);
});

it('chunks a long polling sleep without scheduling a timer beyond the Node limit', async () => {
const fake = manualTiming();
const seconds = 2_147_484;
const deadline = new ActionDeadline(undefined, fake.timing);
const sleep = deadline.sleepSeconds(seconds);

expect(fake.scheduledDelays).toEqual([MAX_TIMER_DELAY_MILLISECONDS]);

fake.advanceTo(MAX_TIMER_DELAY_MILLISECONDS);
fake.deliverNextTimer();
for (let microtask = 0; microtask < 8; microtask += 1) {
await Promise.resolve();
}

expect(fake.scheduledDelays).toEqual([MAX_TIMER_DELAY_MILLISECONDS, 353]);

fake.advanceTo(seconds * 1000);
fake.deliverNextTimer();

await expect(sleep).resolves.toBeUndefined();
expect(fake.scheduledDelays.every((delay) => delay <= MAX_TIMER_DELAY_MILLISECONDS)).toBe(true);
});

it('re-checks monotonic time when an operation resolves before the timer callback is delivered', async () => {
const fake = manualTiming();
const operation = deferred<string>();
const deadline = new ActionDeadline({ mode: 'abort', seconds: 1 }, fake.timing);
const result = deadline.race(() => operation.promise);

fake.advanceTo(1_001);
operation.resolve('late value');

await expect(result).rejects.toEqual(new DeadlineReached('abort', 1));
expect(deadline.signal?.aborted).toBe(true);
});

it('re-checks monotonic time when an operation rejects before the timer callback is delivered', async () => {
const fake = manualTiming();
const operation = deferred<string>();
const deadline = new ActionDeadline({ mode: 'continue', seconds: 1 }, fake.timing);
const result = deadline.race(() => operation.promise);

fake.advanceTo(1_001);
operation.reject(new Error('late API failure'));

await expect(result).rejects.toEqual(new DeadlineReached('continue', 1));
expect(deadline.signal?.aborted).toBe(true);
});

it('allows an operation that resolves immediately before the deadline', async () => {
const fake = manualTiming();
const operation = deferred<string>();
const deadline = new ActionDeadline({ mode: 'continue', seconds: 1 }, fake.timing);
const result = deadline.race(() => operation.promise);

fake.advanceTo(999);
operation.resolve('on time');

await expect(result).resolves.toBe('on time');
expect(deadline.signal?.aborted).toBe(false);
});

it('treats the exact monotonic boundary as expired', async () => {
const fake = manualTiming();
const deadline = new ActionDeadline({ mode: 'continue', seconds: 1 }, fake.timing);

fake.advanceTo(1_000);

await expect(deadline.race(async () => 'boundary value')).rejects.toEqual(
new DeadlineReached('continue', 1),
);
expect(deadline.signal?.aborted).toBe(true);
});

it('cancels pending work idempotently without marking the configured deadline as reached', () => {
const fake = manualTiming();
const deadline = new ActionDeadline({ mode: 'abort', seconds: 10 }, fake.timing);
const reason = new Error('terminal API failure');

deadline.cancel(reason);
deadline.cancel(new Error('later cancellation'));

expect(deadline.signal?.aborted).toBe(true);
expect(deadline.signal?.reason).toBe(reason);
expect(() => deadline.throwIfReached()).not.toThrow();
expect(fake.timerCount()).toBe(1);

deadline.dispose();
expect(fake.timerCount()).toBe(0);
});

it('preserves the configured deadline outcome when cancellation follows expiration', async () => {
const fake = manualTiming();
const deadline = new ActionDeadline({ mode: 'continue', seconds: 1 }, fake.timing);

fake.advanceTo(1_000);
fake.deliverNextTimer();
deadline.cancel(new Error('terminal API failure'));

await expect(deadline.race(async () => 'too late')).rejects.toEqual(
new DeadlineReached('continue', 1),
);
});
});
Loading