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
14 changes: 7 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,19 +28,65 @@ 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 |
|----------------|----------|----------------|-------------------------------------------------|
| `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/) •
Expand Down
6 changes: 5 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -44,16 +52,24 @@ 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
core.info(`waiting for #${delay/1000} seconds before polling the status again`)
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!')
}
}
43 changes: 38 additions & 5 deletions src/lib/runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,57 @@ 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,
workflow_id
})

// 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
.filter(run => run.id !== run_id)
// 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
}
Loading