diff --git a/Dockerfile b/Dockerfile index f71229b..3723204 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,12 +4,12 @@ FROM alpine:3.19 AS base # hadolint ignore=DL3018 RUN apk add --no-cache --update \ - nodejs=18.18.2-r0 \ - git=2.40.1-r0 \ - openssh=9.3_p2-r0 \ - ca-certificates=20230506-r0 \ - ruby-bundler=2.4.15-r0 \ - bash=5.2.15-r5 + nodejs \ + git \ + openssh \ + ca-certificates \ + ruby-bundler \ + bash WORKDIR /action @@ -18,7 +18,7 @@ WORKDIR /action FROM base AS build # hadolint ignore=DL3018 -RUN apk add --no-cache npm=9.6.6-r0 +RUN apk add --no-cache npm # slience npm # hadolint ignore=DL3059 diff --git a/README.md b/README.md index 260f677..83a33d3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Workflows run on every commit asynchronously, this is fine for most cases, howev ## Usage +### Workflow-Level Concurrency (Default) + ###### `.github/workflows/my-workflow.yml` ``` yaml @@ -26,12 +28,57 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: ahmadnassri/action-workflow-queue@v1 # only runs additional steps if there is no other instance of `my-workflow.yml` currently running ``` +### Job-Level Concurrency + +For more granular control, you can specify a job name to check concurrency only for that specific job within the workflow: + +###### `.github/workflows/deployment-workflow.yml` + +``` yaml +jobs: + deploy-staging: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ahmadnassri/action-workflow-queue@v1 + with: + job-name: "deploy-staging" + # only waits if another workflow run has the "deploy-staging" job currently running + - name: Deploy to staging + run: echo "Deploying to staging..." + + deploy-production: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ahmadnassri/action-workflow-queue@v1 + with: + job-name: "deploy-production" + # only waits if another workflow run has the "deploy-production" job currently running + - name: Deploy to production + run: echo "Deploying to production..." + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # No queue action - tests can run concurrently + - name: Run tests + run: echo "Running tests..." +``` + +In this example: +- `deploy-staging` jobs from different workflow runs cannot run concurrently +- `deploy-production` jobs from different workflow runs cannot run concurrently +- `deploy-staging` and `deploy-production` jobs CAN run concurrently with each other +- `test` jobs can always run concurrently + ### Inputs | input | required | default | description | @@ -39,6 +86,7 @@ jobs: | `github-token` | ❌ | `github.token` | The GitHub token used to call the GitHub API | | `timeout` | ❌ | `600000` | timeout before we stop trying (in milliseconds) | | `delay` | ❌ | `10000` | delay between status checks (in milliseconds) | +| `job-name` | ❌ | `null` | Specific job name to check concurrency for (optional - defaults to workflow-level concurrency) | ---- > Author: [Ahmad Nassri](https://www.ahmadnassri.com/) • diff --git a/action.yml b/action.yml index e182ba4..f0659dc 100644 --- a/action.yml +++ b/action.yml @@ -18,6 +18,10 @@ inputs: description: delay between status checks (in milliseconds) default: "3000" + job-name: + description: Specific job name to check concurrency for (optional - defaults to workflow-level concurrency) + required: false + runs: using: docker - image: docker://ghcr.io/ahmadnassri/action-workflow-queue:1.2.0 + image: Dockerfile diff --git a/src/index.js b/src/index.js index 8a0eecc..e07719c 100644 --- a/src/index.js +++ b/src/index.js @@ -11,7 +11,8 @@ import main from './lib/index.js' const inputs = { token: core.getInput('github-token', { required: true }), delay: Number(core.getInput('delay', { required: true })), - timeout: Number(core.getInput('timeout', { required: true })) + timeout: Number(core.getInput('timeout', { required: true })), + jobName: core.getInput('job-name', { required: false }) || null } // error handler diff --git a/src/lib/index.js b/src/lib/index.js index d4671de..8502ea0 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -10,7 +10,7 @@ import runs from './runs.js' // sleep function const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) -export default async function ({ token, delay, timeout }) { +export default async function ({ token, delay, timeout, jobName }) { let timer = 0 // init octokit @@ -28,13 +28,21 @@ export default async function ({ token, delay, timeout }) { // date to check against const before = new Date(run_started_at) - core.info(`searching for workflow runs before ${before}`) + if (jobName) { + core.info(`searching for job "${jobName}" in workflow runs before ${before}`) + } else { + core.info(`searching for workflow runs before ${before}`) + } // get previous runs - let waiting_for = await runs({ octokit, run_id, workflow_id, before }) + let waiting_for = await runs({ octokit, run_id, workflow_id, before, jobName }) if (waiting_for.length === 0) { - core.info('no active run of this workflow found') + if (jobName) { + core.info(`no active run of job "${jobName}" found`) + } else { + core.info('no active run of this workflow found') + } process.exit(0) } @@ -44,7 +52,11 @@ export default async function ({ token, delay, timeout }) { for (const run of waiting_for) { - core.info(`waiting for run #${run.id}: current status: ${run.status}`) + if (jobName) { + core.info(`waiting for job "${jobName}" in run #${run.id}: current status: ${run.status}`) + } else { + core.info(`waiting for run #${run.id}: current status: ${run.status}`) + } } // zzz @@ -52,8 +64,12 @@ export default async function ({ token, delay, timeout }) { await sleep(delay) // get the data again - waiting_for = await runs({ octokit, run_id, workflow_id, before }) + waiting_for = await runs({ octokit, run_id, workflow_id, before, jobName }) } - core.info('all runs in the queue completed!') + if (jobName) { + core.info(`all instances of job "${jobName}" in the queue completed!`) + } else { + core.info('all runs in the queue completed!') + } } diff --git a/src/lib/runs.js b/src/lib/runs.js index 6afc84a..e1fd94b 100644 --- a/src/lib/runs.js +++ b/src/lib/runs.js @@ -7,7 +7,7 @@ import { inspect } from 'util' import core from '@actions/core' import github from '@actions/github' -export default async function ({ octokit, workflow_id, run_id, before }) { +export default async function ({ octokit, workflow_id, run_id, before, jobName }) { // get current run of this workflow const { data: { workflow_runs } } = await octokit.request('GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs', { ...github.context.repo, @@ -15,7 +15,7 @@ export default async function ({ octokit, workflow_id, run_id, before }) { }) // find any instances of the same workflow - const waiting_for = workflow_runs + const active_runs = workflow_runs // limit to currently running ones .filter(run => ['in_progress', 'queued', 'waiting', 'pending', 'action_required', 'requested'].includes(run.status)) // exclude this one @@ -23,8 +23,41 @@ export default async function ({ octokit, workflow_id, run_id, before }) { // get older runs .filter(run => new Date(run.run_started_at) < before) - core.info(`found ${waiting_for.length} workflow runs`) - core.debug(inspect(waiting_for.map(run => ({ id: run.id, name: run.name })))) + core.info(`found ${active_runs.length} active workflow runs`) - return waiting_for + // If no job name specified, return all active runs (existing behavior) + if (!jobName) { + core.debug(inspect(active_runs.map(run => ({ id: run.id, name: run.name })))) + return active_runs + } + + // Job-level filtering: check each active run for the specific job + const runs_with_target_job = [] + + for (const run of active_runs) { + try { + // Get jobs for this workflow run + const { data: { jobs } } = await octokit.request('GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs', { + ...github.context.repo, + run_id: run.id + }) + + // Check if this run has the target job currently running + const target_job = jobs.find(job => + job.name === jobName && + ['in_progress', 'queued', 'waiting', 'pending', 'action_required', 'requested'].includes(job.status) + ) + + if (target_job) { + core.info(`found job "${jobName}" (status: ${target_job.status}) in run #${run.id}`) + runs_with_target_job.push(run) + } + } catch (error) { + // Log error but continue checking other runs + core.warning(`failed to fetch jobs for run #${run.id}: ${error.message}`) + } + } + + core.debug(inspect(runs_with_target_job.map(run => ({ id: run.id, name: run.name })))) + return runs_with_target_job }