Skip to content

Commit 6f6f370

Browse files
feat: Idempotent action and auto-skip Frogbot for internal PRs (#23)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 57de716 commit 6f6f370

File tree

8 files changed

+329
-48
lines changed

8 files changed

+329
-48
lines changed

.github/workflows/frogbot.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ jobs:
1717
frogbot-scan:
1818
runs-on: ubuntu-latest
1919

20-
# A pull request needs to be approved before Frogbot scans it. Any GitHub user who is associated with the
21-
# "frogbot" GitHub environment can approve the pull request to be scanned.
22-
environment: frogbot
20+
# External PRs (forks or different repo) require approval via "frogbot" environment.
21+
# Internal PRs from same repo run automatically without approval.
22+
environment: >-
23+
${{
24+
(github.event.pull_request.head.repo.fork == true ||
25+
github.event.pull_request.head.repo.full_name != github.repository)
26+
&& 'frogbot' || ''
27+
}}
2328
2429
steps:
2530
- uses: jfrog/frogbot@v2

lib/index.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,20 @@
88

99
// Copyright (c) JFrog Ltd. (2025)
1010
Object.defineProperty(exports, "__esModule", ({ value: true }));
11-
exports.GITHUB_STATUS_SKIPPED = exports.GITHUB_STATUS_TIMED_OUT = exports.GITHUB_STATUS_CANCELLED = exports.GITHUB_STATUS_FAILURE = exports.GITHUB_STATUS_SUCCESS = exports.FLY_CLI_SETUP_OUTPUT_PATH = exports.FLY_CLI_PATH = exports.STATE_FLY_PACKAGE_MANAGERS = exports.STATE_FLY_ACCESS_TOKEN = exports.STATE_FLY_URL = exports.INPUT_GITHUB_TOKEN = exports.INPUT_IGNORE_PACKAGE_MANAGERS = exports.INPUT_URL = void 0;
11+
exports.GITHUB_STATUS_TIMED_OUT = exports.GITHUB_STATUS_CANCELLED = exports.GITHUB_STATUS_FAILURE = exports.GITHUB_STATUS_SUCCESS = exports.ENV_FLY_ACTION_CONFIGURED = exports.STATE_FLY_PACKAGE_MANAGERS = exports.STATE_FLY_ACCESS_TOKEN = exports.STATE_FLY_URL = exports.INPUT_GITHUB_TOKEN = exports.INPUT_IGNORE_PACKAGE_MANAGERS = exports.INPUT_URL = void 0;
1212
exports.INPUT_URL = "url";
1313
exports.INPUT_IGNORE_PACKAGE_MANAGERS = "ignore";
1414
exports.INPUT_GITHUB_TOKEN = "github_token";
1515
exports.STATE_FLY_URL = "fly-url";
1616
exports.STATE_FLY_ACCESS_TOKEN = "fly-access-token";
1717
exports.STATE_FLY_PACKAGE_MANAGERS = "fly-package-managers";
18-
exports.FLY_CLI_PATH = "fly-cli-path";
19-
exports.FLY_CLI_SETUP_OUTPUT_PATH = "fly-cli-setup-output-path";
18+
// Environment variable to track if action has already run in this job
19+
exports.ENV_FLY_ACTION_CONFIGURED = "FLY_ACTION_CONFIGURED";
2020
// GitHub step/job conclusion statuses
2121
exports.GITHUB_STATUS_SUCCESS = "success";
2222
exports.GITHUB_STATUS_FAILURE = "failure";
2323
exports.GITHUB_STATUS_CANCELLED = "cancelled";
2424
exports.GITHUB_STATUS_TIMED_OUT = "timed_out";
25-
exports.GITHUB_STATUS_SKIPPED = "skipped";
2625

2726

2827
/***/ }),
@@ -91,6 +90,11 @@ function resolveFlyCLIBinaryPath() {
9190
}
9291
async function run() {
9392
core.info("Main run() function started.");
93+
// Idempotency check: skip if action has already run in this job
94+
if (process.env[constants_1.ENV_FLY_ACTION_CONFIGURED] === "true") {
95+
core.info("Fly action has already been configured in this job, skipping duplicate run.");
96+
return;
97+
}
9498
try {
9599
const url = core.getInput(constants_1.INPUT_URL, { required: true });
96100
core.info(`URL: ${url}`);
@@ -128,6 +132,9 @@ async function run() {
128132
throw new Error("Fly setup command failed");
129133
}
130134
core.info("Fly CLI setup command completed successfully.");
135+
// Mark action as configured to prevent duplicate runs in same job
136+
core.exportVariable(constants_1.ENV_FLY_ACTION_CONFIGURED, "true");
137+
core.info("Marked Fly action as configured for this job.");
131138
}
132139
catch (error) {
133140
core.error("Error occurred during execution.");

lib/post.js

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,20 @@
88

99
// Copyright (c) JFrog Ltd. (2025)
1010
Object.defineProperty(exports, "__esModule", ({ value: true }));
11-
exports.GITHUB_STATUS_SKIPPED = exports.GITHUB_STATUS_TIMED_OUT = exports.GITHUB_STATUS_CANCELLED = exports.GITHUB_STATUS_FAILURE = exports.GITHUB_STATUS_SUCCESS = exports.FLY_CLI_SETUP_OUTPUT_PATH = exports.FLY_CLI_PATH = exports.STATE_FLY_PACKAGE_MANAGERS = exports.STATE_FLY_ACCESS_TOKEN = exports.STATE_FLY_URL = exports.INPUT_GITHUB_TOKEN = exports.INPUT_IGNORE_PACKAGE_MANAGERS = exports.INPUT_URL = void 0;
11+
exports.GITHUB_STATUS_TIMED_OUT = exports.GITHUB_STATUS_CANCELLED = exports.GITHUB_STATUS_FAILURE = exports.GITHUB_STATUS_SUCCESS = exports.ENV_FLY_ACTION_CONFIGURED = exports.STATE_FLY_PACKAGE_MANAGERS = exports.STATE_FLY_ACCESS_TOKEN = exports.STATE_FLY_URL = exports.INPUT_GITHUB_TOKEN = exports.INPUT_IGNORE_PACKAGE_MANAGERS = exports.INPUT_URL = void 0;
1212
exports.INPUT_URL = "url";
1313
exports.INPUT_IGNORE_PACKAGE_MANAGERS = "ignore";
1414
exports.INPUT_GITHUB_TOKEN = "github_token";
1515
exports.STATE_FLY_URL = "fly-url";
1616
exports.STATE_FLY_ACCESS_TOKEN = "fly-access-token";
1717
exports.STATE_FLY_PACKAGE_MANAGERS = "fly-package-managers";
18-
exports.FLY_CLI_PATH = "fly-cli-path";
19-
exports.FLY_CLI_SETUP_OUTPUT_PATH = "fly-cli-setup-output-path";
18+
// Environment variable to track if action has already run in this job
19+
exports.ENV_FLY_ACTION_CONFIGURED = "FLY_ACTION_CONFIGURED";
2020
// GitHub step/job conclusion statuses
2121
exports.GITHUB_STATUS_SUCCESS = "success";
2222
exports.GITHUB_STATUS_FAILURE = "failure";
2323
exports.GITHUB_STATUS_CANCELLED = "cancelled";
2424
exports.GITHUB_STATUS_TIMED_OUT = "timed_out";
25-
exports.GITHUB_STATUS_SKIPPED = "skipped";
2625

2726

2827
/***/ }),
@@ -236,9 +235,16 @@ function analyzeJobSteps(steps) {
236235
return constants_1.GITHUB_STATUS_SUCCESS;
237236
}
238237
/**
239-
* Determines workflow status by checking if any main steps failed
238+
* Determines job status by checking if any main steps failed.
240239
* When post actions run, all main steps have completed but post steps are still pending.
241-
* We only examine main steps to determine if the workflow succeeded up to this point.
240+
* We only examine main steps to determine if the job succeeded up to this point.
241+
*
242+
* Job identification strategy:
243+
* 1. Match GITHUB_JOB against the API job name (works when no custom name: attribute).
244+
* 2. Fallback: find the single in_progress job (our job is always in_progress while
245+
* its post steps run; completed jobs have finished all post steps).
246+
* 3. If multiple jobs are in_progress (parallel execution), we check ALL of them
247+
* for failures — conservative approach when we can't pinpoint our exact job.
242248
*/
243249
async function determineJobStatus() {
244250
try {
@@ -259,12 +265,43 @@ async function determineJobStatus() {
259265
jobs.jobs.forEach((job) => {
260266
core.info(` - Job: ${job.name}, Status: ${job.status}, Conclusion: ${job.conclusion}, Steps: ${job.steps?.length || 0}`);
261267
});
262-
// Find the current job (case-insensitive comparison)
263-
const currentJob = jobs.jobs.find((job) => job.name.toLowerCase() === env.jobName.toLowerCase());
268+
// Find the current job:
269+
// 1. Primary: match GITHUB_JOB (yaml key) against job name.
270+
// Works when the job has no custom `name:` attribute.
271+
// 2. Fallback: find the job that is still in_progress.
272+
// When our post step runs, our job is always in_progress (post steps
273+
// are part of the job). Completed jobs have already finished all their
274+
// post steps. This handles the case where a custom `name:` attribute
275+
// makes GITHUB_JOB (yaml key) differ from the API name (display name).
276+
let currentJob;
277+
// Try name match first (works when no custom name: attribute)
278+
currentJob = jobs.jobs.find((job) => job.name.toLowerCase() === env.jobName.toLowerCase());
264279
if (currentJob) {
265-
core.info(`✓ Found current job: ${currentJob.name}`);
266-
core.info(` Status: ${currentJob.status}, Conclusion: ${currentJob.conclusion || "null"}`);
267-
core.info(` Steps count: ${currentJob.steps?.length || 0}`);
280+
core.info(`✓ Found current job by name: ${currentJob.name}`);
281+
}
282+
// Fallback: match by in_progress status
283+
if (!currentJob) {
284+
const inProgressJobs = jobs.jobs.filter((job) => job.status === "in_progress");
285+
if (inProgressJobs.length === 1) {
286+
currentJob = inProgressJobs[0];
287+
core.info(`✓ Found current job by in_progress status: ${currentJob.name}`);
288+
}
289+
else if (inProgressJobs.length > 1) {
290+
// Multiple jobs running concurrently — check all for failures
291+
core.info(`Found ${inProgressJobs.length} in_progress jobs, analyzing all for failures`);
292+
for (const job of inProgressJobs) {
293+
if (job.steps && job.steps.length > 0) {
294+
const result = analyzeJobSteps(job.steps);
295+
if (result === constants_1.GITHUB_STATUS_FAILURE) {
296+
return constants_1.GITHUB_STATUS_FAILURE;
297+
}
298+
}
299+
}
300+
return constants_1.GITHUB_STATUS_SUCCESS;
301+
}
302+
}
303+
if (currentJob) {
304+
core.info(` Status: ${currentJob.status}, Conclusion: ${currentJob.conclusion || "null"}, Steps: ${currentJob.steps?.length || 0}`);
268305
// Check individual step statuses
269306
if (currentJob.steps && currentJob.steps.length > 0) {
270307
return analyzeJobSteps(currentJob.steps);
@@ -307,17 +344,17 @@ async function determineJobStatus() {
307344
}
308345
}
309346
async function runPost() {
310-
core.info("🏁 Notifying Fly that CI job has ended...");
311-
const flyUrl = core.getState(constants_1.STATE_FLY_URL); // Corrected constant
312-
const accessToken = core.getState(constants_1.STATE_FLY_ACCESS_TOKEN); // Corrected constant
347+
const flyUrl = core.getState(constants_1.STATE_FLY_URL);
348+
const accessToken = core.getState(constants_1.STATE_FLY_ACCESS_TOKEN);
313349
if (!flyUrl) {
314-
core.info("No Fly URL found in state, skipping CI end notification"); // Changed from debug to info
350+
core.info("No Fly URL found in state, skipping CI end notification");
315351
return;
316352
}
317353
if (!accessToken) {
318-
core.info("No access token found in state, skipping CI end notification"); // Changed from debug to info
354+
core.info("No access token found in state, skipping CI end notification");
319355
return;
320356
}
357+
core.info("🏁 Notifying Fly that CI job has ended...");
321358
const packageManagersState = core.getState(constants_1.STATE_FLY_PACKAGE_MANAGERS);
322359
let packageManagers = [];
323360
if (packageManagersState) {

src/constants.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ export const STATE_FLY_URL = "fly-url";
88
export const STATE_FLY_ACCESS_TOKEN = "fly-access-token";
99
export const STATE_FLY_PACKAGE_MANAGERS = "fly-package-managers";
1010

11-
export const FLY_CLI_PATH = "fly-cli-path";
12-
export const FLY_CLI_SETUP_OUTPUT_PATH = "fly-cli-setup-output-path";
11+
// Environment variable to track if action has already run in this job
12+
export const ENV_FLY_ACTION_CONFIGURED = "FLY_ACTION_CONFIGURED";
1313

1414
// GitHub step/job conclusion statuses
1515
export const GITHUB_STATUS_SUCCESS = "success";
1616
export const GITHUB_STATUS_FAILURE = "failure";
1717
export const GITHUB_STATUS_CANCELLED = "cancelled";
1818
export const GITHUB_STATUS_TIMED_OUT = "timed_out";
19-
export const GITHUB_STATUS_SKIPPED = "skipped";

src/index.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
getAllPackageManagers,
2222
SUPPORTED_PACKAGE_MANAGERS,
2323
} from "./package-detection";
24-
import { STATE_FLY_URL, STATE_FLY_ACCESS_TOKEN } from "./constants";
24+
import {
25+
STATE_FLY_URL,
26+
STATE_FLY_ACCESS_TOKEN,
27+
ENV_FLY_ACTION_CONFIGURED,
28+
} from "./constants";
2529

2630
jest.mock("./oidc", () => ({
2731
authenticateOidc: jest.fn(),
@@ -297,3 +301,86 @@ describe("run exec and binary error branches", () => {
297301
);
298302
});
299303
});
304+
305+
describe("run idempotency", () => {
306+
const getInputSpy = jest.spyOn(core, "getInput");
307+
const setFailedSpy = jest.spyOn(core, "setFailed");
308+
const saveStateSpy = jest.spyOn(core, "saveState");
309+
const exportVariableSpy = jest.spyOn(core, "exportVariable");
310+
const infoSpy = jest.spyOn(core, "info");
311+
const execSpy = jest.spyOn(exec, "exec");
312+
313+
beforeEach(() => {
314+
jest.resetAllMocks();
315+
// Remove the env var before each test
316+
delete process.env[ENV_FLY_ACTION_CONFIGURED];
317+
// Stub file system to simulate binary present
318+
(fs.existsSync as jest.Mock).mockReturnValue(true);
319+
(path.resolve as jest.Mock).mockReturnValue("/fake/bin");
320+
(detectPackageManagers as jest.Mock).mockReturnValue([]);
321+
});
322+
323+
afterEach(() => {
324+
// Clean up env var after tests
325+
delete process.env[ENV_FLY_ACTION_CONFIGURED];
326+
});
327+
328+
it("skips execution when FLY_ACTION_CONFIGURED is already set", async () => {
329+
// Set the env var to simulate action already ran
330+
process.env[ENV_FLY_ACTION_CONFIGURED] = "true";
331+
332+
await run();
333+
334+
// Should log skip message
335+
expect(infoSpy).toHaveBeenCalledWith(
336+
"Fly action has already been configured in this job, skipping duplicate run.",
337+
);
338+
// Should NOT call authentication or exec
339+
expect(authenticateOidc).not.toHaveBeenCalled();
340+
expect(execSpy).not.toHaveBeenCalled();
341+
// Should NOT save state
342+
expect(saveStateSpy).not.toHaveBeenCalled();
343+
// Should NOT set failed
344+
expect(setFailedSpy).not.toHaveBeenCalled();
345+
});
346+
347+
it("exports FLY_ACTION_CONFIGURED after successful run", async () => {
348+
getInputSpy.mockImplementation((name: string) =>
349+
name === "url" ? "https://test.com" : "",
350+
);
351+
(authenticateOidc as jest.Mock).mockResolvedValue({
352+
user: "user",
353+
accessToken: "token",
354+
});
355+
execSpy.mockResolvedValue(0);
356+
357+
await run();
358+
359+
// Should export the env var
360+
expect(exportVariableSpy).toHaveBeenCalledWith(
361+
ENV_FLY_ACTION_CONFIGURED,
362+
"true",
363+
);
364+
expect(setFailedSpy).not.toHaveBeenCalled();
365+
});
366+
367+
it("does not export FLY_ACTION_CONFIGURED when setup fails", async () => {
368+
getInputSpy.mockImplementation((name: string) =>
369+
name === "url" ? "https://test.com" : "",
370+
);
371+
(authenticateOidc as jest.Mock).mockResolvedValue({
372+
user: "user",
373+
accessToken: "token",
374+
});
375+
execSpy.mockResolvedValue(1); // Non-zero exit code
376+
377+
await run();
378+
379+
// Should NOT export the env var on failure
380+
expect(exportVariableSpy).not.toHaveBeenCalledWith(
381+
ENV_FLY_ACTION_CONFIGURED,
382+
"true",
383+
);
384+
expect(setFailedSpy).toHaveBeenCalled();
385+
});
386+
});

src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
STATE_FLY_URL,
1313
STATE_FLY_ACCESS_TOKEN,
1414
STATE_FLY_PACKAGE_MANAGERS,
15+
ENV_FLY_ACTION_CONFIGURED,
1516
} from "./constants";
1617

1718
/**
@@ -31,6 +32,15 @@ export function resolveFlyCLIBinaryPath(): string {
3132

3233
export async function run(): Promise<void> {
3334
core.info("Main run() function started.");
35+
36+
// Idempotency check: skip if action has already run in this job
37+
if (process.env[ENV_FLY_ACTION_CONFIGURED] === "true") {
38+
core.info(
39+
"Fly action has already been configured in this job, skipping duplicate run.",
40+
);
41+
return;
42+
}
43+
3444
try {
3545
const url = core.getInput(INPUT_URL, { required: true });
3646
core.info(`URL: ${url}`);
@@ -80,6 +90,10 @@ export async function run(): Promise<void> {
8090
throw new Error("Fly setup command failed");
8191
}
8292
core.info("Fly CLI setup command completed successfully.");
93+
94+
// Mark action as configured to prevent duplicate runs in same job
95+
core.exportVariable(ENV_FLY_ACTION_CONFIGURED, "true");
96+
core.info("Marked Fly action as configured for this job.");
8397
} catch (error) {
8498
core.error("Error occurred during execution.");
8599

0 commit comments

Comments
 (0)