├── .prettierrc.json ├── jest.config.js ├── .github ├── dependabot.yml ├── workflows │ ├── test.yml │ ├── codeql-analysis.yml │ └── integration-test.yml └── FUNDING.yml ├── src ├── utils.ts ├── cleanup.ts ├── index.ts └── lock.ts ├── action.yml ├── package.json ├── tsconfig.json ├── LICENSE ├── release.sh ├── .gitignore ├── README.md └── __test__ └── lock.test.ts /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | ignore: 13 | # update too often, ignore patch releases 14 | - dependency-name: "@types/node" 15 | update-types: ["version-update:semver-patch"] 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - "main" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: 16 | - ubuntu-latest 17 | - windows-latest 18 | - macos-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v2 22 | 23 | - name: Set Node.js 12.x 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: 12.x 27 | 28 | - run: npm ci 29 | - run: npm run build 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | - shogo82148 5 | patreon: # Replace with a single Patreon username 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: # Replace with a single Ko-fi username 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: # Replace with a single Liberapay username 11 | issuehunt: # Replace with a single IssueHunt username 12 | otechie: # Replace with a single Otechie username 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs' 2 | import * as os from 'os' 3 | import * as path from 'path' 4 | import * as crypto from 'crypto' 5 | 6 | const tmp = os.tmpdir() 7 | 8 | export async function mkdtemp(): Promise { 9 | return fs.mkdtemp(`${tmp}${path.sep}actions-mutex-`) 10 | } 11 | 12 | // return random string 13 | export async function random(): Promise { 14 | return new Promise(function (resolve, reject) { 15 | crypto.randomBytes(16, (err, buf) => { 16 | if (err) { 17 | reject(err) 18 | } 19 | resolve(buf.toString('hex')) 20 | }) 21 | }) 22 | } 23 | 24 | export async function sleep(waitSec: number): Promise { 25 | return new Promise(function (resolve) { 26 | setTimeout(() => resolve(), waitSec * 1000) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'actions-mutex' 2 | description: 'A GitHub Action for exclusive control' 3 | inputs: 4 | token: 5 | description: 'GitHub Token. It must have a write access to the repository.' 6 | required: true 7 | default: '${{ github.token }}' 8 | key: 9 | description: 'The name of the critical section' 10 | required: true 11 | default: 'lock' 12 | repository: 13 | description: 'A repository for locking' 14 | required: true 15 | default: '${{ github.repository }}' 16 | prefix: 17 | description: 'Prefix of branch names for locking' 18 | required: true 19 | default: 'actions-mutex-lock/' 20 | 21 | runs: 22 | using: "node12" 23 | main: "lib/index.js" 24 | post: "lib/cleanup.js" 25 | post-if: "always()" 26 | 27 | branding: 28 | icon: 'lock' 29 | color: 'gray-dark' 30 | -------------------------------------------------------------------------------- /src/cleanup.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as lock from './lock' 3 | 4 | async function run() { 5 | try { 6 | const rawState = core.getState('STATE') 7 | if (!rawState) { 8 | return 9 | } 10 | const state = JSON.parse(rawState) as lock.lockState 11 | const required = { 12 | required: true 13 | } 14 | const token = core.getInput('token', required) 15 | const key = core.getInput('key', required) 16 | const repository = core.getInput('repository', required) 17 | const prefix = core.getInput('prefix', required) 18 | 19 | await lock.unlock( 20 | { 21 | token, 22 | key, 23 | repository, 24 | prefix 25 | }, 26 | state 27 | ) 28 | } catch (e) { 29 | if (e instanceof Error) { 30 | core.setFailed(e) 31 | } else { 32 | core.setFailed(`${e}`) 33 | } 34 | } 35 | } 36 | 37 | run() 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as lock from './lock' 3 | 4 | async function run() { 5 | try { 6 | core.warning( 7 | 'shogo82148/actions-mutex is no longer maintained. ' + 8 | 'Please consider use official support for limiting concurrency. ' + 9 | 'https://github.com/shogo82148/actions-mutex#official-concurrency-support-on-github-actions' 10 | ) 11 | 12 | const required = { 13 | required: true 14 | } 15 | const token = core.getInput('token', required) 16 | const key = core.getInput('key', required) 17 | const repository = core.getInput('repository', required) 18 | const prefix = core.getInput('prefix', required) 19 | 20 | const state = await lock.lock({ 21 | token, 22 | key, 23 | repository, 24 | prefix 25 | }) 26 | core.saveState('STATE', JSON.stringify(state)) 27 | } catch (e) { 28 | if (e instanceof Error) { 29 | core.setFailed(e) 30 | } else { 31 | core.setFailed(`${e}`) 32 | } 33 | } 34 | } 35 | 36 | run() 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actions-mutex", 3 | "version": "0.0.0", 4 | "description": "A GitHub Action for exclusive control", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "format": "prettier --write **/*.ts", 9 | "format-check": "prettier --check **/*.ts", 10 | "test": "jest" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+ssh://git@github.com/shogo82148/actions-mutex.git" 15 | }, 16 | "author": "Ichinose Shogo", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/shogo82148/actions-mutex/issues" 20 | }, 21 | "homepage": "https://github.com/shogo82148/actions-mutex#readme", 22 | "dependencies": { 23 | "@actions/core": "^1.6.0", 24 | "@actions/exec": "^1.1.0", 25 | "@actions/io": "^1.1.1" 26 | }, 27 | "devDependencies": { 28 | "@types/jest": "^27.0.3", 29 | "@types/node": "^16.11.1", 30 | "jest": "^27.4.3", 31 | "jest-circus": "^27.4.2", 32 | "prettier": "^2.5.0", 33 | "ts-jest": "^27.0.7", 34 | "typescript": "^4.5.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ichinose Shogo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -uex 4 | 5 | CURRENT=$(cd "$(dirname "$0")" && pwd) 6 | VERSION=$1 7 | MAJOR=$(echo "$VERSION" | cut -d. -f1) 8 | MINOR=$(echo "$VERSION" | cut -d. -f2) 9 | PATCH=$(echo "$VERSION" | cut -d. -f3) 10 | WORKING=$CURRENT/.working 11 | 12 | : clone 13 | ORIGIN=$(git remote get-url origin) 14 | rm -rf "$WORKING" 15 | git clone "$ORIGIN" "$WORKING" 16 | cd "$WORKING" 17 | 18 | git checkout -b "releases/v$MAJOR" "origin/releases/v$MAJOR" || git checkout -b "releases/v$MAJOR" main 19 | git merge --no-ff -X theirs -m "Merge branch 'main' into releases/v$MAJOR" main || true 20 | 21 | : update the version of package.json 22 | git checkout main -- package.json package-lock.json 23 | jq ".version=\"$MAJOR.$MINOR.$PATCH\"" < package.json > .tmp.json 24 | mv .tmp.json package.json 25 | jq ".version=\"$MAJOR.$MINOR.$PATCH\"" < package-lock.json > .tmp.json 26 | mv .tmp.json package-lock.json 27 | git add package.json package-lock.json 28 | git commit -m "bump up to v$MAJOR.$MINOR.$PATCH" 29 | 30 | : build the action 31 | npm ci 32 | npm run build 33 | 34 | : remove development packages from node_modules 35 | npm prune --production 36 | perl -ne 'print unless m(^/node_modules/|/lib/$)' -i .gitignore 37 | 38 | : publish to GitHub 39 | git add . 40 | git commit -m "build v$MAJOR.$MINOR.$PATCH" || true 41 | git push origin "releases/v$MAJOR" 42 | git tag -a "v$MAJOR.$MINOR.$PATCH" -m "release v$MAJOR.$MINOR.$PATCH" 43 | git push origin "v$MAJOR.$MINOR.$PATCH" 44 | git tag -fa "v$MAJOR" -m "release v$MAJOR.$MINOR.$PATCH" 45 | git push -f origin "v$MAJOR" 46 | 47 | cd "$CURRENT" 48 | rm -rf "$WORKING" 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/ 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '17 8 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/integration-test.yml: -------------------------------------------------------------------------------- 1 | name: integration test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - "main" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | # the integration test requires write permissions. 11 | # if GITHUB_TOKEN doesn't have enough permissions, skip the test. 12 | check-permission: 13 | name: check permission 14 | runs-on: ubuntu-latest 15 | outputs: 16 | permission: ${{ steps.check.outputs.permission }} 17 | steps: 18 | - id: check 19 | uses: shogo82148/actions-check-permissions@v1 20 | 21 | build: 22 | runs-on: ubuntu-latest 23 | needs: 24 | - check-permission 25 | if: needs.check-permission.outputs.permission == 'write' 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | 30 | - name: Set Node.js 12.x 31 | uses: actions/setup-node@v1 32 | with: 33 | node-version: 12.x 34 | 35 | - run: npm ci 36 | - run: npm run build 37 | - run: npm prune --production 38 | - uses: actions/upload-artifact@v2 39 | with: 40 | name: action 41 | path: ./ 42 | 43 | job1: 44 | runs-on: ubuntu-latest 45 | needs: 'build' 46 | outputs: 47 | result: ${{ steps.critical-section.outputs.result }} 48 | steps: 49 | - uses: actions/download-artifact@v2 50 | with: 51 | name: action 52 | - name: run the action 53 | uses: ./ 54 | - id: critical-section 55 | name: critical section 56 | run: | 57 | START=$(date --iso-8601=ns) 58 | sleep 10 59 | END=$(date --iso-8601=ns) 60 | echo "::set-output name=result::$START $END" 61 | 62 | job2: 63 | runs-on: ubuntu-latest 64 | needs: 'build' 65 | outputs: 66 | result: ${{ steps.critical-section.outputs.result }} 67 | steps: 68 | - uses: actions/download-artifact@v2 69 | with: 70 | name: action 71 | - name: run the action 72 | uses: ./ 73 | - id: critical-section 74 | name: critical section 75 | run: | 76 | START=$(date --iso-8601=ns) 77 | sleep 10 78 | END=$(date --iso-8601=ns) 79 | echo "::set-output name=result::$START $END" 80 | 81 | job3: 82 | runs-on: ubuntu-latest 83 | needs: 'build' 84 | outputs: 85 | result: ${{ steps.critical-section.outputs.result }} 86 | steps: 87 | - uses: actions/download-artifact@v2 88 | with: 89 | name: action 90 | - name: run the action 91 | uses: ./ 92 | - id: critical-section 93 | name: critical section 94 | run: | 95 | START=$(date --iso-8601=ns) 96 | sleep 10 97 | END=$(date --iso-8601=ns) 98 | echo "::set-output name=result::$START $END" 99 | 100 | validation: 101 | runs-on: ubuntu-latest 102 | needs: 103 | - job1 104 | - job2 105 | - job3 106 | steps: 107 | - run: | 108 | { echo "$JOB1"; echo "$JOB2"; echo "$JOB3"; } | sort | xargs -n1 echo | sort --check 109 | env: 110 | JOB1: ${{ needs.job1.outputs.result }} 111 | JOB2: ${{ needs.job2.outputs.result }} 112 | JOB3: ${{ needs.job3.outputs.result }} 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # actions-mutex 2 | 3 | A GitHub Action for exclusive control. 4 | 5 | ## FEATURE 6 | 7 | - avoid running multiple jobs concurrently across workflows 8 | 9 | ## OFFICIAL CONCURRENCY SUPPORT ON GITHUB ACTIONS 10 | 11 | The action is no longer maintained. 12 | 13 | On April 19, 2021, GitHub launched support for limiting concurrency in the workflow files. 14 | Please consider to use this feature. 15 | 16 | - [GitHub Actions: Limit workflow run or job concurrency](https://github.blog/changelog/2021-04-19-github-actions-limit-workflow-run-or-job-concurrency/) 17 | 18 | Using this feature, the example in the SYNOPSIS section may be: 19 | 20 | ```yaml 21 | on: 22 | push: 23 | branches: 24 | - main 25 | 26 | # The workflow level concurrency 27 | # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency 28 | concurrency: deploy 29 | 30 | jobs: 31 | build: 32 | 33 | # The job level concurrency 34 | # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency 35 | concurrency: deploy 36 | 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - run: ': some jobs that can not run concurrently' 41 | ``` 42 | 43 | Please read the latest document of [Workflow syntax for GitHub Actions](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions). 44 | 45 | ## SYNOPSIS 46 | 47 | ```yaml 48 | on: 49 | push: 50 | branches: 51 | - main 52 | 53 | jobs: 54 | build: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v2 58 | 59 | - uses: shogo82148/actions-mutex@v1 60 | with: 61 | key: deploy 62 | 63 | - run: ': some jobs that can not run concurrently' 64 | ``` 65 | 66 | ## INPUTS 67 | 68 | ### key 69 | 70 | The name of the critical section. The default is "lock". 71 | 72 | ### token 73 | 74 | A GitHub Token. It must have a write access to the repository. 75 | The default is "`${{ github.token }}`" 76 | 77 | ### repository 78 | 79 | A repository for locking. 80 | The default is the repository that the workflow runs on. 81 | 82 | ### prefix 83 | 84 | Prefix of branch names for locking. 85 | The default is "actions-mutex-lock/" 86 | 87 | ## HOW THE ACTION WORKS 88 | 89 | As you known, Git rejects non-fast-forward updates. 90 | The action uses it for locking. 91 | 92 | The action tries to push a commit that contains a random string. 93 | If the pushing succeeds, it means that no concurrent jobs run. 94 | 95 | ``` 96 | $ echo "$RANDOM" > lock.txt 97 | $ git add lock.txt 98 | $ git commit -m 'add lock files' 99 | $ git push origin HEAD:actions-mutex-lock/lock 100 | To https://github.com/shogo82148/actions-mutex 101 | * [new branch] HEAD -> actions-mutex-lock/lock 102 | ``` 103 | 104 | If the pushing fails, it means that a concurrent job is now running. 105 | The action will retry to push after some wait. 106 | 107 | ``` 108 | $ echo "$RANDOM" > lock.txt 109 | $ git add lock.txt 110 | $ git commit -m 'add lock files' 111 | $ git push origin HEAD:actions-mutex-lock/lock 112 | To https://github.com/shogo82148/actions-mutex 113 | ! [rejected] HEAD -> actions-mutex-lock/lock (fetch first) 114 | error: failed to push some refs to 'https://github.com/shogo82148/actions-mutex' 115 | hint: Updates were rejected because the remote contains work that you do 116 | hint: not have locally. This is usually caused by another repository pushing 117 | hint: to the same ref. You may want to first integrate the remote changes 118 | hint: (e.g., 'git pull ...') before pushing again. 119 | hint: See the 'Note about fast-forwards' in 'git push --help' for details. 120 | ``` 121 | -------------------------------------------------------------------------------- /src/lock.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | import * as io from '@actions/io' 4 | import {promises as fs} from 'fs' 5 | import * as path from 'path' 6 | import * as utils from './utils' 7 | 8 | const serverUrl = process.env['GITHUB_SERVER_URL'] || 'https://github.com' 9 | 10 | export interface lockOptions { 11 | token?: string 12 | key: string 13 | repository: string 14 | prefix: string 15 | } 16 | 17 | export interface lockState { 18 | owner: string 19 | origin: string 20 | branch: string 21 | } 22 | 23 | class Locker { 24 | owner: string 25 | local: string 26 | branch: string 27 | origin: string 28 | key: string 29 | 30 | private constructor(owner: string, local: string, branch: string, origin: string, key: string) { 31 | this.owner = owner 32 | this.local = local 33 | this.branch = branch 34 | this.origin = origin 35 | this.key = key 36 | } 37 | 38 | static async create(options: lockOptions, state?: lockState): Promise { 39 | const owner = state ? state.owner : await utils.random() 40 | const local = await utils.mkdtemp() 41 | const key = options.key 42 | const branch = state ? state.branch : options.prefix + options.key 43 | let origin = state ? state.origin : options.repository 44 | if (/^[^/]+\/[^/]+$/.test(origin)) { 45 | // it looks that GitHub repository 46 | origin = `${serverUrl}/${origin}` 47 | } 48 | return new Locker(owner, local, branch, origin, key) 49 | } 50 | 51 | async init(token?: string): Promise { 52 | await this.git('init', this.local) 53 | await this.git('config', '--local', 'core.autocrlf', 'false') 54 | await this.git('remote', 'add', 'origin', this.origin) 55 | 56 | if (token) { 57 | // configure authorize header 58 | const auth = Buffer.from(`x-oauth-basic:${token}`).toString('base64') 59 | core.setSecret(auth) // make sure it's secret 60 | await this.git('config', '--local', `http.${serverUrl}/.extraheader`, `AUTHORIZATION: basic ${auth}`) 61 | } 62 | } 63 | 64 | async lock(token?: string): Promise { 65 | await this.init(token) 66 | 67 | // generate files 68 | let data = `# Lock File for actions-mutex 69 | 70 | The \`${this.branch}\` branch contains lock file for [actions-mutex](https://github.com/shogo82148/actions-mutex). 71 | DO NOT TOUCH this branch manually. 72 | 73 | - Key: ${this.key} 74 | ` 75 | const currentRepository = process.env['GITHUB_REPOSITORY'] 76 | const currentRunId = process.env['GITHUB_RUN_ID'] 77 | if (currentRepository && currentRunId) { 78 | data += `- Workflow: [Workflow](${serverUrl}/${currentRepository}/actions/runs/${currentRunId})` 79 | data += '\n' 80 | } 81 | await fs.writeFile(path.join(this.local, 'README.md'), data) 82 | 83 | const state = { 84 | owner: this.owner, 85 | origin: this.origin, 86 | branch: this.branch 87 | } 88 | await fs.writeFile(path.join(this.local, 'state.json'), JSON.stringify(state)) 89 | 90 | // configure user information 91 | await this.git('config', '--local', 'user.name', 'github-actions[bot]') 92 | await this.git('config', '--local', 'user.email', '1898282+github-actions[bot]@users.noreply.github.com') 93 | 94 | // commit 95 | await this.git('add', '.') 96 | await this.git('commit', '-m', 'add lock files') 97 | 98 | // try to lock 99 | let sleepSec: number = 1 100 | for (;;) { 101 | const locked = await this.tryLock() 102 | if (locked) { 103 | break 104 | } 105 | await utils.sleep(sleepSec + Math.random()) 106 | 107 | // exponential back off 108 | sleepSec *= 2 109 | if (sleepSec > 30) { 110 | sleepSec = 30 111 | } 112 | } 113 | 114 | await this.cleanup() 115 | return state 116 | } 117 | 118 | async tryLock(): Promise { 119 | let stderr: string = '' 120 | let code = await exec.exec('git', ['push', 'origin', `HEAD:${this.branch}`], { 121 | cwd: this.local, 122 | ignoreReturnCode: true, 123 | listeners: { 124 | stderr: data => { 125 | stderr += data.toString() 126 | } 127 | } 128 | }) 129 | if (code == 0) { 130 | return true 131 | } 132 | if (stderr.includes('[rejected]') || stderr.includes('[remote rejected]')) { 133 | return false 134 | } 135 | throw new Error('failed to git push: ' + code) 136 | } 137 | 138 | async unlock(token?: string): Promise { 139 | await this.init(token) 140 | await this.git('fetch', 'origin', this.branch) 141 | await this.git('checkout', `origin/${this.branch}`) 142 | const rawState = await fs.readFile(path.join(this.local, 'state.json')) 143 | const state = JSON.parse(rawState.toString()) as lockState 144 | if (state.owner !== this.owner) { 145 | // This lock is generated by another instance. 146 | // ignore it 147 | return 148 | } 149 | await this.git('push', '--delete', 'origin', this.branch) 150 | await this.cleanup() 151 | } 152 | 153 | async git(...args: string[]): Promise { 154 | await exec.exec('git', args, {cwd: this.local}) 155 | } 156 | 157 | async cleanup(): Promise { 158 | io.rmRF(this.local) 159 | } 160 | } 161 | 162 | export async function lock(options: lockOptions): Promise { 163 | const locker = await Locker.create(options) 164 | return locker.lock(options.token) 165 | } 166 | 167 | export async function unlock(options: lockOptions, state: lockState): Promise { 168 | const locker = await Locker.create(options, state) 169 | return locker.unlock(options.token) 170 | } 171 | -------------------------------------------------------------------------------- /__test__/lock.test.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs' 2 | import * as path from 'path' 3 | import * as io from '@actions/io' 4 | import * as exec from '@actions/exec' 5 | import * as utils from '../src/utils' 6 | import * as lock from '../src/lock' 7 | 8 | describe('locking', () => { 9 | let remote: string // dummy remote repository 10 | beforeEach(async () => { 11 | // prepare dummy remote repository 12 | remote = await utils.mkdtemp() 13 | await exec.exec('git', ['init', '--bare', remote]) 14 | }, 10000) 15 | 16 | afterEach(async () => { 17 | io.rmRF(remote) 18 | }) 19 | 20 | it('lock', async () => { 21 | await lock.lock({ 22 | repository: remote, 23 | key: 'lock', 24 | prefix: 'actions-mutex-lock/' 25 | }) 26 | 27 | let output: string = '' 28 | await exec.exec('git', ['-C', remote, 'branch'], { 29 | listeners: { 30 | stdout: (data: Buffer) => { 31 | output += data.toString() 32 | } 33 | } 34 | }) 35 | 36 | expect(output.trim()).toBe('actions-mutex-lock/lock') 37 | }) 38 | 39 | it( 40 | 'waits for unlocking', 41 | async () => { 42 | // prepare dummy lock 43 | const local = await utils.mkdtemp() 44 | const execOption = { 45 | cwd: local 46 | } 47 | await exec.exec('git', ['init', local], execOption) 48 | await exec.exec('git', ['config', '--local', 'core.autocrlf', 'false'], execOption) 49 | await exec.exec('git', ['remote', 'add', 'origin', remote], execOption) 50 | await fs.writeFile(path.join(local, 'state.json'), JSON.stringify({})) 51 | await exec.exec('git', ['add', '.'], execOption) 52 | await exec.exec('git', ['config', '--local', 'user.name', '[bot]'], execOption) 53 | await exec.exec('git', ['config', '--local', 'user.email', 'john@example.com'], execOption) 54 | await exec.exec('git', ['commit', '-m', 'add lock files'], execOption) 55 | await exec.exec('git', ['push', 'origin', 'HEAD:actions-mutex-lock/lock'], execOption) 56 | 57 | let locked: boolean = false 58 | const lockPromise = lock.lock({ 59 | repository: remote, 60 | key: 'lock', 61 | prefix: 'actions-mutex-lock/' 62 | }) 63 | lockPromise.then(() => { 64 | locked = true 65 | }) 66 | 67 | // wait for trying to lock 68 | await utils.sleep(1) 69 | expect(locked).toBe(false) 70 | 71 | // unlock 72 | await exec.exec('git', ['push', '--delete', 'origin', 'actions-mutex-lock/lock'], execOption) 73 | io.rmRF(local) 74 | 75 | await lockPromise 76 | expect(locked).toBe(true) 77 | }, 78 | 10 * 1000 79 | ) 80 | 81 | it('unlocks', async () => { 82 | // prepare dummy lock 83 | const local = await utils.mkdtemp() 84 | const execOption = { 85 | cwd: local 86 | } 87 | const state = { 88 | owner: 'identity-of-the-owner', 89 | origin: remote, 90 | branch: 'actions-mutex-lock/lock' 91 | } 92 | await exec.exec('git', ['init', local], execOption) 93 | await exec.exec('git', ['config', '--local', 'core.autocrlf', 'false'], execOption) 94 | await exec.exec('git', ['remote', 'add', 'origin', remote], execOption) 95 | await fs.writeFile(path.join(local, 'state.json'), JSON.stringify(state)) 96 | await exec.exec('git', ['add', '.'], execOption) 97 | await exec.exec('git', ['config', '--local', 'user.name', '[bot]'], execOption) 98 | await exec.exec('git', ['config', '--local', 'user.email', 'john@example.com'], execOption) 99 | await exec.exec('git', ['commit', '-m', 'add lock files'], execOption) 100 | await exec.exec('git', ['push', 'origin', 'HEAD:actions-mutex-lock/lock'], execOption) 101 | 102 | await lock.unlock( 103 | { 104 | repository: remote, 105 | key: 'lock', 106 | prefix: 'actions-mutex-lock/' 107 | }, 108 | { 109 | owner: 'identity-of-the-owner', 110 | origin: remote, 111 | branch: 'actions-mutex-lock/lock' 112 | } 113 | ) 114 | 115 | let output: string = '' 116 | await exec.exec('git', ['-C', remote, 'branch'], { 117 | listeners: { 118 | stdout: (data: Buffer) => { 119 | output += data.toString() 120 | } 121 | } 122 | }) 123 | 124 | expect(output.trim()).toBe('') 125 | }) 126 | 127 | it('does not unlock the lock owned by another', async () => { 128 | // prepare dummy lock 129 | const local = await utils.mkdtemp() 130 | const execOption = { 131 | cwd: local 132 | } 133 | const state = { 134 | owner: 'identity-of-another-owner', 135 | origin: remote, 136 | branch: 'actions-mutex-lock/lock' 137 | } 138 | await exec.exec('git', ['init', local], execOption) 139 | await exec.exec('git', ['config', '--local', 'core.autocrlf', 'false'], execOption) 140 | await exec.exec('git', ['remote', 'add', 'origin', remote], execOption) 141 | await fs.writeFile(path.join(local, 'state.json'), JSON.stringify(state)) 142 | await exec.exec('git', ['add', '.'], execOption) 143 | await exec.exec('git', ['config', '--local', 'user.name', '[bot]'], execOption) 144 | await exec.exec('git', ['config', '--local', 'user.email', 'john@example.com'], execOption) 145 | await exec.exec('git', ['commit', '-m', 'add lock files'], execOption) 146 | await exec.exec('git', ['push', 'origin', 'HEAD:actions-mutex-lock/lock'], execOption) 147 | 148 | await lock.unlock( 149 | { 150 | repository: remote, 151 | key: 'lock', 152 | prefix: 'actions-mutex-lock/' 153 | }, 154 | { 155 | owner: 'identity-of-the-owner', 156 | origin: remote, 157 | branch: 'actions-mutex-lock/lock' 158 | } 159 | ) 160 | 161 | let output: string = '' 162 | await exec.exec('git', ['-C', remote, 'branch'], { 163 | listeners: { 164 | stdout: (data: Buffer) => { 165 | output += data.toString() 166 | } 167 | } 168 | }) 169 | 170 | expect(output.trim()).toBe('actions-mutex-lock/lock') 171 | }) 172 | }) 173 | --------------------------------------------------------------------------------