├── .node-version
├── .github
├── CODEOWNERS
├── config
│ └── exclude.txt
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── question.yml
│ └── bug.yml
├── workflows
│ ├── test.yml
│ ├── lint.yml
│ ├── actions-config-validation.yml
│ ├── copilot-setup-steps.yml
│ ├── codeql-analysis.yml
│ ├── package-check.yml
│ ├── update-latest-release-tag.yml
│ └── old
│ │ └── sample-workflow.yml
└── copilot-instructions.md
├── .eslintignore
├── .prettierignore
├── dist
└── package.json
├── .gitattributes
├── docs
├── assets
│ ├── ship-it.jpg
│ ├── pr-reviews.png
│ ├── custom-comment.png
│ ├── sha-deployment.png
│ ├── ignore-ci-checks.png
│ ├── deployment-approved.png
│ ├── deployment-rejected.png
│ ├── deployment-timeout.png
│ ├── required-ci-checks.png
│ ├── rules
│ │ ├── missing_deletion.png
│ │ ├── missing_pull_request.png
│ │ ├── missing_non_fast_forward.png
│ │ ├── missing_required_deployments.png
│ │ ├── mismatch_pull_request_require_code_owner_review.png
│ │ ├── mismatch_pull_request_dismiss_stale_reviews_on_push.png
│ │ ├── mismatch_pull_request_required_approving_review_count.png
│ │ └── mismatch_required_status_checks_strict_required_status_checks_policy.png
│ ├── update-branch-setting.png
│ ├── required-ci-checks-example.png
│ └── only-using-checks-for-one-ci-job.png
├── deprecated.md
├── naked-commands.md
├── maintainer-guide.md
├── unlock-on-merge.md
├── deployment-payload.md
├── hubot-style-deployment-locks.md
├── parameters.md
├── deployment-confirmation.md
├── branch-rulesets.md
├── sha-deployments.md
├── enforced-deployment-order.md
├── merge-commit-strategy.md
├── checks.md
├── deploying-commit-SHAs.md
├── custom-deployment-messages.md
└── usage.md
├── events
├── issue_comment_deploy_noop.json
├── issue_comment_deploy_main.json
└── issue_comment_deploy.json
├── src
├── functions
│ ├── api-headers.js
│ ├── colors.js
│ ├── timestamp.js
│ ├── lock-metadata.js
│ ├── params.js
│ ├── templates
│ │ └── error.js
│ ├── valid-branch-name.js
│ ├── check-input.js
│ ├── time-diff.js
│ ├── context-check.js
│ ├── string-to-array.js
│ ├── suggested-rulesets.js
│ ├── trigger-check.js
│ ├── truncate-comment-body.js
│ ├── react-emote.js
│ ├── valid-permissions.js
│ ├── deprecated-checks.js
│ ├── check-lock-file.js
│ ├── action-status.js
│ ├── is-timestamp-older.js
│ ├── label.js
│ ├── post.js
│ ├── outdated-check.js
│ ├── valid-deployment-order.js
│ ├── naked-command-check.js
│ ├── commit-safety-checks.js
│ ├── unlock-on-merge.js
│ ├── branch-ruleset-checks.js
│ ├── admin.js
│ ├── identical-commit-check.js
│ └── inputs.js
└── version.js
├── .prettierrc.json
├── .gitignore
├── __tests__
├── functions
│ ├── timestamp.test.js
│ ├── time-diff.test.js
│ ├── truncate-comment-body.test.js
│ ├── check-input.test.js
│ ├── react-emote.test.js
│ ├── context-check.test.js
│ ├── string-to-array.test.js
│ ├── deprecated-checks.test.js
│ ├── valid-branch-name.test.js
│ ├── valid-permissions.test.js
│ ├── label.test.js
│ ├── trigger-check.test.js
│ ├── params.test.js
│ ├── identical-commit-check.test.js
│ ├── is-timestamp-older.test.js
│ ├── post.test.js
│ ├── naked-command-check.test.js
│ └── outdated-check.test.js
├── setup.js
├── version.test.js
└── templates
│ └── test_deployment_message.md
├── .eslintrc.json
├── vitest.config.js
├── badges
└── coverage.svg
├── LICENSE
├── package.json
├── script
└── release
└── CONTRIBUTING.md
/.node-version:
--------------------------------------------------------------------------------
1 | 24.9.0
2 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @GrantBirki
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/dist/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | dist/** -diff linguist-generated=true
2 |
--------------------------------------------------------------------------------
/docs/assets/ship-it.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/ship-it.jpg
--------------------------------------------------------------------------------
/docs/assets/pr-reviews.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/pr-reviews.png
--------------------------------------------------------------------------------
/events/issue_comment_deploy_noop.json:
--------------------------------------------------------------------------------
1 | {
2 | "comment": {
3 | "body": ".noop"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/functions/api-headers.js:
--------------------------------------------------------------------------------
1 | export const API_HEADERS = {
2 | 'X-GitHub-Api-Version': '2022-11-28'
3 | }
4 |
--------------------------------------------------------------------------------
/docs/assets/custom-comment.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/custom-comment.png
--------------------------------------------------------------------------------
/docs/assets/sha-deployment.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/sha-deployment.png
--------------------------------------------------------------------------------
/events/issue_comment_deploy_main.json:
--------------------------------------------------------------------------------
1 | {
2 | "comment": {
3 | "body": ".deploy main"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.github/config/exclude.txt:
--------------------------------------------------------------------------------
1 | # gitignore style exclude file for the GrantBirki/json-yaml-validate Action
2 | .github/
3 |
--------------------------------------------------------------------------------
/docs/assets/ignore-ci-checks.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/ignore-ci-checks.png
--------------------------------------------------------------------------------
/docs/assets/deployment-approved.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/deployment-approved.png
--------------------------------------------------------------------------------
/docs/assets/deployment-rejected.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/deployment-rejected.png
--------------------------------------------------------------------------------
/docs/assets/deployment-timeout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/deployment-timeout.png
--------------------------------------------------------------------------------
/docs/assets/required-ci-checks.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/required-ci-checks.png
--------------------------------------------------------------------------------
/docs/assets/rules/missing_deletion.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/missing_deletion.png
--------------------------------------------------------------------------------
/docs/assets/update-branch-setting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/update-branch-setting.png
--------------------------------------------------------------------------------
/docs/assets/required-ci-checks-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/required-ci-checks-example.png
--------------------------------------------------------------------------------
/docs/assets/rules/missing_pull_request.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/missing_pull_request.png
--------------------------------------------------------------------------------
/docs/assets/rules/missing_non_fast_forward.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/missing_non_fast_forward.png
--------------------------------------------------------------------------------
/docs/assets/only-using-checks-for-one-ci-job.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/only-using-checks-for-one-ci-job.png
--------------------------------------------------------------------------------
/docs/assets/rules/missing_required_deployments.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/missing_required_deployments.png
--------------------------------------------------------------------------------
/docs/assets/rules/mismatch_pull_request_require_code_owner_review.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/mismatch_pull_request_require_code_owner_review.png
--------------------------------------------------------------------------------
/docs/assets/rules/mismatch_pull_request_dismiss_stale_reviews_on_push.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/mismatch_pull_request_dismiss_stale_reviews_on_push.png
--------------------------------------------------------------------------------
/events/issue_comment_deploy.json:
--------------------------------------------------------------------------------
1 | {
2 | "comment": {
3 | "body": ".deploy"
4 | },
5 | "issue": {
6 | "pull_request": {},
7 | "number": 1
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/docs/assets/rules/mismatch_pull_request_required_approving_review_count.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/mismatch_pull_request_required_approving_review_count.png
--------------------------------------------------------------------------------
/docs/assets/rules/mismatch_required_status_checks_strict_required_status_checks_policy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/github/branch-deploy/main/docs/assets/rules/mismatch_required_status_checks_strict_required_status_checks_policy.png
--------------------------------------------------------------------------------
/src/version.js:
--------------------------------------------------------------------------------
1 | // The version of the branch-deploy Action
2 | // Acceptable version formats:
3 | // - v1.0.0
4 | // - v4.5.1
5 | // - v10.123.44
6 | // - v1.1.1-rc.1
7 | // - etc
8 |
9 | export const VERSION = 'v11.1.2'
10 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": true,
7 | "trailingComma": "none",
8 | "bracketSpacing": false,
9 | "arrowParens": "avoid"
10 | }
11 |
--------------------------------------------------------------------------------
/src/functions/colors.js:
--------------------------------------------------------------------------------
1 | export const COLORS = {
2 | highlight: '\u001b[35m', // magenta
3 | info: '\u001b[34m', // blue
4 | success: '\u001b[32m', // green
5 | warning: '\u001b[33m', // yellow
6 | error: '\u001b[31m', // red
7 | reset: '\u001b[0m' // reset
8 | }
9 |
--------------------------------------------------------------------------------
/src/functions/timestamp.js:
--------------------------------------------------------------------------------
1 | // Helper function to generate an ISO 8601 formatted timestamp string in UTC
2 | // :returns: An ISO 8601 formatted timestamp string (ex: 2025-01-01T00:00:00.000Z)
3 | export function timestamp() {
4 | const now = new Date()
5 | return now.toISOString()
6 | }
7 |
--------------------------------------------------------------------------------
/src/functions/lock-metadata.js:
--------------------------------------------------------------------------------
1 | export const LOCK_METADATA = {
2 | lockInfoFlags: [' --info', ' --i', ' -i', ' --details', ' --d', ' -d'],
3 | lockBranchSuffix: 'branch-deploy-lock',
4 | globalLockBranch: 'global-branch-deploy-lock',
5 | lockCommitMsg: 'lock [skip ci]',
6 | lockFile: 'lock.json'
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # vscode
2 | .vscode
3 |
4 | # Dependency directory
5 | node_modules/
6 |
7 | # Coverage directory used by tools like istanbul
8 | coverage
9 |
10 | # Optional npm cache directory
11 | .npm
12 |
13 | # Optional eslint cache
14 | .eslintcache
15 |
16 | # OS metadata
17 | .DS_Store
18 | Thumbs.db
19 |
20 | # Extra
21 | tmp/
22 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | ---
2 | version: 2
3 | updates:
4 | - package-ecosystem: github-actions
5 | directory: "/"
6 | groups:
7 | github-actions:
8 | patterns:
9 | - "*"
10 | schedule:
11 | interval: monthly
12 | - package-ecosystem: npm
13 | directory: "/"
14 | groups:
15 | npm-dependencies:
16 | patterns:
17 | - "*"
18 | schedule:
19 | interval: monthly
20 |
--------------------------------------------------------------------------------
/__tests__/functions/timestamp.test.js:
--------------------------------------------------------------------------------
1 | import {timestamp} from '../../src/functions/timestamp.js'
2 | import {vi, expect, test, beforeEach, afterEach} from 'vitest'
3 |
4 | beforeEach(() => {
5 | vi.clearAllMocks()
6 | })
7 |
8 | afterEach(() => {
9 | vi.useRealTimers()
10 | })
11 |
12 | test('should return the current date in ISO 8601 format', () => {
13 | const mockDate = new Date('2025-01-01T00:00:00.000Z')
14 | vi.setSystemTime(mockDate)
15 |
16 | const result = timestamp()
17 |
18 | expect(result).toBe('2025-01-01T00:00:00.000Z')
19 | })
20 |
--------------------------------------------------------------------------------
/__tests__/functions/time-diff.test.js:
--------------------------------------------------------------------------------
1 | import {timeDiff} from '../../src/functions/time-diff.js'
2 | import {expect, test} from 'vitest'
3 |
4 | test('checks the time elapsed between two dates - days apart', async () => {
5 | expect(
6 | await timeDiff('2022-06-08T14:28:50.149Z', '2022-06-10T20:55:18.356Z')
7 | ).toStrictEqual('2d:6h:26m:28s')
8 | })
9 |
10 | test('checks the time elapsed between two dates - seconds apart', async () => {
11 | expect(
12 | await timeDiff('2022-06-10T20:55:20.999Z', '2022-06-10T20:55:50.356Z')
13 | ).toStrictEqual('0d:0h:0m:29s')
14 | })
15 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es2022": true,
4 | "node": true
5 | },
6 | "extends": "eslint:recommended",
7 | "globals": {
8 | "Atomics": "readonly",
9 | "SharedArrayBuffer": "readonly",
10 | "vi": "readonly",
11 | "describe": "readonly",
12 | "test": "readonly",
13 | "expect": "readonly",
14 | "beforeEach": "readonly",
15 | "afterEach": "readonly"
16 | },
17 | "parserOptions": {
18 | "ecmaVersion": 2022,
19 | "sourceType": "module"
20 | },
21 | "rules": {}
22 | }
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question.yml:
--------------------------------------------------------------------------------
1 | name: Question
2 | description: Open an issue to ask a question or start a discussion
3 | labels: ["question"]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | # Question ❓
9 |
10 | Thanks for taking the time to open an issue!
11 |
12 | This issue template is just for asking questions or starting a discussion. If you have a bug report, please use the bug template instead.
13 |
14 | - type: textarea
15 | id: question
16 | attributes:
17 | label: Details
18 | description: Add details about your question here
19 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: read
10 |
11 | jobs:
12 | test:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v6
16 | with:
17 | persist-credentials: false
18 |
19 | - name: setup node
20 | uses: actions/setup-node@v6
21 | with:
22 | node-version-file: .node-version
23 | cache: 'npm'
24 |
25 | - name: install dependencies
26 | run: npm ci
27 |
28 | - name: test
29 | run: npm run ci-test
30 |
--------------------------------------------------------------------------------
/src/functions/params.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import parse from 'yargs-parser'
3 |
4 | // Helper function to parse parameters if requested by input
5 | // :param params: The trimmed input parameters
6 | // :returns: A JSON object of the parameters parsed by yargs-parser
7 | // @see https://www.npmjs.com/package/yargs-parser
8 | export function parseParams(params) {
9 | // use the yarns-parser library to parse the parameters as JSON
10 | const parsed = parse(params ?? '')
11 | core.debug(
12 | `Parsing parameters string: ${params}, produced: ${JSON.stringify(parsed)}`
13 | )
14 | return parsed
15 | }
16 |
--------------------------------------------------------------------------------
/__tests__/setup.js:
--------------------------------------------------------------------------------
1 | import {vi} from 'vitest'
2 |
3 | // Mock @actions/core module globally to suppress output
4 | // Individual tests can still spy on these mocked functions
5 | vi.mock('@actions/core', async () => {
6 | const actual = await vi.importActual('@actions/core')
7 | return {
8 | ...actual,
9 | debug: vi.fn(),
10 | info: vi.fn(),
11 | warning: vi.fn(),
12 | error: vi.fn(),
13 | setOutput: vi.fn(),
14 | saveState: vi.fn(),
15 | setFailed: vi.fn(),
16 | setSecret: vi.fn(),
17 | exportVariable: vi.fn(),
18 | addPath: vi.fn(),
19 | notice: vi.fn(),
20 | startGroup: vi.fn(),
21 | endGroup: vi.fn()
22 | }
23 | })
24 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: read
10 |
11 | jobs:
12 | lint:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v6
16 | with:
17 | persist-credentials: false
18 |
19 | - name: setup node
20 | uses: actions/setup-node@v6
21 | with:
22 | node-version-file: .node-version
23 | cache: 'npm'
24 |
25 | - name: install dependencies
26 | run: npm ci
27 |
28 | - name: lint
29 | run: |
30 | npm run format-check
31 | npm run lint
32 |
--------------------------------------------------------------------------------
/__tests__/functions/truncate-comment-body.test.js:
--------------------------------------------------------------------------------
1 | import {vi, expect, test, beforeEach} from 'vitest'
2 | import {truncateCommentBody} from '../../src/functions/truncate-comment-body.js'
3 |
4 | beforeEach(() => {
5 | vi.clearAllMocks()
6 | })
7 |
8 | test('truncates a long message', () => {
9 | const message = 'a'.repeat(65537)
10 | const got = truncateCommentBody(message)
11 | expect(got).toContain('The message is too large to be posted as a comment.')
12 | expect(got.length).toBeLessThanOrEqual(65536)
13 | })
14 |
15 | test('does not truncate a short message', () => {
16 | const message = 'a'.repeat(65536)
17 | const got = truncateCommentBody(message)
18 | expect(got).toEqual(message)
19 | })
20 |
--------------------------------------------------------------------------------
/src/functions/templates/error.js:
--------------------------------------------------------------------------------
1 | export const ERROR = {
2 | messages: {
3 | upgrade_or_public: {
4 | status: 403,
5 | message:
6 | 'Upgrade to GitHub Pro or make this repository public to enable this feature',
7 | help_text:
8 | 'Rulesets are available in public repositories with GitHub Free and GitHub Free for organizations, and in public and private repositories with GitHub Pro, GitHub Team, and GitHub Enterprise Cloud. For more information see https://docs.github.com/en/get-started/learning-about-github/githubs-plans or https://docs.github.com/rest/repos/rules#get-rules-for-a-branch - (Upgrade to GitHub Pro or make this repository public to enable this feature.)'
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/functions/valid-branch-name.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // Helper function to create a valid branch name that will pass GitHub's API ref validation
4 | // :param branch: The branch name
5 | // :returns: A string of the branch name with proper formatting
6 | export function constructValidBranchName(branch) {
7 | core.debug(`constructing valid branch name: ${branch}`)
8 |
9 | if (branch === null) {
10 | return null
11 | } else if (branch === undefined) {
12 | return undefined
13 | }
14 |
15 | // If environment contains any spaces, replace all of them with a hyphen
16 | branch = branch.replace(/\s/g, '-')
17 |
18 | core.debug(`constructed valid branch name: ${branch}`)
19 | return branch
20 | }
21 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | import {defineConfig} from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: 'node',
6 | setupFiles: ['./__tests__/setup.js'],
7 | coverage: {
8 | provider: 'v8',
9 | reporter: ['text', 'lcov', 'json-summary'],
10 | include: ['src/**/*.js'],
11 | exclude: ['node_modules', '__tests__'],
12 | all: true,
13 | thresholds: {
14 | lines: 100,
15 | functions: 100,
16 | branches: 100,
17 | statements: 100
18 | }
19 | },
20 | globals: true,
21 | reporters: ['default'],
22 | logHeapUsage: false,
23 | // Suppress console output from tests
24 | onConsoleLog() {
25 | return false
26 | }
27 | }
28 | })
29 |
--------------------------------------------------------------------------------
/src/functions/check-input.js:
--------------------------------------------------------------------------------
1 | // Helper function to check an Action's input to ensure it is valid
2 | // :param input: The input to check
3 | // :returns: The input if it is valid, null otherwise
4 | export function checkInput(input) {
5 | // if the input is an empty string (most common), return null
6 | if (input === '' || input?.trim() === '') {
7 | return null
8 | }
9 |
10 | // if the input is null, undefined, or empty, return null
11 | if (input === null || input === undefined || input?.length === 0) {
12 | return null
13 | }
14 |
15 | // if the input is a string of null or undefined, return null
16 | if (input === 'null' || input === 'undefined') {
17 | return null
18 | }
19 |
20 | // if we made it this far, the input is valid, return it
21 | return input
22 | }
23 |
--------------------------------------------------------------------------------
/.github/workflows/actions-config-validation.yml:
--------------------------------------------------------------------------------
1 | name: actions-config-validation
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: read
11 | pull-requests: write # enable write permissions for pull request comments
12 |
13 | jobs:
14 | actions-config-validation:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v6
18 | with:
19 | persist-credentials: false
20 |
21 | - name: actions-config-validation
22 | uses: GrantBirki/json-yaml-validate@9bbaa8474e3af4e91f25eda8ac194fdc30564d96 # pin@v4.0.0
23 | with:
24 | comment: "true" # enable comment mode
25 | yaml_schema: "__tests__/schemas/action.schema.yml"
26 | exclude_file: ".github/config/exclude.txt"
27 |
--------------------------------------------------------------------------------
/src/functions/time-diff.js:
--------------------------------------------------------------------------------
1 | // Helper function to calculate the time difference between two dates
2 | // :param firstDate: ISO 8601 formatted date string
3 | // :param secondDate: ISO 8601 formatted date string
4 | // :returns: A string in the following format: `${days}d:${hours}h:${minutes}m:${seconds}s`
5 | export async function timeDiff(firstDate, secondDate) {
6 | const firstDateFmt = new Date(firstDate)
7 | const secondDateFmt = new Date(secondDate)
8 |
9 | var seconds = Math.floor((secondDateFmt - firstDateFmt) / 1000)
10 | var minutes = Math.floor(seconds / 60)
11 | var hours = Math.floor(minutes / 60)
12 | var days = Math.floor(hours / 24)
13 |
14 | hours = hours - days * 24
15 | minutes = minutes - days * 24 * 60 - hours * 60
16 | seconds = seconds - days * 24 * 60 * 60 - hours * 60 * 60 - minutes * 60
17 |
18 | return `${days}d:${hours}h:${minutes}m:${seconds}s`
19 | }
20 |
--------------------------------------------------------------------------------
/src/functions/context-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // A simple function that checks the event context to make sure it is valid
4 | // :param context: The GitHub Actions event context
5 | // :returns: Boolean - true if the context is valid, false otherwise
6 | export async function contextCheck(context) {
7 | // Get the PR event context
8 | var pr
9 | try {
10 | pr = context.payload.issue.pull_request
11 | } catch (error) {
12 | throw new Error(`Could not get PR event context: ${error}`)
13 | }
14 |
15 | // If the context is not valid, return false
16 | if (context.eventName !== 'issue_comment' || pr == null || pr == undefined) {
17 | core.saveState('bypass', 'true')
18 | core.warning(
19 | 'This Action can only be run in the context of a pull request comment'
20 | )
21 | return false
22 | }
23 |
24 | // If the context is valid, return true
25 | return true
26 | }
27 |
--------------------------------------------------------------------------------
/__tests__/functions/check-input.test.js:
--------------------------------------------------------------------------------
1 | import {checkInput} from '../../src/functions/check-input.js'
2 | import {expect, test} from 'vitest'
3 |
4 | test('checks an input an finds that it is valid', async () => {
5 | expect(checkInput('production')).toStrictEqual('production')
6 | })
7 |
8 | test('checks an input an finds that it is valid with true/false strings', async () => {
9 | expect(checkInput('true')).toStrictEqual('true')
10 |
11 | expect(checkInput('false')).toStrictEqual('false')
12 | })
13 |
14 | test('checks an empty string input an finds that it is invalid', async () => {
15 | expect(checkInput('')).toStrictEqual(null)
16 | })
17 |
18 | test('checks a null object input an finds that it is invalid', async () => {
19 | expect(checkInput(null)).toStrictEqual(null)
20 | })
21 |
22 | test('checks a string of null input an finds that it is invalid', async () => {
23 | expect(checkInput('null')).toStrictEqual(null)
24 | })
25 |
--------------------------------------------------------------------------------
/__tests__/version.test.js:
--------------------------------------------------------------------------------
1 | import {VERSION} from '../src/version.js'
2 | import {expect, test} from 'vitest'
3 |
4 | const versionRegex = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
5 |
6 | test('VERSION constant should match the version pattern', () => {
7 | expect(VERSION).toMatch(versionRegex)
8 | })
9 |
10 | test('should validate v1.0.0', () => {
11 | const version = 'v1.0.0'
12 | expect(version).toMatch(versionRegex)
13 | })
14 |
15 | test('should validate v4.5.1', () => {
16 | const version = 'v4.5.1'
17 | expect(version).toMatch(versionRegex)
18 | })
19 |
20 | test('should validate v10.123.44', () => {
21 | const version = 'v10.123.44'
22 | expect(version).toMatch(versionRegex)
23 | })
24 |
25 | test('should validate v1.1.1-rc.1', () => {
26 | const version = 'v1.1.1-rc.1'
27 | expect(version).toMatch(versionRegex)
28 | })
29 |
30 | test('should validate v15.19.4-rc.35', () => {
31 | const version = 'v15.19.4-rc.35'
32 | expect(version).toMatch(versionRegex)
33 | })
34 |
--------------------------------------------------------------------------------
/badges/coverage.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/copilot-setup-steps.yml:
--------------------------------------------------------------------------------
1 | name: "Copilot Setup Steps"
2 |
3 | # Allows you to test the setup steps from your repository's "Actions" tab
4 | on: workflow_dispatch
5 |
6 | jobs:
7 | copilot-setup-steps:
8 | runs-on: ubuntu-latest
9 | # Set the permissions to the lowest permissions possible needed for *your steps*. Copilot will be given its own token for its operations.
10 | permissions:
11 | # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
12 | contents: read
13 | steps:
14 | - name: checkout
15 | uses: actions/checkout@v6
16 | with:
17 | persist-credentials: false
18 |
19 | - name: setup node
20 | uses: actions/setup-node@v6
21 | with:
22 | node-version-file: .node-version
23 | cache: 'npm'
24 |
25 | - name: install dependencies
26 | run: npm ci
27 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | # Disable on PR for now to speed up testing
7 | # pull_request:
8 | # # The branches below must be a subset of the branches above
9 | # branches: [ main ]
10 | schedule:
11 | - cron: '45 3 * * 5'
12 |
13 | jobs:
14 | analyze:
15 | name: Analyze
16 | runs-on: ubuntu-latest
17 | permissions:
18 | actions: read
19 | contents: read
20 | security-events: write
21 |
22 | strategy:
23 | fail-fast: false
24 | matrix:
25 | language: [ 'javascript', 'actions' ]
26 |
27 | steps:
28 | - name: checkout
29 | uses: actions/checkout@v6
30 | with:
31 | persist-credentials: false
32 |
33 | # Initializes the CodeQL tools for scanning.
34 | - name: Initialize CodeQL
35 | uses: github/codeql-action/init@v4
36 | with:
37 | languages: ${{ matrix.language }}
38 |
39 | - name: Autobuild
40 | uses: github/codeql-action/autobuild@v4
41 |
42 | - name: Perform CodeQL Analysis
43 | uses: github/codeql-action/analyze@v4
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2018 GitHub, Inc. and contributors
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
--------------------------------------------------------------------------------
/__tests__/functions/react-emote.test.js:
--------------------------------------------------------------------------------
1 | import {reactEmote} from '../../src/functions/react-emote.js'
2 | import {vi, expect, test} from 'vitest'
3 |
4 | const context = {
5 | repo: {
6 | owner: 'corp',
7 | repo: 'test'
8 | },
9 | payload: {
10 | comment: {
11 | id: '1'
12 | }
13 | }
14 | }
15 |
16 | const octokit = {
17 | rest: {
18 | reactions: {
19 | createForIssueComment: vi.fn().mockReturnValueOnce({
20 | data: {
21 | id: '1'
22 | }
23 | })
24 | }
25 | }
26 | }
27 |
28 | test('adds a reaction emote to a comment', async () => {
29 | expect(await reactEmote('eyes', context, octokit)).toStrictEqual({
30 | data: {id: '1'}
31 | })
32 | })
33 |
34 | test('returns if no reaction is specified', async () => {
35 | expect(await reactEmote('', context, octokit)).toBe(undefined)
36 | expect(await reactEmote(null, context, octokit)).toBe(undefined)
37 | })
38 |
39 | test('throws an error if a bad emote is used', async () => {
40 | try {
41 | await reactEmote('bad', context, octokit)
42 | } catch (e) {
43 | expect(e.message).toBe('Reaction "bad" is not a valid preset')
44 | }
45 | })
46 |
--------------------------------------------------------------------------------
/src/functions/string-to-array.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // Helper function to convert a String to an Array specifically in Actions
4 | // :param string: A comma seperated string to convert to an array
5 | // :return Array: The function returns an Array - can be empty
6 | export function stringToArray(string) {
7 | try {
8 | // If the String is empty, return an empty Array
9 | if (string.trim() === '') {
10 | core.debug(
11 | 'in stringToArray(), an empty String was found so an empty Array was returned'
12 | )
13 | return []
14 | }
15 |
16 | // Split up the String on commas, trim each element, and return the Array
17 | const stringArray = string.split(',').map(target => target.trim())
18 | var results = []
19 |
20 | // filter out empty items
21 | for (const item of stringArray) {
22 | if (item === '') {
23 | continue
24 | }
25 | results.push(item)
26 | }
27 |
28 | return results
29 | } catch (error) {
30 | core.error(`failed string for debugging purposes: ${string}`)
31 | throw new Error(`could not convert String to Array - error: ${error}`)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/functions/suggested-rulesets.js:
--------------------------------------------------------------------------------
1 | export const SUGGESTED_RULESETS = [
2 | {
3 | type: 'deletion' // ensure that the stable / default branch is protected from deletion
4 | },
5 | {
6 | type: 'non_fast_forward' // ensure that the stable / default branch is protected from force pushes
7 | },
8 | {
9 | type: 'pull_request', // ensure that the stable / default branch requires a PR to merge into
10 | parameters: {
11 | dismiss_stale_reviews_on_push: true, // Dismisses approvals when new commits are pushed to the branch
12 | require_code_owner_review: true, // Require an approved review from code owners
13 | required_approving_review_count: 1 // At least one approving review is required by default (or greater)
14 | }
15 | },
16 | {
17 | type: 'required_status_checks', // ensure that the stable / default branch requires checks to pass before merging into
18 | parameters: {
19 | strict_required_status_checks_policy: true // requires that the branch is up to date with the latest stable / default branch before merging
20 | }
21 | },
22 | {
23 | type: 'required_deployments' // ensure that the stable / default branch requires deployments to pass before merging into (can be any environment)
24 | }
25 | ]
26 |
--------------------------------------------------------------------------------
/src/functions/trigger-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 |
4 | // A simple function that checks the body of the message against the trigger
5 | // :param body: The content body of the message being checked (String)
6 | // :param trigger: The "trigger" phrase which is searched for in the body of the message (String)
7 | // :returns: true if a message activates the trigger, false otherwise
8 | export async function triggerCheck(body, trigger) {
9 | // If the trigger is not activated, set the output to false and return with false
10 | if (!body.startsWith(trigger)) {
11 | core.debug(
12 | `comment body does not start with trigger: ${COLORS.highlight}${trigger}${COLORS.reset}`
13 | )
14 | return false
15 | }
16 |
17 | // Ensure the trigger match is complete: either end-of-string or followed by whitespace
18 | const nextChar = body[trigger.length]
19 | if (nextChar && !/\s/.test(nextChar)) {
20 | core.debug(
21 | `comment body starts with trigger but is not complete: ${COLORS.highlight}${trigger}${COLORS.reset}`
22 | )
23 | return false
24 | }
25 |
26 | core.info(
27 | `✅ comment body starts with trigger: ${COLORS.highlight}${trigger}${COLORS.reset}`
28 | )
29 | return true
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/package-check.yml:
--------------------------------------------------------------------------------
1 | name: package-check
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | workflow_dispatch:
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | package-check:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v6
19 | with:
20 | persist-credentials: false
21 |
22 | - name: setup node
23 | uses: actions/setup-node@v6
24 | with:
25 | node-version-file: .node-version
26 | cache: 'npm'
27 |
28 | - name: install dependencies
29 | run: npm ci
30 |
31 | - name: rebuild the dist/ directory
32 | run: npm run bundle
33 |
34 | - name: compare the expected and actual dist/ directories
35 | run: |
36 | if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then
37 | echo "Detected uncommitted changes after build. See status below:"
38 | git diff
39 | exit 1
40 | fi
41 | id: diff
42 |
43 | # If index.js was different than expected, upload the expected version as an artifact
44 | - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # pin@v5.0.0
45 | if: ${{ failure() && steps.diff.conclusion == 'failure' }}
46 | with:
47 | name: dist
48 | path: dist/
49 |
--------------------------------------------------------------------------------
/.github/workflows/update-latest-release-tag.yml:
--------------------------------------------------------------------------------
1 | name: Update Latest Release Tag
2 | run-name: Update ${{ github.event.inputs.major_version_tag }} with ${{ github.event.inputs.source_tag }}
3 |
4 | on:
5 | workflow_dispatch:
6 | inputs:
7 | source_tag:
8 | description: 'The tag or reference to use as the source (example: v8.0.0)'
9 | required: true
10 | default: vX.X.X
11 | major_version_tag:
12 | description: 'The major release tag to update with the source (example: v8)'
13 | required: true
14 | default: vX
15 |
16 | permissions:
17 | contents: write
18 |
19 | jobs:
20 | tag:
21 | runs-on: ubuntu-latest
22 | steps:
23 | - uses: actions/checkout@v6
24 | with:
25 | fetch-depth: 0
26 |
27 | - name: git config
28 | run: |
29 | git config user.name github-actions
30 | git config user.email github-actions@github.com
31 |
32 | - name: tag new target
33 | env:
34 | SOURCE_TAG: ${{ github.event.inputs.source_tag }}
35 | MAJOR_VERSION_TAG: ${{ github.event.inputs.major_version_tag }}
36 | run: git tag -f ${MAJOR_VERSION_TAG} ${SOURCE_TAG}
37 |
38 | - name: push new tag
39 | env:
40 | MAJOR_VERSION_TAG: ${{ github.event.inputs.major_version_tag }}
41 | run: git push origin ${MAJOR_VERSION_TAG} --force
42 |
--------------------------------------------------------------------------------
/docs/deprecated.md:
--------------------------------------------------------------------------------
1 | # Deprecated
2 |
3 | This document contains a list of features that have been deprecated, removed, or are no longer supported.
4 |
5 | ## `.deploy noop`
6 |
7 | > Changes made in [`v7.0.0`](https://github.com/github/branch-deploy/releases/tag/v7.0.0)
8 |
9 | First off, it should be made clear that "noop" style deployments are absolutely still supported in this Action. However, they are no longer invoked with `.deploy noop` and are now invoked with `.noop` by default.
10 |
11 | If you are running any version of this Action prior to `v7.0.0` you are likely using `.deploy noop` to invoke noop style deploys. From version `v7.0.0` and beyond, the default behavior of this Action is to invoke noop style deploys with `.noop`. You can change this behavior by setting the `noop_trigger` input to be something else, but it is no longer possible to make the noop command a subcommand of `.deploy`.
12 |
13 | You can learn more about why this change was made by viewing [this pull request](https://github.com/github/branch-deploy/pull/169) or [this issue](https://github.com/github/branch-deploy/issues/108).
14 |
15 | After release `v7.0.0` all future `.deploy noop` commands will result in a deprecation warning, a halted noop deployment, and a link to this document.
16 |
17 | Nearly all users should just be able to use `.noop` instead, or change the `noop_trigger` input to be whatever their preferred noop trigger is.
18 |
--------------------------------------------------------------------------------
/__tests__/functions/context-check.test.js:
--------------------------------------------------------------------------------
1 | import {contextCheck} from '../../src/functions/context-check.js'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import * as core from '@actions/core'
4 |
5 | const warningMock = vi.spyOn(core, 'warning')
6 | const saveStateMock = vi.spyOn(core, 'saveState')
7 |
8 | var context
9 | beforeEach(() => {
10 | vi.clearAllMocks()
11 |
12 | context = {
13 | eventName: 'issue_comment',
14 | payload: {
15 | issue: {
16 | pull_request: {}
17 | }
18 | },
19 | pull_request: {
20 | number: 1
21 | }
22 | }
23 | })
24 |
25 | test('checks the event context and finds that it is valid', async () => {
26 | expect(await contextCheck(context)).toBe(true)
27 | })
28 |
29 | test('checks the event context and finds that it is invalid', async () => {
30 | context.eventName = 'push'
31 | expect(await contextCheck(context)).toBe(false)
32 | expect(warningMock).toHaveBeenCalledWith(
33 | 'This Action can only be run in the context of a pull request comment'
34 | )
35 | expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true')
36 | })
37 |
38 | test('checks the event context and throws an error', async () => {
39 | try {
40 | await contextCheck('evil')
41 | } catch (e) {
42 | expect(e.message).toBe(
43 | "Could not get PR event context: TypeError: Cannot read properties of undefined (reading 'issue')"
44 | )
45 | }
46 | })
47 |
--------------------------------------------------------------------------------
/docs/naked-commands.md:
--------------------------------------------------------------------------------
1 | # Naked Commands
2 |
3 | "Naked commands" are commands that are not associated with an environment. They are convenient but can potentially be dangerous if a user hits `enter` before their command is fully typed out and they ship changes to production. Here are a few examples of naked commands:
4 |
5 | - `.deploy`
6 | - `.noop`
7 | - `.lock`
8 | - `.unlock`
9 | - `.wcid`
10 |
11 | These commands are "naked" because they do not have a listed environment. This means that they will default to what ever environment is configured _as the default_. In most cases, this is **production**.
12 |
13 | Here are some examples of non-naked commands:
14 |
15 | - `.deploy staging`
16 | - `.noop production`
17 | - `.deploy to production`
18 | - `.noop to staging`
19 | - `.lock staging`
20 | - `.unlock production`
21 | - `.wcid development`
22 |
23 | If you want to **enforce** non-naked commands as the default for your project, you can!
24 |
25 | ## Disabling Naked Commands
26 |
27 | By setting the following input option (`disable_naked_commands`), you can disable naked commands for your project. This means that users will have to specify an environment for their command to run.
28 |
29 | ```yaml
30 | - uses: github/branch-deploy@vX.X.X
31 | id: branch-deploy
32 | with:
33 | disable_naked_commands: "true" # <--- this option must be "true" to disable naked commands
34 | ```
35 |
36 | ---
37 |
38 | [reference](https://github.com/github/branch-deploy/issues/210)
39 |
--------------------------------------------------------------------------------
/src/functions/truncate-comment-body.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 |
4 | const truncatedMessageStart =
5 | 'The message is too large to be posted as a comment.\nClick to see the truncated message\n'
6 | const truncatedMessageEnd = '\n'
7 | // The maximum length of an issue comment body
8 | const maxCommentLength = 65536
9 |
10 | // Helper function to truncate the body of a comment if it is too long. If the message is too long,
11 | // it will be truncated and wrapped in a details tag. If the message is short enough, it will be
12 | // returned as is.
13 | // :param message: The message to be truncated (String)
14 | export function truncateCommentBody(message) {
15 | // If the message is short enough, return it as is
16 | if (message.length <= maxCommentLength) {
17 | core.debug('comment body is within length limit')
18 | return message
19 | }
20 |
21 | // if we make it here, the message is too long, so truncate it
22 | core.warning(
23 | `✂️ truncating - comment body is too long - current: ${COLORS.highlight}${message.length}${COLORS.reset} characters - max: ${COLORS.highlight}${maxCommentLength}${COLORS.reset} characters`
24 | )
25 |
26 | let truncated = message.substring(
27 | 0,
28 | maxCommentLength - truncatedMessageStart.length - truncatedMessageEnd.length
29 | )
30 |
31 | // return the truncated message wrapped in a details tag
32 | return truncatedMessageStart + truncated + truncatedMessageEnd
33 | }
34 |
--------------------------------------------------------------------------------
/__tests__/functions/string-to-array.test.js:
--------------------------------------------------------------------------------
1 | import {stringToArray} from '../../src/functions/string-to-array.js'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import * as core from '@actions/core'
4 |
5 | const debugMock = vi.spyOn(core, 'debug')
6 | const errorMock = vi.spyOn(core, 'error')
7 |
8 | beforeEach(() => {
9 | vi.clearAllMocks()
10 | })
11 |
12 | test('successfully converts a string to an array', async () => {
13 | expect(stringToArray('production,staging,development')).toStrictEqual([
14 | 'production',
15 | 'staging',
16 | 'development'
17 | ])
18 | })
19 |
20 | test('successfully converts a single string item string to an array', async () => {
21 | expect(stringToArray('production,')).toStrictEqual(['production'])
22 |
23 | expect(stringToArray('production')).toStrictEqual(['production'])
24 | })
25 |
26 | test('successfully converts an empty string to an empty array', async () => {
27 | expect(stringToArray('')).toStrictEqual([])
28 |
29 | expect(debugMock).toHaveBeenCalledWith(
30 | 'in stringToArray(), an empty String was found so an empty Array was returned'
31 | )
32 | })
33 |
34 | test('successfully converts garbage to an empty array', async () => {
35 | expect(stringToArray(',,,')).toStrictEqual([])
36 | })
37 |
38 | test('throws an error when string processing fails', async () => {
39 | // Pass a non-string value to trigger the error
40 | expect(() => stringToArray(null)).toThrow('could not convert String to Array')
41 | expect(errorMock).toHaveBeenCalled()
42 | })
43 |
--------------------------------------------------------------------------------
/src/functions/react-emote.js:
--------------------------------------------------------------------------------
1 | import {API_HEADERS} from './api-headers.js'
2 |
3 | // Fixed presets of allowed emote types as defined by GitHub
4 | const presets = [
5 | '+1',
6 | '-1',
7 | 'laugh',
8 | 'confused',
9 | 'heart',
10 | 'hooray',
11 | 'rocket',
12 | 'eyes'
13 | ]
14 |
15 | // Helper function to add a reaction to an issue_comment
16 | // :param reaction: A string which determines the reaction to use (String)
17 | // :param context: The GitHub Actions event context
18 | // :param octokit: The octokit client
19 | // :returns: The reactRes object which contains the reaction ID among other things. Returns nil if no reaction was specified, or throws an error if it fails
20 | export async function reactEmote(reaction, context, octokit) {
21 | // Get the owner and repo from the context
22 | const {owner, repo} = context.repo
23 |
24 | // If the reaction is not specified, return
25 | if (!reaction || reaction.trim() === '') {
26 | return
27 | }
28 |
29 | // Find the reaction in the list of presets, otherwise throw an error
30 | const preset = presets.find(preset => preset === reaction.trim())
31 | if (!preset) {
32 | throw new Error(`Reaction "${reaction}" is not a valid preset`)
33 | }
34 |
35 | // Add the reaction to the issue_comment
36 | const reactRes = await octokit.rest.reactions.createForIssueComment({
37 | owner,
38 | repo,
39 | comment_id: context.payload.comment.id,
40 | content: preset,
41 | headers: API_HEADERS
42 | })
43 |
44 | // Return the reactRes which contains the id for reference later
45 | return reactRes
46 | }
47 |
--------------------------------------------------------------------------------
/__tests__/functions/deprecated-checks.test.js:
--------------------------------------------------------------------------------
1 | import {isDeprecated} from '../../src/functions/deprecated-checks.js'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import * as core from '@actions/core'
4 |
5 | const docsLink =
6 | 'https://github.com/github/branch-deploy/blob/main/docs/deprecated.md'
7 | const warningMock = vi.spyOn(core, 'warning')
8 |
9 | var context
10 | var octokit
11 |
12 | beforeEach(() => {
13 | vi.clearAllMocks()
14 |
15 | context = {
16 | repo: {
17 | owner: 'corp',
18 | repo: 'test'
19 | },
20 | issue: {
21 | number: 1
22 | },
23 | payload: {
24 | comment: {
25 | id: '1'
26 | }
27 | }
28 | }
29 |
30 | octokit = {
31 | rest: {
32 | reactions: {
33 | createForIssueComment: vi.fn().mockReturnValueOnce({
34 | data: {}
35 | })
36 | },
37 | issues: {
38 | createComment: vi.fn().mockReturnValueOnce({
39 | data: {}
40 | })
41 | }
42 | }
43 | }
44 | })
45 |
46 | test('checks a deployment message and does not find anything that is deprecated', async () => {
47 | const body = '.deploy to production'
48 | expect(await isDeprecated(body, octokit, context)).toBe(false)
49 | })
50 |
51 | test('checks a deployment message and finds the old "noop" style command which is now deprecated', async () => {
52 | const body = '.deploy noop'
53 | expect(await isDeprecated(body, octokit, context)).toBe(true)
54 | expect(warningMock).toHaveBeenCalledWith(
55 | `'.deploy noop' is deprecated. Please view the docs for more information: ${docsLink}#deploy-noop`
56 | )
57 | })
58 |
--------------------------------------------------------------------------------
/.github/workflows/old/sample-workflow.yml:
--------------------------------------------------------------------------------
1 | # name: "sample-workflow"
2 |
3 | # on:
4 | # issue_comment:
5 | # types: [created]
6 |
7 | # permissions:
8 | # pull-requests: write
9 | # deployments: write
10 | # contents: write
11 | # checks: read
12 | # statuses: read
13 |
14 | # jobs:
15 | # sample:
16 | # if: ${{ github.event.issue.pull_request }} # only run on pull request comments
17 | # environment: production-secrets
18 | # runs-on: ubuntu-latest
19 | # steps:
20 | # # Need to checkout for testing the Action in this repo
21 | # - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3.0.2
22 |
23 | # # Start the branch deployment
24 | # - uses: ./
25 | # id: branch-deploy
26 | # with:
27 | # admins: grantbirki
28 |
29 | # # Check out the ref from the output of the IssueOps command
30 | # - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3.0.2
31 | # if: ${{ steps.branch-deploy.outputs.continue == 'true' }}
32 | # with:
33 | # ref: ${{ steps.branch-deploy.outputs.sha }}
34 |
35 | # # Do some fake "noop" deployment logic here
36 | # - name: fake noop deploy
37 | # if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' }}
38 | # run: echo "I am doing a fake noop deploy"
39 |
40 | # # Do some fake "regular" deployment logic here
41 | # - name: fake regular deploy
42 | # if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop != 'true' }}
43 | # run: echo "I am doing a fake regular deploy"
44 |
--------------------------------------------------------------------------------
/src/functions/valid-permissions.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {API_HEADERS} from './api-headers.js'
3 |
4 | // Helper function to check if an actor has permissions to use this Action in a given repository
5 | // :param octokit: The octokit client
6 | // :param context: The GitHub Actions event context
7 | // :param validPermissionsArray: An array of permissions that the actor must have
8 | // :returns: An error string if the actor doesn't have permissions, otherwise true
9 | export async function validPermissions(
10 | octokit,
11 | context,
12 | validPermissionsArray
13 | ) {
14 | // fetch the defined permissions from the Action input
15 |
16 | core.setOutput('actor', context.actor)
17 |
18 | // Get the permissions of the user who made the comment
19 | const permissionRes = await octokit.rest.repos.getCollaboratorPermissionLevel(
20 | {
21 | ...context.repo,
22 | username: context.actor,
23 | headers: API_HEADERS
24 | }
25 | )
26 |
27 | // Check permission API call status code
28 | if (permissionRes.status !== 200) {
29 | return `Permission check returns non-200 status: ${permissionRes.status}`
30 | }
31 |
32 | // Check to ensure the user has at least write permission on the repo
33 | const actorPermission = permissionRes.data.permission
34 | if (!validPermissionsArray.includes(actorPermission)) {
35 | return `👋 @${
36 | context.actor
37 | }, that command requires the following permission(s): \`${validPermissionsArray.join(
38 | '/'
39 | )}\`\n\nYour current permissions: \`${actorPermission}\``
40 | }
41 |
42 | // Return true if the user has permissions
43 | return true
44 | }
45 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Enabling Branch Deployments through IssueOps with GitHub Actions",
3 | "type": "module",
4 | "main": "lib/main.js",
5 | "scripts": {
6 | "format": "prettier --write '**/*.js'",
7 | "format-check": "prettier --check '**/*.js'",
8 | "lint": "eslint src/**/*.js",
9 | "package": "ncc build src/main.js -o dist --source-map --license licenses.txt",
10 | "test": "mkdir -p badges && BRANCH_DEPLOY_VITEST_TEST=true vitest run --coverage && make-coverage-badge --output-path ./badges/coverage.svg",
11 | "ci-test": "BRANCH_DEPLOY_VITEST_TEST=true vitest run --coverage",
12 | "all": "npm run format && npm run lint && npm run package",
13 | "bundle": "npm run format && npm run package",
14 | "act": "npm run format && npm run package && act issue_comment -e events/issue_comment_deploy.json -s GITHUB_TOKEN=faketoken -j test"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/github/branch-deploy.git"
19 | },
20 | "keywords": [
21 | "actions",
22 | "issueops",
23 | "deployment",
24 | "github"
25 | ],
26 | "author": "Grant Birkinbine",
27 | "license": "MIT",
28 | "dependencies": {
29 | "@actions/core": "^1.11.1",
30 | "@actions/github": "^6.0.1",
31 | "@octokit/plugin-retry": "^8.0.2",
32 | "@octokit/request": "^10.0.6",
33 | "@octokit/rest": "^22.0.0",
34 | "dedent-js": "^1.0.1",
35 | "github-username-regex-js": "^1.0.0",
36 | "nunjucks": "^3.2.4",
37 | "yargs-parser": "^22.0.0"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^24.9.2",
41 | "@vercel/ncc": "^0.38.4",
42 | "@vitest/coverage-v8": "^4.0.6",
43 | "eslint": "^8.57.0",
44 | "js-yaml": "^4.1.0",
45 | "make-coverage-badge": "^1.2.0",
46 | "prettier": "^3.7.3",
47 | "vitest": "^4.0.6"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug/issue report
3 | labels: ["bug"]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | # Bug Report 🐛
9 |
10 | Thanks for taking the time to fill out this bug report!
11 |
12 | Please answer each question below to your best ability. It is okay to leave questions blank if you have to!
13 |
14 | - type: textarea
15 | id: description
16 | attributes:
17 | label: Describe the Issue
18 | description: Please describe the bug/issue in detail
19 | placeholder: Something is wrong with X when trying to do Y
20 | validations:
21 | required: true
22 |
23 | - type: textarea
24 | id: config
25 | attributes:
26 | label: Action Configuration
27 | description: Please copy and paste your Action's configuration. Please omit any sensitive information if your configuration is not public.
28 | placeholder: |
29 | ```yaml
30 | - name: branch-deploy
31 | id: branch-deploy
32 | uses: github/branch-deploy@vX.X.X
33 | with:
34 | trigger: ".deploy"
35 | environment: "production"
36 | stable_branch: "main"
37 | ```
38 |
39 | - type: textarea
40 | id: logs
41 | attributes:
42 | label: Relevant Actions Log Output
43 | description: Please copy and paste any relevant log output. Please ensure to re-run your workflow with debug mode enabled if you can. The debug logs from this Action are quite rich and can help us solve your problem! If your Action's workflow is public, please provide a direct link to the logs. Thank you!
44 |
45 | - type: textarea
46 | id: extra
47 | attributes:
48 | label: Extra Information
49 | description: Any extra information, links to issues, screenshots, etc
50 |
--------------------------------------------------------------------------------
/__tests__/functions/valid-branch-name.test.js:
--------------------------------------------------------------------------------
1 | import {constructValidBranchName} from '../../src/functions/valid-branch-name.js'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import * as core from '@actions/core'
4 |
5 | const debugMock = vi.spyOn(core, 'debug')
6 |
7 | const branchName = 'production'
8 |
9 | beforeEach(() => {
10 | vi.clearAllMocks()
11 | })
12 |
13 | test('does not make any modifications to a valid branch name', async () => {
14 | expect(constructValidBranchName(branchName)).toBe(branchName)
15 | expect(debugMock).toHaveBeenCalledWith(
16 | `constructing valid branch name: ${branchName}`
17 | )
18 | expect(debugMock).toHaveBeenCalledWith(
19 | `constructed valid branch name: ${branchName}`
20 | )
21 | })
22 |
23 | test('replaces spaces with hyphens', async () => {
24 | expect(constructValidBranchName(`super ${branchName}`)).toBe(
25 | `super-${branchName}`
26 | )
27 | expect(debugMock).toHaveBeenCalledWith(
28 | `constructing valid branch name: super ${branchName}`
29 | )
30 | expect(debugMock).toHaveBeenCalledWith(
31 | `constructed valid branch name: super-${branchName}`
32 | )
33 | })
34 |
35 | test('replaces multiple spaces with hyphens', async () => {
36 | expect(constructValidBranchName(`super duper ${branchName}`)).toBe(
37 | `super-duper-${branchName}`
38 | )
39 | expect(debugMock).toHaveBeenCalledWith(
40 | `constructing valid branch name: super duper ${branchName}`
41 | )
42 | expect(debugMock).toHaveBeenCalledWith(
43 | `constructed valid branch name: super-duper-${branchName}`
44 | )
45 | })
46 |
47 | test('returns null if the branch is null', async () => {
48 | expect(constructValidBranchName(null)).toBe(null)
49 | })
50 |
51 | test('returns undefined if the branch is undefined', async () => {
52 | expect(constructValidBranchName(undefined)).toBe(undefined)
53 | })
54 |
--------------------------------------------------------------------------------
/script/release:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Usage:
4 | # script/release
5 |
6 | # COLORS
7 | OFF='\033[0m'
8 | RED='\033[0;31m'
9 | GREEN='\033[0;32m'
10 | BLUE='\033[0;34m'
11 |
12 | # Read the version from src/version.js
13 | version_file="src/version.js"
14 | if [[ ! -f $version_file ]]; then
15 | echo -e "${RED}ERROR${OFF} - Version file not found: $version_file"
16 | exit 1
17 | fi
18 |
19 | version_line=$(grep 'export const VERSION' $version_file)
20 | if [[ -z $version_line ]]; then
21 | echo -e "${RED}ERROR${OFF} - Version line not found in: $version_file"
22 | exit 1
23 | fi
24 |
25 | # Extract the version value
26 | new_tag=$(echo $version_line | sed -E "s/export const VERSION = '([^']+)'/\1/")
27 | if [[ -z $new_tag ]]; then
28 | echo -e "${RED}ERROR${OFF} - Failed to extract version from: $version_file"
29 | exit 1
30 | fi
31 |
32 | # Validate the version tag format
33 | tag_regex='^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$'
34 | echo "$new_tag" | grep -E "$tag_regex" > /dev/null
35 |
36 | if [[ $? -ne 0 ]]; then
37 | echo -e "${RED}ERROR${OFF} - Tag: $new_tag is not valid. Please use vX.X.X or vX.X.X-rc.X format."
38 | exit 1
39 | fi
40 |
41 | # Get the latest tag
42 | latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
43 | echo -e "The latest release tag is: ${BLUE}${latest_tag}${OFF}"
44 |
45 | # Confirm the new tag
46 | read -p "New Release Tag (press ENTER for default: ${new_tag}): " input_tag
47 | new_tag=${input_tag:-$new_tag}
48 |
49 | # Tag the new release
50 | git tag -a $new_tag -m "$new_tag Release"
51 | if [[ $? -ne 0 ]]; then
52 | echo -e "${RED}ERROR${OFF} - Failed to create tag: $new_tag"
53 | exit 1
54 | fi
55 |
56 | echo -e "${GREEN}OK${OFF} - Tagged: $new_tag"
57 |
58 | # Push the tags to remote
59 | git push --tags
60 | if [[ $? -ne 0 ]]; then
61 | echo -e "${RED}ERROR${OFF} - Failed to push tags to remote"
62 | exit 1
63 | fi
64 |
65 | echo -e "${GREEN}OK${OFF} - Tags pushed to remote!"
66 | echo -e "${GREEN}DONE${OFF}"
67 |
--------------------------------------------------------------------------------
/src/functions/deprecated-checks.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import dedent from 'dedent-js'
3 | import {API_HEADERS} from './api-headers.js'
4 |
5 | // The old and common trigger for noop style deployments
6 | const oldNoopInput = '.deploy noop'
7 | const docsLink =
8 | 'https://github.com/github/branch-deploy/blob/main/docs/deprecated.md'
9 | const thumbsDown = '-1'
10 |
11 | // A helper function to check against common inputs to see if they are deprecated
12 | // :param body: The content body of the message being checked (String)
13 | // :param octokit: The octokit object
14 | // :param context: The context of the action
15 | // :returns: true if the input is deprecated, false otherwise
16 | export async function isDeprecated(body, octokit, context) {
17 | // If the body of the payload starts with the common 'old noop' trigger, warn the user and exit
18 | if (body.startsWith(oldNoopInput)) {
19 | core.warning(
20 | `'${oldNoopInput}' is deprecated. Please view the docs for more information: ${docsLink}#deploy-noop`
21 | )
22 |
23 | const message = dedent(`
24 | ### Deprecated Input Detected
25 |
26 | ⚠️ Command is Deprecated ⚠️
27 |
28 | The \`${oldNoopInput}\` command is deprecated. The new default is now \`.noop\`. Please view the docs for more information: ${docsLink}#deploy-noop
29 | `)
30 |
31 | // add a comment to the issue with the message
32 | await octokit.rest.issues.createComment({
33 | ...context.repo,
34 | issue_number: context.issue.number,
35 | body: message,
36 | headers: API_HEADERS
37 | })
38 |
39 | // add a reaction to the issue_comment to indicate failure
40 | await octokit.rest.reactions.createForIssueComment({
41 | ...context.repo,
42 | comment_id: context.payload.comment.id,
43 | content: thumbsDown,
44 | headers: API_HEADERS
45 | })
46 |
47 | return true
48 | }
49 |
50 | // if we get here, the input is not deprecated
51 | return false
52 | }
53 |
--------------------------------------------------------------------------------
/__tests__/functions/valid-permissions.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import {validPermissions} from '../../src/functions/valid-permissions.js'
4 |
5 | const setOutputMock = vi.spyOn(core, 'setOutput')
6 |
7 | var octokit
8 | var context
9 | var permissions = ['write', 'admin']
10 |
11 | beforeEach(() => {
12 | vi.clearAllMocks()
13 | process.env.INPUT_PERMISSIONS = 'write,admin'
14 |
15 | context = {
16 | actor: 'monalisa'
17 | }
18 |
19 | octokit = {
20 | rest: {
21 | repos: {
22 | getCollaboratorPermissionLevel: vi.fn().mockReturnValueOnce({
23 | status: 200,
24 | data: {
25 | permission: 'write'
26 | }
27 | })
28 | }
29 | }
30 | }
31 | })
32 |
33 | test('determines that a user has valid permissions to invoke the Action', async () => {
34 | expect(await validPermissions(octokit, context, permissions)).toEqual(true)
35 | expect(setOutputMock).toHaveBeenCalledWith('actor', 'monalisa')
36 | })
37 |
38 | test('determines that a user has does not valid permissions to invoke the Action', async () => {
39 | octokit.rest.repos.getCollaboratorPermissionLevel = vi.fn().mockReturnValue({
40 | status: 200,
41 | data: {
42 | permission: 'read'
43 | }
44 | })
45 |
46 | expect(await validPermissions(octokit, context, permissions)).toEqual(
47 | '👋 @monalisa, that command requires the following permission(s): `write/admin`\n\nYour current permissions: `read`'
48 | )
49 | expect(setOutputMock).toHaveBeenCalledWith('actor', 'monalisa')
50 | })
51 |
52 | test('fails to get actor permissions due to a bad status code', async () => {
53 | octokit.rest.repos.getCollaboratorPermissionLevel = vi.fn().mockReturnValue({
54 | status: 500
55 | })
56 |
57 | expect(await validPermissions(octokit, context, permissions)).toEqual(
58 | 'Permission check returns non-200 status: 500'
59 | )
60 | expect(setOutputMock).toHaveBeenCalledWith('actor', 'monalisa')
61 | })
62 |
--------------------------------------------------------------------------------
/src/functions/check-lock-file.js:
--------------------------------------------------------------------------------
1 | import {LOCK_METADATA} from './lock-metadata.js'
2 | import {COLORS} from './colors.js'
3 | import {constructValidBranchName} from './valid-branch-name.js'
4 | import * as core from '@actions/core'
5 | import {API_HEADERS} from './api-headers.js'
6 |
7 | const LOCK_FILE = LOCK_METADATA.lockFile
8 |
9 | // Helper function to check if a lock file exists and decodes it if it does
10 | // :param octokit: The octokit client
11 | // :param context: The GitHub Actions event context
12 | // :param branchName: The name of the branch to check
13 | // :return: The lock file contents if it exists, false if not
14 | export async function checkLockFile(octokit, context, branchName) {
15 | branchName = constructValidBranchName(branchName)
16 |
17 | core.debug(`checking if lock file exists on branch: ${branchName}`)
18 | // If the lock branch exists, check if a lock file exists
19 | try {
20 | // Get the lock file contents
21 | const response = await octokit.rest.repos.getContent({
22 | ...context.repo,
23 | path: LOCK_FILE,
24 | ref: branchName,
25 | headers: API_HEADERS
26 | })
27 |
28 | // decode the file contents to json
29 | const lockData = JSON.parse(
30 | Buffer.from(response.data.content, 'base64').toString()
31 | )
32 |
33 | return lockData
34 | } catch (error) {
35 | core.debug(`checkLockFile() error.status: ${error.status}`)
36 | // If the lock file doesn't exist, return false
37 | if (error.status === 404) {
38 | const lockFileNotFoundMsg = `🔍 lock file does not exist on branch: ${COLORS.highlight}${branchName}`
39 | if (branchName === LOCK_METADATA.globalLockBranch) {
40 | // since we jump out directly to the 'lock file' without checking the branch (only on global locks), we get this error often so we just want it to be a debug message
41 | core.debug(lockFileNotFoundMsg)
42 | } else {
43 | core.info(lockFileNotFoundMsg)
44 | }
45 | return false
46 | }
47 |
48 | // If some other error occurred, throw it
49 | throw new Error(error)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/functions/action-status.js:
--------------------------------------------------------------------------------
1 | import {truncateCommentBody} from './truncate-comment-body.js'
2 | import {API_HEADERS} from './api-headers.js'
3 |
4 | // Default failure reaction
5 | const thumbsDown = '-1'
6 | // Default success reaction
7 | const rocket = 'rocket'
8 | // Alt success reaction
9 | const thumbsUp = '+1'
10 |
11 | // Helper function to add a status update for the action that is running a branch deployment
12 | // It also updates the original comment with a reaction depending on the status of the deployment
13 | // :param context: The context of the action
14 | // :param octokit: The octokit object
15 | // :param reactionId: The id of the original reaction added to our trigger comment (Integer)
16 | // :param message: The message to be added to the action status (String)
17 | // :param success: Boolean indicating whether the deployment was successful (Boolean)
18 | // :param altSuccessReaction: Boolean indicating whether to use the alternate success reaction (Boolean)
19 | // :returns: Nothing
20 | export async function actionStatus(
21 | context,
22 | octokit,
23 | reactionId,
24 | message,
25 | success,
26 | altSuccessReaction
27 | ) {
28 | // check if message is null or empty
29 | if (!message || message.length === 0) {
30 | const log_url = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`
31 | message = 'Unknown error, [check logs](' + log_url + ') for more details.'
32 | }
33 |
34 | await octokit.rest.issues.createComment({
35 | ...context.repo,
36 | issue_number: context.issue.number,
37 | body: truncateCommentBody(message),
38 | headers: API_HEADERS
39 | })
40 |
41 | // Select the reaction to add to the issue_comment
42 | var reaction
43 | if (success) {
44 | if (altSuccessReaction) {
45 | reaction = thumbsUp
46 | } else {
47 | reaction = rocket
48 | }
49 | } else {
50 | reaction = thumbsDown
51 | }
52 |
53 | // remove the initial reaction on the IssueOp comment that triggered this action
54 | await octokit.rest.reactions.deleteForIssueComment({
55 | ...context.repo,
56 | comment_id: context.payload.comment.id,
57 | reaction_id: reactionId,
58 | headers: API_HEADERS
59 | })
60 |
61 | // add a reaction to the issue_comment to indicate success or failure
62 | await octokit.rest.reactions.createForIssueComment({
63 | ...context.repo,
64 | comment_id: context.payload.comment.id,
65 | content: reaction,
66 | headers: API_HEADERS
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/docs/maintainer-guide.md:
--------------------------------------------------------------------------------
1 | # Maintainer Guide 🧑🔬
2 |
3 | This document is intended for maintainers of the project. It describes the process of maintaining the project, including how to release new versions.
4 |
5 | ## Release Process 🏷️
6 |
7 | Here is a very high level flow of how we go from idea to release:
8 |
9 | 1. User XYZ wants to add feature ABC to the project
10 | 2. The user likely opens an issue
11 | 3. Either the user or a maintainer creates a pull request with the feature
12 | 4. The pull request is reviewed by a maintainer - CI passing, etc
13 | 5. The pull request is merged
14 | 6. A new tag is pushed to the repository (see section below for more details)
15 | 7. A pre-release is created on GitHub. Maintainers can test this pre-release and so can users.
16 | 8. The pre-release looks good, so the maintainer(s) flip the release to a full release (aka latest)
17 | 9. The [`update-latest-release-tag`](../.github/workflows/update-latest-release-tag.yml) workflow is run to sync major release tags with the latest release tag
18 |
19 | ### Creating a Release
20 |
21 | > This project uses semantic versioning
22 |
23 | Creating a release is a rather straight forward process.
24 |
25 | First, you must edit the [`src/version.js`](src/version.js) file to bump the version number.
26 |
27 | Second, you simply run the following script to push a new release tag using the version number in `src/version.js` as a default:
28 |
29 | ```bash
30 | script/release
31 | ```
32 |
33 | > Pretty much just `script/release` and press `ENTER` for the default
34 |
35 | Now that the new release is published you can set it as a pre-release to test it out, or set it as the latest release.
36 |
37 | Once a tag is set to the latest release, we need to update the major release tags to point to the latest release tag.
38 |
39 | _What does that mean?_... Here is an example! Let's say we just pushed a new release with the tag `v1.2.3` and we want our "major" release tag `v1` to point to this new release. We would run the [`update-latest-release-tag`](../.github/workflows/update-latest-release-tag.yml) workflow to accomplish this. The workflow has a few inputs with descriptions that will help you along with this process.
40 |
41 | The reason that we update release tags to point to major releases is for the convenience of users. If a user wants to use the latest version of this Action, all they need to do is simply point to the latest major release tag. If they point at `v1` then they will pick up **all** changes made to `v1.x.x` without having to update their workflows. When/if a `v2` tag rolls out, then they will need to update their workflows (example).
42 |
--------------------------------------------------------------------------------
/docs/unlock-on-merge.md:
--------------------------------------------------------------------------------
1 | # Unlock On Merge Mode
2 |
3 | This is an alternate workflow configuration that is bundled into this Action for simplicity. It is not required to use this Action and it is entirely optional. Essentially, if you create a new workflow and pass in the `unlock_on_merge_mode` input with a value of `true`, then an entirely new workflow type will run.
4 |
5 | This workflow can only run in the context of a merged pull request and it will look for all locks associated with the merged pull request. If "sticky" locks are found that were created from the merged pull request, then they will be removed via this workflow.
6 |
7 | This can be especially useful when you merge a pull request and want the "sticky" locks that you claimed with `.lock` to be automatically cleaned up.
8 |
9 | > The "Unlock on Merge Mode" is very similar to the "[Merge Commit Strategy](merge-commit-strategy.md)" workflow and they often complement each other well.
10 |
11 | ## Full Workflow Example
12 |
13 | This is a complete Actions workflow example that demonstrates how to use the "Unlock on Merge Mode" workflow.
14 |
15 | ```yaml
16 | name: Unlock On Merge
17 |
18 | on:
19 | pull_request:
20 | types: [closed]
21 |
22 | permissions:
23 | contents: write
24 |
25 | jobs:
26 | unlock-on-merge:
27 | runs-on: ubuntu-latest
28 | # Gate this job to only run when the pull request is merged (not closed)
29 | # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-pull_request-workflow-when-a-pull-request-merges
30 | if: github.event.pull_request.merged == true
31 |
32 | steps:
33 | - name: unlock on merge
34 | uses: github/branch-deploy@vX.X.X
35 | id: unlock-on-merge
36 | with:
37 | unlock_on_merge_mode: "true" # <-- indicates that this is the "Unlock on Merge Mode" workflow
38 | ```
39 |
40 | **Note**: It should be noted that if you use custom `environment_targets` on your main `branch-deploy` workflow, then you must also bring those settings over to this new workflow as well. See the example below:
41 |
42 | ```yaml
43 | # .github/workflows/branch-deploy.yml
44 | - uses: github/branch-deploy@vX.X.X
45 | id: branch-deploy
46 | with:
47 | trigger: ".deploy"
48 | environment_targets: "prod,stage,dev"
49 |
50 | # -------------------------------------------------
51 |
52 | # .github/workflows/unlock-on-merge.yml
53 | - name: unlock on merge
54 | uses: github/branch-deploy@vX.X.X
55 | id: unlock-on-merge
56 | with:
57 | unlock_on_merge_mode: "true" # <-- indicates that this is the "Unlock on Merge Mode" workflow
58 | environment_targets: "prod,stage,dev"
59 |
--------------------------------------------------------------------------------
/__tests__/templates/test_deployment_message.md:
--------------------------------------------------------------------------------
1 | ### Deployment Results {{ ":rocket:" if status === "success" else ":cry:" }}
2 |
3 | The following variables are available to use in this template:
4 |
5 | - `environment` - The name of the environment (String)
6 | - `environment_url` - The URL of the environment (String) {Optional}
7 | - `status` - The status of the deployment (String) - `success`, `failure`, or `unknown`
8 | - `noop` - Whether or not the deployment is a noop (Boolean)
9 | - `ref` - The ref of the deployment (String)
10 | - `sha` - The exact commit SHA of the deployment (String)
11 | - `actor` - The GitHub username of the actor who triggered the deployment (String)
12 | - `approved_reviews_count` - The number of approved reviews on the pull request at the time of deployment (String of a number)
13 | - `deployment_id` - The ID of the deployment (String)
14 | - `review_decision` - The decision of the review (String or null) - `"APPROVED"`, `"REVIEW_REQUIRED"`, `"CHANGES_REQUESTED"`, `null`, etc.
15 | - `params` - The raw parameters provided in the deploy command (String)
16 | - `parsed_params` - The parsed parameters provided in the deploy command (String)
17 | - `deployment_end_time` - The end time of the deployment - this value is not _exact_ but it is very close (String)
18 | - `logs` - The url to the logs of the deployment (String)
19 | - `commit_verified` - Whether or not the commit was verified (Boolean)
20 | - `total_seconds` - The total number of seconds the deployment took (String of a number)
21 |
22 | Here is an example:
23 |
24 | {{ actor }} deployed branch `{{ ref }}` to the **{{ environment }}** environment. This deployment was a {{ status }} {{ ":rocket:" if status === "success" else ":cry:" }}.
25 |
26 | The exact commit sha that was used for the deployment was `{{ sha }}`.
27 |
28 | The exact deployment ID for this deployment was `{{ deployment_id }}`.
29 |
30 | The review decision for this deployment was `{{ review_decision }}`.
31 |
32 | The deployment had the following parameters provided in the deploy command: `{{ params }}`
33 |
34 | The deployment had the following "parsed" parameters provided in the deploy command: `{{ parsed_params | safe }}`
35 |
36 | The deployment process ended at `{{ deployment_end_time }}` and it took `{{ total_seconds }}` seconds to complete.
37 |
38 | Here are the deployment logs: {{ logs }}
39 |
40 | {% if commit_verified %}The commit was verified.{% else %}The commit was not verified.{% endif %}
41 |
42 | {% if environment_url %}You can view the deployment [here]({{ environment_url }}).{% endif %}
43 |
44 | {% if noop %}This was a noop deployment.{% endif %}
45 |
46 | > This deployment had `{{ approved_reviews_count }}` approvals.
47 |
--------------------------------------------------------------------------------
/src/functions/is-timestamp-older.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // A helper method that checks if timestamp A is older than timestamp B
4 | // :param timestampA: The first timestamp to compare (String - format: "2024-10-21T19:10:24Z")
5 | // :param timestampB: The second timestamp to compare (String - format: "2024-10-21T19:10:24Z")
6 | // :returns: true if timestampA is older than timestampB, false otherwise
7 | export function isTimestampOlder(timestampA, timestampB) {
8 | // Defensive: handle null/undefined/empty
9 | if (!timestampA || !timestampB) {
10 | throw new Error('One or both timestamps are missing or empty.')
11 | }
12 |
13 | // Strict ISO 8601 UTC format: YYYY-MM-DDTHH:MM:SSZ
14 | const ISO_UTC_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/
15 | if (
16 | typeof timestampA !== 'string' ||
17 | typeof timestampB !== 'string' ||
18 | !ISO_UTC_REGEX.test(timestampA) ||
19 | !ISO_UTC_REGEX.test(timestampB)
20 | ) {
21 | throw new Error(
22 | `Timestamps must be strings in the format YYYY-MM-DDTHH:MM:SSZ. Received: '${timestampA}', '${timestampB}'`
23 | )
24 | }
25 |
26 | // Parse the date strings into Date objects
27 | const timestampADate = new Date(timestampA)
28 | const timestampBDate = new Date(timestampB)
29 |
30 | // Extra strict: ensure the parsed date matches the input string exactly (prevents JS date rollover)
31 | const toStrictISOString = d => {
32 | // Returns YYYY-MM-DDTHH:MM:SSZ
33 | return (
34 | d.getUTCFullYear().toString().padStart(4, '0') +
35 | '-' +
36 | (d.getUTCMonth() + 1).toString().padStart(2, '0') +
37 | '-' +
38 | d.getUTCDate().toString().padStart(2, '0') +
39 | 'T' +
40 | d.getUTCHours().toString().padStart(2, '0') +
41 | ':' +
42 | d.getUTCMinutes().toString().padStart(2, '0') +
43 | ':' +
44 | d.getUTCSeconds().toString().padStart(2, '0') +
45 | 'Z'
46 | )
47 | }
48 | if (
49 | isNaN(timestampADate) ||
50 | isNaN(timestampBDate) ||
51 | toStrictISOString(timestampADate) !== timestampA ||
52 | toStrictISOString(timestampBDate) !== timestampB
53 | ) {
54 | core.error(
55 | `Invalid date parsing. Received: '${timestampA}' => ${timestampADate}, '${timestampB}' => ${timestampBDate}`
56 | )
57 | throw new Error(
58 | `Invalid date format. Please ensure the dates are valid UTC timestamps. Received: '${timestampA}', '${timestampB}'`
59 | )
60 | }
61 |
62 | const result = timestampADate < timestampBDate
63 |
64 | if (result) {
65 | core.debug(`${timestampA} is older than ${timestampB}`)
66 | } else {
67 | core.debug(`${timestampA} is not older than ${timestampB}`)
68 | }
69 |
70 | return result
71 | }
72 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing 💻
2 |
3 | All contributions are welcome and greatly appreciated!
4 |
5 | ## Steps to Contribute 💡
6 |
7 | > Check the `.node-version` file in the root of this repo so see what version of Node.js is required for local development - note, this can be different from the version of Node.js which runs the Action on GitHub runners. It is suggested to download [nodenv](https://github.com/nodenv/nodenv) which uses this file and manages your Node.js versions for you
8 |
9 | 1. Fork this repository
10 | 2. Commit your changes
11 | 3. Test your changes (learn how to test below)
12 | 4. Open a pull request back to this repository
13 | > Make sure to run `npm run all` as your final commit!
14 | 5. Notify the maintainers of this repository for peer review and approval
15 | 6. Merge!
16 |
17 | The maintainers of this repository will create a new release with your changes so that everyone can use the new release and enjoy the awesome features of branch deployments.
18 |
19 | > For maintainers, see the [Maintainer Guide](./docs/maintainer-guide.md) for more information on how to create a new release.
20 |
21 | ## Testing 🧪
22 |
23 | This project requires **100%** test coverage
24 |
25 | > The branch-deploy Action is used by enterprises, governments, and open source organizations - it is critical that we have 100% test coverage to ensure that we are not introducing any regressions. All changes will be throughly tested by maintainers of this repository before a new release is created.
26 |
27 | ### Running the test suite (required)
28 |
29 | Simply run the following command to execute the entire test suite:
30 |
31 | ```bash
32 | npm run test
33 | ```
34 |
35 | > Note: this requires that you have already run `npm install`
36 |
37 | ### Testing directly with IssueOps
38 |
39 | > See the testing FAQs below for more information on this process
40 |
41 | You can test your changes by doing the following steps:
42 |
43 | 1. Commit your changes to the `main` branch on your fork
44 | 2. Open a new pull request
45 | 3. Run IssueOps commands on the pull request you just opened (`.deploy`, `.noop`, `.deploy main`, etc)
46 | 4. Ensure that all IssueOps commands work as expected on your testing PR
47 |
48 | ### Testing FAQs 🤔
49 |
50 | Answers to questions you might have around testing
51 |
52 | Q: Why do I have to commit my changes to `main`?
53 |
54 | A: The `on: issue_comment` workflow only uses workflow files from the `main` branch by design - [learn more](https://github.com/github/branch-deploy#security-)
55 |
56 | Q: How can I test my changes once my PR is merged and *before* a new release is created?
57 |
58 | A: You should create a repo like [this one](https://github.com/GrantBirki/actions-sandbox) that uses `github/branch-deploy@main` as the Action version and test your changes there
59 |
--------------------------------------------------------------------------------
/src/functions/label.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {API_HEADERS} from './api-headers.js'
3 |
4 | // Helper function to add labels to a pull request
5 | // :param context: The GitHub Actions event context
6 | // :param octokit: The octokit client
7 | // :param labelsToAdd: An array of labels to add to the pull request (Array)
8 | // :parm labelsToRemove: An array of labels to remove from the pull request (Array)
9 | // :returns: An object containing the labels added and removed (Object)
10 | export async function label(context, octokit, labelsToAdd, labelsToRemove) {
11 | // Get the owner, repo, and issue number from the context
12 | const {owner, repo} = context.repo
13 | const issueNumber = context.issue.number
14 | var addedLabels = [] // an array of labels that were actually added
15 | var removedLabels = [] // an array of labels that were actually removed
16 |
17 | // exit early if there are no labels to add or remove
18 | if (labelsToAdd.length === 0 && labelsToRemove.length === 0) {
19 | core.debug('🏷️ no labels to add or remove')
20 | return {
21 | added: [],
22 | removed: []
23 | }
24 | }
25 |
26 | // first, find and cleanup labelsToRemove if any are provided
27 | if (labelsToRemove.length > 0) {
28 | // Fetch current labels on the issue
29 | core.debug('fetching current labels on the issue')
30 | const currentLabelsResult = await octokit.rest.issues.listLabelsOnIssue({
31 | owner: owner,
32 | repo: repo,
33 | issue_number: issueNumber,
34 | headers: API_HEADERS
35 | })
36 | const currentLabels = currentLabelsResult.data.map(label => label.name)
37 |
38 | core.debug(`current labels: ${currentLabels}`)
39 | core.debug(`labels to remove: ${labelsToRemove}`)
40 |
41 | // Remove unwanted labels
42 | for (const label of labelsToRemove) {
43 | if (currentLabels.includes(label)) {
44 | await octokit.rest.issues.removeLabel({
45 | owner: owner,
46 | repo: repo,
47 | issue_number: issueNumber,
48 | name: label,
49 | headers: API_HEADERS
50 | })
51 | core.info(`🏷️ label removed: ${label}`)
52 | removedLabels.push(label)
53 | } else {
54 | core.debug(`🏷️ label not found: '${label}' so it was not removed`)
55 | }
56 | }
57 | }
58 |
59 | // now, add the labels if any are provided
60 | if (labelsToAdd.length > 0) {
61 | core.debug(`attempting to apply labels: ${labelsToAdd}`)
62 | await octokit.rest.issues.addLabels({
63 | owner: owner,
64 | repo: repo,
65 | issue_number: issueNumber,
66 | labels: labelsToAdd,
67 | headers: API_HEADERS
68 | })
69 | core.info(`🏷️ labels added: ${labelsToAdd}`)
70 |
71 | addedLabels = labelsToAdd
72 | }
73 |
74 | return {
75 | added: addedLabels,
76 | removed: removedLabels
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/docs/deployment-payload.md:
--------------------------------------------------------------------------------
1 | # Deployment Payloads
2 |
3 | The deployment payload is a JSON data structure that gets uploaded to GitHub when a new deployment is created. The values in this payload can be almost anything you want. The branch-deploy GitHub Action hydrates the deployment payload with some useful information that can be accessed later on in the deployment process if you need it.
4 |
5 | Here is the data that the branch-deploy Action will add to the deployment payload:
6 |
7 | ```json
8 | {
9 | "type": "branch-deploy",
10 | "sha": "",
11 | "params": "",
12 | "parsed_params": {},
13 | "github_run_id": 123,
14 | "initial_comment_id": 123,
15 | "initial_reaction_id": 123,
16 | "deployment_started_comment_id": 123456,
17 | "timestamp": "2025-01-01T00:00:00.000Z",
18 | "commit_verified": true,
19 | "actor": "",
20 | "stable_branch_used": false
21 | }
22 | ```
23 |
24 | - `type` - This is the type of deployment that is being created. This will always be `branch-deploy` for the branch-deploy Action.
25 | - `sha` - This is the commit SHA that is being deployed.
26 | - `params` - This is the raw string of parameters that were passed to the branch-deploy Action. You can read more about parameters [here](./parameters.md).
27 | - `parsed_params` - This is the parsed version of the `params` string. This is a JSON object that is created by parsing the `params` string. You can read more about parameters [here](./parameters.md).
28 | - `github_run_id` - This is the ID of the GitHub Action run that created the deployment. This can be useful if you need to access the logs of the deployment.
29 | - `initial_comment_id` - This is the ID of the initial (trigger) comment that kicked off the branch-deploy Action. Example: `.deploy` would be the comment that triggered the deployment and this would be the ID of that comment.
30 | - `initial_reaction_id` - This is the ID of the initial reaction that was left on the trigger comment by the branch-deploy Action. This is usually a pair of eyes (👀) to indicate that the branch-deploy Action has detected the trigger comment and it is running logic.
31 | - `deployment_started_comment_id` - This is the ID of the comment that the branch-deploy Action leaves below the trigger comment. It usually contains information about the deployment that is about to take place. Example: `Deployment Triggered 🚀... GrantBirki, started a branch deployment to production`
32 | - `timestamp` - This is the timestamp of when the deployment was created from the perspective of the branch-deploy Action.
33 | - `commit_verified` - This is a boolean that indicates whether the commit that is being deployed is verified.
34 | - `actor` - This is the username of the user that triggered the deployment.
35 | - `stable_branch_used` - This is a boolean that indicates whether the stable branch was used for the deployment. This will be `true` if the stable branch was used and `false` if the stable branch was not used.
36 |
--------------------------------------------------------------------------------
/__tests__/functions/label.test.js:
--------------------------------------------------------------------------------
1 | import {label} from '../../src/functions/label.js'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 |
4 | var context
5 | var octokit
6 | beforeEach(() => {
7 | vi.clearAllMocks()
8 |
9 | context = {
10 | repo: {
11 | owner: 'corp',
12 | repo: 'test'
13 | },
14 | issue: {
15 | number: 1
16 | }
17 | }
18 |
19 | octokit = {
20 | rest: {
21 | issues: {
22 | addLabels: vi.fn().mockReturnValueOnce({
23 | data: {}
24 | }),
25 | removeLabel: vi.fn().mockReturnValueOnce({
26 | data: {}
27 | }),
28 | listLabelsOnIssue: vi.fn().mockReturnValueOnce({
29 | data: [
30 | {
31 | name: 'deploy-failed'
32 | },
33 | {
34 | name: 'noop'
35 | }
36 | ]
37 | })
38 | }
39 | }
40 | }
41 | })
42 |
43 | test('adds a single label to a pull request and removes none', async () => {
44 | expect(await label(context, octokit, ['read-for-review'], [])).toStrictEqual({
45 | added: ['read-for-review'],
46 | removed: []
47 | })
48 | })
49 |
50 | test('adds two labels to a pull request and removes none', async () => {
51 | expect(
52 | await label(context, octokit, ['read-for-review', 'cool-label'], [])
53 | ).toStrictEqual({
54 | added: ['read-for-review', 'cool-label'],
55 | removed: []
56 | })
57 | })
58 |
59 | test('adds a single label to a pull request and tries to remove a label but it is not on the PR to begin with', async () => {
60 | expect(
61 | await label(context, octokit, ['read-for-review'], ['unknown-label'])
62 | ).toStrictEqual({
63 | added: ['read-for-review'],
64 | removed: []
65 | })
66 | })
67 |
68 | test('does not add or remove any labels', async () => {
69 | expect(await label(context, octokit, [], [])).toStrictEqual({
70 | added: [],
71 | removed: []
72 | })
73 | })
74 |
75 | test('adds a single label to a pull request and removes a single label', async () => {
76 | expect(
77 | await label(context, octokit, ['deploy-success'], ['deploy-failed'])
78 | ).toStrictEqual({
79 | added: ['deploy-success'],
80 | removed: ['deploy-failed']
81 | })
82 | })
83 |
84 | test('adds two labels to a pull request and removes two labels', async () => {
85 | expect(
86 | await label(
87 | context,
88 | octokit,
89 | ['deploy-success', 'read-for-review'],
90 | ['deploy-failed', 'noop']
91 | )
92 | ).toStrictEqual({
93 | added: ['deploy-success', 'read-for-review'],
94 | removed: ['deploy-failed', 'noop']
95 | })
96 | })
97 |
98 | test('does not add any labels and removes a single label', async () => {
99 | expect(await label(context, octokit, [], ['noop'])).toStrictEqual({
100 | added: [],
101 | removed: ['noop']
102 | })
103 | })
104 |
--------------------------------------------------------------------------------
/docs/hubot-style-deployment-locks.md:
--------------------------------------------------------------------------------
1 | # Hubot Style Deployment Locks 🔒
2 |
3 | > Wait, what is [hubot](https://hubot.github.com/)? - Hubot is GitHub's chatop friend. It has `.deploy` functionality that is extremely similar to this Action.
4 |
5 | By default, if you run a `.deploy` command, it creates a [non-sticky lock](./locks.md#deployment-locks) that is released as soon as the deployment finishes. This is fine for smaller projects, but if you have dozens or even hundreds of PRs open at the same time (from different people) there is a good chance when your deploy finishes, someone else will run `.deploy` and wipe out your changes.
6 |
7 | By using _Hubot Style Deployment Locks_, AKA _Sticky Deployment Locks_, you can ensure that your deployment will not be wiped out by another deployment since the deployment itself will claim the lock.
8 |
9 | You read that last bit correctly 😉, the largest difference you will notice when using this setting is that all deployments (`.noop` and `.deploy`) will claim persistent (sticky) locks when they are invoked. This is helpful as you have to run one less command if your usual workflow is `.lock` then `.deploy`.
10 |
11 | This behavior is not enabled out of the box and you need to enable it in your Actions configuration with `sticky_locks: "true"`. The reasoning for this is that you should also configure the ["unlock on merge" mode workflow](./unlock-on-merge.md) to take full advantage of automatically releasing locks on PR merge. This extra workflow is really quite beneficial as you no longer need to worry about cleaning up locks after a PR is merged. As soon as you merge your PR where you were running deployments from, that PR is considered "done" and the lock is released.
12 |
13 | You can still release locks manually with `.unlock` at any time and so can other users. This is helpful if you need to release a lock that is blocking a deployment from another PR or if you were just testing changes and want to release the lock.
14 |
15 | It should be noted that if you want this logic to **also apply to noop deployments** you need to enable another input option called `sticky_locks_for_noop` and also set its value to `"true"`. By default, noop deployments will not claim sticky locks as this often just leads to locks being left behind and never cleaned up.
16 |
17 | ## Examples
18 |
19 | Enabling sticky deployment locks for `.deploy` commands:
20 |
21 | ```yaml
22 | - name: branch-deploy
23 | id: branch-deploy
24 | uses: github/branch-deploy@vX.X.X
25 | with:
26 | sticky_locks: "true" # <--- enables sticky deployment lock / hubot style deployment locks
27 | # ... other configuration
28 | ```
29 |
30 | Enabling sticky deployment locks for `.deploy` and `.noop` commands:
31 |
32 | ```yaml
33 | - name: branch-deploy
34 | id: branch-deploy
35 | uses: github/branch-deploy@vX.X.X
36 | with:
37 | sticky_locks: "true" # <--- enables sticky deployment lock / hubot style deployment locks
38 | sticky_locks_for_noop: "true" # <--- enables sticky deployment lock / hubot style deployment locks for noop deployments
39 | # ... other configuration
40 | ```
41 |
--------------------------------------------------------------------------------
/__tests__/functions/trigger-check.test.js:
--------------------------------------------------------------------------------
1 | import {triggerCheck} from '../../src/functions/trigger-check.js'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import * as core from '@actions/core'
4 | import {COLORS} from '../../src/functions/colors.js'
5 |
6 | const color = COLORS.highlight
7 | const colorReset = COLORS.reset
8 | const infoMock = vi.spyOn(core, 'info')
9 | const debugMock = vi.spyOn(core, 'debug')
10 |
11 | beforeEach(() => {
12 | vi.clearAllMocks()
13 | })
14 |
15 | test('checks a message and finds a standard trigger', async () => {
16 | const body = '.deploy'
17 | const trigger = '.deploy'
18 | expect(await triggerCheck(body, trigger)).toBe(true)
19 | expect(infoMock).toHaveBeenCalledWith(
20 | `✅ comment body starts with trigger: ${color}.deploy${colorReset}`
21 | )
22 | })
23 |
24 | test('checks a message and does not find trigger', async () => {
25 | const body = '.bad'
26 | const trigger = '.deploy'
27 | expect(await triggerCheck(body, trigger)).toBe(false)
28 | expect(debugMock).toHaveBeenCalledWith(
29 | `comment body does not start with trigger: ${color}.deploy${colorReset}`
30 | )
31 | })
32 |
33 | test('checks a message and finds a global trigger', async () => {
34 | const body = 'I want to .deploy'
35 | const trigger = '.deploy'
36 | expect(await triggerCheck(body, trigger)).toBe(false)
37 | expect(debugMock).toHaveBeenCalledWith(
38 | `comment body does not start with trigger: ${color}.deploy${colorReset}`
39 | )
40 | })
41 |
42 | test('checks a message and finds a trigger with an environment and a variable', async () => {
43 | const trigger = '.deploy'
44 | expect(await triggerCheck('.deploy dev something', trigger)).toBe(true)
45 | expect(await triggerCheck('.deploy something', trigger)).toBe(true)
46 | expect(await triggerCheck('.deploy dev something', trigger)).toBe(true)
47 | expect(await triggerCheck('.deploy dev something', trigger)).toBe(true)
48 | })
49 |
50 | test('checks a message and does not find global trigger', async () => {
51 | const body = 'I want to .ping a website'
52 | const trigger = '.deploy'
53 | expect(await triggerCheck(body, trigger)).toBe(false)
54 | expect(debugMock).toHaveBeenCalledWith(
55 | `comment body does not start with trigger: ${color}.deploy${colorReset}`
56 | )
57 | })
58 |
59 | test('does not match when body starts with a longer command sharing prefix', async () => {
60 | const body = '.deploy-two to prod'
61 | const trigger = '.deploy'
62 | expect(await triggerCheck(body, trigger)).toBe(false)
63 | expect(debugMock).toHaveBeenCalledWith(
64 | `comment body starts with trigger but is not complete: ${color}.deploy${colorReset}`
65 | )
66 | })
67 |
68 | test('does not match when immediately followed by alphanumeric', async () => {
69 | const body = '.deploy1'
70 | const trigger = '.deploy'
71 | expect(await triggerCheck(body, trigger)).toBe(false)
72 | expect(debugMock).toHaveBeenCalledWith(
73 | `comment body starts with trigger but is not complete: ${color}.deploy${colorReset}`
74 | )
75 | })
76 |
77 | test('matches when followed by a newline (whitespace)', async () => {
78 | const body = `.deploy\ndev`
79 | const trigger = '.deploy'
80 | expect(await triggerCheck(body, trigger)).toBe(true)
81 | })
82 |
--------------------------------------------------------------------------------
/__tests__/functions/params.test.js:
--------------------------------------------------------------------------------
1 | import {vi, expect, test, beforeEach} from 'vitest'
2 | import {parseParams} from '../../src/functions/params.js'
3 |
4 | beforeEach(() => {
5 | vi.clearAllMocks()
6 | })
7 |
8 | test('with empty param object', async () => {
9 | expect(parseParams('')).toStrictEqual({_: []})
10 | expect(parseParams(null)).toStrictEqual({_: []})
11 | expect(parseParams(undefined)).toStrictEqual({_: []})
12 | })
13 |
14 | test('it parses positional parameters', async () => {
15 | expect(parseParams('foo bar baz')).toHaveProperty('_', ['foo', 'bar', 'baz'])
16 | })
17 |
18 | test('it parses arguments using the default settings of library', async () => {
19 | const parsed = parseParams('--foo bar --env.foo=bar baz')
20 | expect(parsed).toHaveProperty('foo', 'bar')
21 | expect(parsed).toHaveProperty('env', {foo: 'bar'})
22 | expect(parsed).toHaveProperty('_', ['baz'])
23 | })
24 |
25 | test('it works with empty string', async () => {
26 | expect(parseParams('')).toHaveProperty('_', [])
27 | })
28 |
29 | test('it parses multiple positional parameters', async () => {
30 | expect(parseParams('foo bar baz')).toHaveProperty('_', ['foo', 'bar', 'baz'])
31 | })
32 |
33 | test('it parses flags correctly', async () => {
34 | const parsed = parseParams('--foo --bar')
35 | expect(parsed).toHaveProperty('foo', true)
36 | expect(parsed).toHaveProperty('bar', true)
37 | expect(parsed).toHaveProperty('_', [])
38 | })
39 |
40 | test('it parses numeric values correctly', async () => {
41 | const parsed = parseParams('--count 42')
42 | expect(parsed).toHaveProperty('count', 42)
43 | expect(parsed).toHaveProperty('_', [])
44 | })
45 |
46 | test('it parses plain values', async () => {
47 | const parsed = parseParams('count 42')
48 | expect(parsed).toHaveProperty('_', ['count', 42])
49 | })
50 |
51 | test('it parses string values with comma separation', async () => {
52 | const parsed = parseParams('LOG_LEVEL=debug,CPU_CORES=4')
53 | expect(parsed).toHaveProperty('_', ['LOG_LEVEL=debug,CPU_CORES=4'])
54 | })
55 |
56 | test('it parses boolean values correctly', async () => {
57 | const parsed = parseParams('--enabled=true --disabled false')
58 | expect(parsed).toHaveProperty('enabled', 'true')
59 | expect(parsed).toHaveProperty('disabled', 'false')
60 | expect(parsed).toHaveProperty('_', [])
61 | })
62 |
63 | test('it parses nested objects correctly', async () => {
64 | const parsed = parseParams(
65 | 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432'
66 | )
67 | expect(parsed).toHaveProperty('config', {db: {host: 'localhost', port: 5432}})
68 | expect(parsed).toHaveProperty('_', ['LOG_LEVEL=debug'])
69 | expect(parsed).toStrictEqual({
70 | config: {db: {host: 'localhost', port: 5432}},
71 | _: ['LOG_LEVEL=debug']
72 | })
73 | })
74 |
75 | test('it parses a real world example correctly', async () => {
76 | const parsed = parseParams(
77 | '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue'
78 | )
79 | expect(parsed).toHaveProperty('cpu', 2)
80 | expect(parsed).toHaveProperty('memory', '4G')
81 | expect(parsed).toHaveProperty('env', 'development')
82 | expect(parsed).toHaveProperty('port', 8080)
83 | expect(parsed).toHaveProperty('name', 'my-app')
84 | expect(parsed).toHaveProperty('q', 'my-queue')
85 | expect(parsed).toHaveProperty('_', [])
86 | })
87 |
--------------------------------------------------------------------------------
/docs/parameters.md:
--------------------------------------------------------------------------------
1 | # Parameters
2 |
3 | Given the highly customizable nature of the `branch-deploy` Action, users may often find that they need to pass in a number of parameters into subsequent steps during their deployments. This Action provides a way to pass in parameters to the `.deploy` command without any required structure or format.
4 |
5 | ## Example
6 |
7 | Here are a few examples of how to pass in parameters to the `.deploy` command and why they might be used.
8 |
9 | ### Example 1
10 |
11 | **Command**:
12 |
13 | ```text
14 | .deploy to development | LOG_LEVEL=debug,CPU_CORES=4
15 | ```
16 |
17 | **Outputs**:
18 |
19 | - `params` = `LOG_LEVEL=debug,CPU_CORES=4`
20 | - `parsed_params` = `["LOG_LEVEL=debug,CPU_CORES=4"]`
21 |
22 | **Why**: A user might need to deploy to the development environment and tell subsequent workflow steps to use a `LOG_LEVEL` of `debug` and `CPU_CORES` of `4`.
23 |
24 | ### Example 2
25 |
26 | **Command**:
27 |
28 | ```text
29 | .deploy | something1 something2 something3
30 | ```
31 |
32 | **Outputs**:
33 |
34 | - `params` = `something1 something2 something3`
35 | - `parsed_params` = `["something1", "something2", "something3"]`
36 |
37 | **Why**: This example shows that the `params` output is just a string that can be literally anything your heart desires. It is up to the user to parse the string and use it in subsequent steps.
38 |
39 | ### Example 3
40 |
41 | **Command**:
42 |
43 | ```text
44 | .deploy | --cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue
45 | ```
46 |
47 | **Outputs**:
48 |
49 | - `params` = `--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue`
50 | - `parsed_params` = `{_: [], cpu: 2, memory: '4G', env: 'development', port: 8080, name: 'my-app', q: 'my-queue'}`
51 |
52 | **Why**: This example shows that by using structure within your params string like `--key=value`, they can be automatically parsed into a JSON object and saved as the `parsed_params` output. This can be useful for users that want to pass in a number of parameters to their deployment and have them automatically parsed and saved as a JSON object as an output of this Action. Having machine readable output can be quite useful for subsequent workflow steps.
53 |
54 | ## Parameter Separator
55 |
56 | The `param_separator` input defaults to `|` and will collect any text that is provided after this character and save it as a GitHub Actions output called `params`. This output can then be used in subsequent steps.
57 |
58 | This value can be configured to be any character (or string) that you want.
59 |
60 | ## Parameter Output
61 |
62 | The `params` and `parsed_params` outputs can be accessed just like any other output from the `branch-deploy` Action. Here is a quick example:
63 |
64 | ```yaml
65 | - name: branch-deploy
66 | id: branch-deploy
67 | uses: github/branch-deploy@vX.X.X
68 | with:
69 | trigger: .deploy
70 | param_separator: "|"
71 |
72 | - name: example
73 | if: steps.branch-deploy.outputs.continue == 'true'
74 | run: |
75 | echo "params: ${{ steps.branch-deploy.outputs.params }}"
76 | echo "parsed_params: ${{ steps.branch-deploy.outputs.parsed_params }}"
77 | ```
78 |
79 | ## Parameters and Deployment Payloads
80 |
81 | All parameters (both parsed and raw) can be found in the `payload` of the actual deployment that gets created and stored on GitHub. You can read more about the deployment payload and its structure [here](./deployment-payload.md).
82 |
--------------------------------------------------------------------------------
/src/functions/post.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {retry} from '@octokit/plugin-retry'
3 | import * as github from '@actions/github'
4 | import {context} from '@actions/github'
5 |
6 | import {stringToArray} from './string-to-array.js'
7 | import {contextCheck} from './context-check.js'
8 | import {checkInput} from './check-input.js'
9 | import {postDeploy} from './post-deploy.js'
10 | import {COLORS} from './colors.js'
11 | import {VERSION} from '../version.js'
12 |
13 | export async function post() {
14 | try {
15 | const token = core.getState('actionsToken')
16 | const bypass = core.getState('bypass') === 'true'
17 | const skip_completing = core.getBooleanInput('skip_completing')
18 |
19 | const data = {
20 | sha: core.getState('sha'),
21 | ref: core.getState('ref'),
22 | comment_id: core.getState('comment_id'),
23 | reaction_id: core.getState('reaction_id'),
24 | noop: core.getState('noop') === 'true',
25 | deployment_id: core.getState('deployment_id'),
26 | environment: core.getState('environment'),
27 | environment_url: checkInput(core.getState('environment_url')),
28 | approved_reviews_count: core.getState('approved_reviews_count'),
29 | review_decision: core.getState('review_decision'),
30 | status: core.getInput('status'),
31 | fork: core.getState('fork') === 'true',
32 | params: core.getState('params'),
33 | parsed_params: core.getState('parsed_params'),
34 | labels: {
35 | successful_deploy: stringToArray(
36 | core.getInput('successful_deploy_labels')
37 | ),
38 | successful_noop: stringToArray(core.getInput('successful_noop_labels')),
39 | failed_deploy: stringToArray(core.getInput('failed_deploy_labels')),
40 | failed_noop: stringToArray(core.getInput('failed_noop_labels')),
41 | skip_successful_noop_labels_if_approved: core.getBooleanInput(
42 | 'skip_successful_noop_labels_if_approved'
43 | ),
44 | skip_successful_deploy_labels_if_approved: core.getBooleanInput(
45 | 'skip_successful_deploy_labels_if_approved'
46 | )
47 | },
48 | commit_verified: core.getState('commit_verified') === 'true',
49 | deployment_start_time: core.getState('deployment_start_time')
50 | }
51 |
52 | // If bypass is set, exit the workflow
53 | if (bypass) {
54 | core.warning(`⛔ ${COLORS.highlight}bypass${COLORS.reset} set, exiting`)
55 | return
56 | }
57 |
58 | // Check the context of the event to ensure it is valid, return if it is not
59 | if (!(await contextCheck(context))) {
60 | return
61 | }
62 |
63 | // Skip the process of completing a deployment, return
64 | if (skip_completing) {
65 | core.info(
66 | `⏩ ${COLORS.highlight}skip_completing${COLORS.reset} set, exiting`
67 | )
68 | return
69 | }
70 |
71 | // Create an octokit client with the retry plugin
72 | const octokit = github.getOctokit(token, {
73 | userAgent: `github/branch-deploy@${VERSION}`,
74 | additionalPlugins: [retry]
75 | })
76 |
77 | core.info(`🧑🚀 commit SHA: ${COLORS.highlight}${data.sha}${COLORS.reset}`)
78 |
79 | // Set the environment_url
80 | if (data.environment_url === null) {
81 | core.debug('environment_url not set, its value is null')
82 | }
83 |
84 | await postDeploy(context, octokit, data)
85 |
86 | return
87 | } catch (error) {
88 | core.error(error.stack)
89 | core.setFailed(error.message)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/docs/deployment-confirmation.md:
--------------------------------------------------------------------------------
1 | # Deployment Confirmation
2 |
3 | ## Overview
4 |
5 | For projects that require the highest level of deployment safety/security, the branch-deploy Action can be configured to require a deployment **confirmation** before a deployment is allowed to proceed.
6 |
7 | This can be considered a "final safety check" before a deployment can continue.
8 |
9 | By using this feature, it is also an extremely effective way to prevent accidental or malicious commits from being deployed without first having one last safety review. This is important for hardening against Actions related [TOCTOU](https://github.com/AdnaneKhan/ActionsTOCTOU) vulnerabilities. For example, since noop deployments do not require PR approvals (except on forks), a malicious actor could push a commit to a PR just after the `.noop` command is invoked and hope their code is executed. By requiring a deployment confirmation, this attack vector is effectively mitigated as the deployer would have the opportunity to reject the deployment when they notice the unexpected commit. This same attack vector is also mitigated by using the [branch rulesets](./branch-rulesets.md) feature (for regular `.deploy` operations), which is also a good security practice to use in conjunction with deployment confirmation.
10 |
11 | ## How it works
12 |
13 | When a user invokes a deployment via the `.deploy` (or `.noop`) command, the branch-deploy Action will pause _just_ before the final call to start a deployment by this Action. The Action will then create a new comment on the pull request that invoked the deployment, asking the user to confirm (or reject) the deployment.
14 |
15 | This comment will provide the user with a summary of the deployment that is **about** to be run. The user will then have the opportunity to confirm (with a 👍) or deny (with a 👎) the deployment.
16 |
17 | Depending on the user's response (or lack of response), the branch-deploy Action will update the comment with the outcome.
18 |
19 | The only reaction (👍 or 👎) that will be considered is the first reaction from the original actor that invoked the deployment (via `.deploy`). For example, if `@monalisa` comments `.deploy`, only `@monalisa` can give deployment confirmation via a reaction. All other reactions will be ignored on the deployment confirmation comment.
20 |
21 | ### Usage
22 |
23 | ```yaml
24 | - uses: github/branch-deploy@vX.X.X
25 | id: branch-deploy
26 | with:
27 | trigger: ".deploy"
28 | deployment_confirmation: true # <-- enables deployment confirmation
29 | deployment_confirmation_timeout: 60 # <-- sets the timeout to 60 seconds
30 | ```
31 |
32 | ### Confirming a Deployment 👍
33 |
34 | If the user confirms the deployment, the deployment will proceed as normal:
35 |
36 | 
37 |
38 | ### Rejecting a Deployment 👎
39 |
40 | If the user rejects the deployment, the branch-deploy Action will immediately exit and the `continue` output will not be set to `true`. This will prevent the deployment from proceeding.
41 |
42 | 
43 |
44 | ### Timing Out 🕒
45 |
46 | If the user does not respond within a set amount of time (configurable, but defaults to 1 minute), the deployment will be automatically rejected. The user will be notified of this outcome.
47 |
48 | 
49 |
50 | ## Demo 📹
51 |
52 | View a [demo video](https://github.com/github/branch-deploy/pull/374) of a deployment confirmation in action on the original pull request that introduced this feature.
53 |
--------------------------------------------------------------------------------
/docs/branch-rulesets.md:
--------------------------------------------------------------------------------
1 | # Branch Rulesets
2 |
3 | A ruleset is a named list of rules that applies to a repository. You can have up to 75 rulesets per repository. In this project specifically, we care about the rulesets that are applied to the default (stable) branch of a repository (most likely `main` or `master`).
4 |
5 | You should absolutely enable rulesets on your default branch when using this Action. It can help protect your default branch from accidental or even malicious changes.
6 |
7 | This project will actually warn you in the logs if you are missing or have misconfigured certain rulesets. The "warnings" section of this document will help you understand how to fix these warnings and enable robust rulesets to protect your repository.
8 |
9 | It should be noted that if you have a good reason to *not* use any of these rulesets, and you want to disable to loud warnings in the logs, you can do so by setting the `use_security_warnings` input option to `false`. This will disable all warnings in the logs.
10 |
11 | Example:
12 |
13 | ```yaml
14 | - uses: github/branch-deploy@vX.X.X
15 | id: branch-deploy
16 | with:
17 | use_security_warnings: false # <-- this will disable all warnings in the logs related to branch rulesets
18 | ```
19 |
20 | ## Warnings
21 |
22 | ### `missing_non_fast_forward`
23 |
24 | Solution: Enable the **Block force pushes** rule
25 |
26 | 
27 |
28 | ### `missing_deletion`
29 |
30 | Solution: Enable the **Restrict deletions** rule
31 |
32 | 
33 |
34 | ### `mismatch_required_status_checks_strict_required_status_checks_policy`
35 |
36 | Solution: Enable the **Require branches to be up to date before merging** rule
37 |
38 | 
39 |
40 | ### `missing_pull_request`
41 |
42 | Solution: Enable the **Require a pull request before merging** rule
43 |
44 | 
45 |
46 | ### `mismatch_pull_request_dismiss_stale_reviews_on_push`
47 |
48 | Solution: Enable the **Dismiss stale pull request approvals when new commits are pushed** rule
49 |
50 | 
51 |
52 | ### `mismatch_pull_request_require_code_owner_review`
53 |
54 | Solution: Enable the **Require review from Code Owners** rule
55 |
56 | 
57 |
58 | ### `mismatch_pull_request_required_approving_review_count`
59 |
60 | Solution: Ensure that the **Required approvals** setting is not `0`
61 |
62 | 
63 |
64 | ### `missing_required_deployments`
65 |
66 | Solution: Enable the **Require deployments to succeed** rule
67 |
68 | 
69 |
70 | ## Extra Documentation
71 |
72 | - [Learn about rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets)
73 | - [Learn about repo rules in the API](https://docs.github.com/en/rest/repos/rules?apiVersion=2022-11-28)
74 | - [Learn about branch protection rules in the API](https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2022-11-28)
75 |
--------------------------------------------------------------------------------
/docs/sha-deployments.md:
--------------------------------------------------------------------------------
1 | # Unsafe SHA Deployments
2 |
3 | The following pull request [#212](https://github.com/github/branch-deploy/pull/212) enables users to use the branch-deploy Action to deploy an **exact** SHA1 or SHA256 hash instead of a branch. While this feature can be incredibly useful or even [necessary for some projects](https://github.com/github/branch-deploy/issues/211#issue-1924462155), it can be dangerous and unsafe.
4 |
5 | By enabling the `allow_sha_deployments` input option, you can enable SHA deployments for your project, but you should be aware of the risks.
6 |
7 | SHA deployments allow you to deploy an **exact** SHA instead of the branch associated with your pull request. This means that you can deploy a commit that is not associated with a pull request, or even a commit that is associated with another user's pull request.
8 |
9 | Here is an example:
10 |
11 | ```text
12 | .deploy b69ba12504020c9067abe680c1dc28191d4c9be3 to production
13 | ```
14 |
15 | ## Why is this option dangerous / unsafe?
16 |
17 | Before we start, let's first take a look at how SHA1/256 hashes are used in Git. Branches, tags, and commits, all point to a "SHA" under the hood. So think of a branch / tag as a friendly name to point to a SHA at a certain point in time.
18 |
19 | When using the branch-deploy Action in its default configuration, a user is typically either deploying a **branch** associated with a pull request, or they are deploying `main` (the main or default branch) in a rollback should something go wrong.
20 |
21 | Branches can be associated with CI checks, and approvals. The `main` (or default) branch can be associated with branch protection rules and we can often assume that changes only land on `main` once they have gone through the proper pull request process.
22 |
23 | Now pulling together all the statements above... what does this tell us about deploying a given SHA? It _can be_ incredibly risky. A user could comment `.deploy ` on their pull request and deploy a commit attached to _another user's_ pull request that has not been approved or tested. This could potentially allow for malicious or broken code to be deployed.
24 |
25 | Since a given SHA could originate from **anywhere** in a given Git project, we can't reliably check if CI passed or if approvals were given for the SHA in question.
26 |
27 | Given all of the above, it is clear that deploying SHAs could potentially be unsafe if you are unfamiliar with the actions being taken by this style of deployment
28 |
29 | ## My project structure requires SHA deployments, what should I do?
30 |
31 | Just because SHA deployments _can be unsafe_ doesn't mean that they _will be unsafe_.
32 |
33 | For example, if you:
34 |
35 | - Understand the risks
36 | - Have proper and restricted repo permissions for your collaborators (i.e. the whole company doesn't have `write` access to deploy)
37 | - Trust your collaborators
38 | - Have a well defined rollback process
39 |
40 | Then you can properly evaluate the risk of enabling this deployment option.
41 |
42 | ## Enabling SHA Deployments
43 |
44 | In order to use this feature, you must set the following input option:
45 |
46 | ```yaml
47 | - uses: github/branch-deploy@vX.X.X
48 | id: branch-deploy
49 | with:
50 | allow_sha_deployments: "true" # <--- this option must be "true"
51 | ```
52 |
53 | The reasoning for this is that SHA deployments can easily become dangerous. So users should be informed, and have to manually enable this feature for it to be used.
54 |
55 | ### Example 📸
56 |
57 | Once you have enabled SHA deployments, you should see some output similar to this example to know that it is working properly.
58 |
59 | 
60 |
--------------------------------------------------------------------------------
/src/functions/outdated-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 | import {API_HEADERS} from './api-headers.js'
4 |
5 | // Helper function to check to see if the PR branch is outdated in anyway based on the Action's configuration
6 | //
7 | // outdated_mode can be: pr_base, default_branch, or strict (default)
8 | //
9 | // :param context: The context of the Action
10 | // :param octokit: An authenticated instance of the GitHub client
11 | // :param data: An object containing all of the data needed for this function
12 | // :return: A boolean value indicating if the PR branch is outdated or not
13 | export async function isOutdated(context, octokit, data) {
14 | core.debug(`outdated_mode: ${data.outdated_mode}`)
15 |
16 | // Helper function to compare two branches
17 | // :param baseBranch: The base branch to compare against
18 | // :param prBranch: The PR branch to compare
19 | // :return: An object containing a boolean value indicating if the PR branch is behind the base branch or not, and a string containing the name of the branch that is behind
20 | async function compareBranches(baseBranch, prBranch) {
21 | // if the mergeStateStatus is BEHIND, then we know the PR is behind the base branch
22 | // in this case we can skip the commit comparison
23 | if (data.mergeStateStatus === 'BEHIND') {
24 | core.debug(`mergeStateStatus is BEHIND - exiting isOutdated logic early`)
25 | return {outdated: true, branch: baseBranch.data.name}
26 | }
27 |
28 | const compare = await octokit.rest.repos.compareCommits({
29 | ...context.repo,
30 | base: baseBranch.data.commit.sha,
31 | head: prBranch.data.head.sha,
32 | headers: API_HEADERS
33 | })
34 |
35 | if (compare.data.behind_by > 0) {
36 | const commits = compare.data.behind_by === 1 ? 'commit' : 'commits'
37 | core.warning(
38 | `The PR branch is behind the base branch by ${COLORS.highlight}${compare.data.behind_by} ${commits}${COLORS.reset}`
39 | )
40 | return {outdated: true, branch: baseBranch.data.name}
41 | } else {
42 | core.debug(`The PR branch is not behind the base branch - OK`)
43 | return {outdated: false, branch: baseBranch.data.name}
44 | }
45 | }
46 |
47 | // Check based on the outdated_mode
48 | // pr_base: compare the PR branch to the base branch it is targeting
49 | // default_branch: compare the PR branch to the default branch of the repo (aka the "stable" branch)
50 | // strict: compare the PR branch to both the base branch and the default branch (default mode)
51 | switch (data.outdated_mode) {
52 | case 'pr_base':
53 | core.debug(`checking isOutdated with pr_base mode`)
54 | return await compareBranches(data.baseBranch, data.pr)
55 | case 'default_branch':
56 | core.debug(`checking isOutdated with default_branch mode`)
57 | return await compareBranches(data.stableBaseBranch, data.pr)
58 | case 'strict': {
59 | core.debug(`checking isOutdated with strict mode`)
60 | const isBehindBaseBranch = await compareBranches(data.baseBranch, data.pr)
61 | const isBehindStableBaseBranch = await compareBranches(
62 | data.stableBaseBranch,
63 | data.pr
64 | )
65 |
66 | // Return the first branch that is behind (if any)
67 | if (isBehindBaseBranch.outdated === true) {
68 | return isBehindBaseBranch
69 | } else if (isBehindStableBaseBranch.outdated === true) {
70 | return isBehindStableBaseBranch
71 | } else {
72 | // If neither branch is behind, then the PR is not outdated
73 | return {
74 | outdated: false,
75 | branch: `${data.baseBranch.data.name}|${data.stableBaseBranch.data.name}`
76 | }
77 | }
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | # Copilot Instructions
2 |
3 | ## Environment Setup
4 |
5 | Bootstrap the project by running:
6 |
7 | ```bash
8 | npm install
9 | ```
10 |
11 | ## Testing
12 |
13 | Ensure all unit tests pass by running the following:
14 |
15 | ```bash
16 | npm run test
17 | ```
18 |
19 | This project should include unit tests for all lines, functions, and branches of code.
20 |
21 | This project **requires 100% test coverage** of code.
22 |
23 | Unit tests should exist in the `__tests__` directory. They are powered by `jest`.
24 |
25 | ## Bundling
26 |
27 | The final commit should always be a bundle of the code. This is done by running the following command:
28 |
29 | ```bash
30 | npm run all
31 | ```
32 |
33 | This uses Vercel's `ncc` to bundle JS code for running in GitHub Actions.
34 |
35 | ## Project Guidelines
36 |
37 | - Follow:
38 | - Object-Oriented best practices, especially abstraction and encapsulation
39 | - GRASP Principles, especially Information Expert, Creator, Indirection, Low Coupling, High Cohesion, and Pure Fabrication
40 | - SOLID principles, especially Dependency Inversion, Open/Closed, and Single Responsibility
41 | - Base new work on latest `main` branch
42 | - Changes should maintain consistency with existing patterns and style.
43 | - Document changes clearly and thoroughly, including updates to existing comments when appropriate. Try to use the same "voice" as the other comments, mimicking their tone and style.
44 | - When responding to code refactoring suggestions, function suggestions, or other code changes, please keep your responses as concise as possible. We are capable engineers and can understand the code changes without excessive explanation. If you feel that a more detailed explanation is necessary, you can provide it, but keep it concise. After doing any refactoring, ensure to run `npm run test` to ensure that all tests still pass.
45 | - When suggesting code changes, always opt for the most maintainable approach. Try your best to keep the code clean and follow DRY principles. Avoid unnecessary complexity and always consider the long-term maintainability of the code.
46 | - When writing unit tests, try to consider edge cases as well as the main path of success. This will help ensure that the code is robust and can handle unexpected inputs or situations.
47 | - Hard-coded strings should almost always be constant variables.
48 | - In writing code, take the following as preferences but not rules:
49 | - understandability over concision
50 | - syntax, expressions, and blocks that are common across many languages over language-specific syntax.
51 | - more descriptive names over brevity of variable, function, and class names
52 | - the use of whitespace (newlines) over compactness of files
53 | - naming of variables and methods that lead to expressions and blocks reading more like English sentences.
54 | - less lines of code over more. Keep changes minimal and focused.
55 |
56 | ## Pull Request Requirements
57 |
58 | - All tests must pass.
59 | - The linter must pass.
60 | - Documentation must be up-to-date.
61 | - The body of the Pull Request should:
62 | - contain a summary of the changes
63 | - make special note of any changes to dependencies
64 | - comment on the security of the changes being made and offer suggestions for further securing the code
65 |
66 | ## Repository Organization
67 |
68 | - `.github/` - GitHub configurations and settings
69 | - `docs/` - Main documentation storage
70 | - `script/` - Repository maintenance scripts
71 | - `src/` - Main code for the project. This is where the main application/service code lives
72 | - `__tests__/` - Tests for the project. This is where the unit tests live
73 | - `dist/` - This is where the JS compiled code lives for the GitHub Action
74 | - `action.yml` - The GitHub Action file. This is where the GitHub Action is defined
75 |
--------------------------------------------------------------------------------
/src/functions/valid-deployment-order.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 | import {activeDeployment} from './deployment.js'
4 |
5 | // Helper function to ensure the deployment order is enforced (if any)
6 | // :param octokit: The octokit client
7 | // :param context: The GitHub Actions event context
8 | // :param enforced_deployment_order: The enforced deployment order (ex: ['development', 'staging', 'production'])
9 | // :param environment: The environment to check for (ex: production)
10 | // :param sha: The sha to check for (ex: cb2bc0193184e779a5efc05e48acdfd1026f59a7)
11 | // :returns: an object with the valid: true if the deployment order is valid, false otherwise, and results: an array of the previous environments in the enforced deployment order that do not have active deployments
12 | export async function validDeploymentOrder(
13 | octokit,
14 | context,
15 | enforced_deployment_order,
16 | environment,
17 | sha
18 | ) {
19 | core.info(`🚦 deployment order is ${COLORS.highlight}enforced${COLORS.reset}`)
20 |
21 | if (enforced_deployment_order.length === 1) {
22 | core.warning(
23 | `💡 Having only one environment in the enforced deployment order will always cause the deployment order checks to pass if the environment names match. This is likely not what you want. Please either unset the enforced deployment order or add more environments to it.`
24 | )
25 | return {valid: enforced_deployment_order[0] === environment, results: []}
26 | }
27 |
28 | // if the enforced deployment order is set, check to see if the current environment is the first in the list
29 | // this indicates that we can proceed with the deployment right away as there are no previous environments to gate it
30 | if (enforced_deployment_order[0] === environment) {
31 | core.info(
32 | `🚦 deployment order checks passed as ${COLORS.highlight}${environment}${COLORS.reset} is the first environment in the enforced deployment order`
33 | )
34 | return {valid: true, results: []}
35 | }
36 |
37 | // determine all the previous environments in the enforced deployment order prior to the current environment
38 | const previous_environments = enforced_deployment_order.slice(
39 | 0,
40 | enforced_deployment_order.indexOf(environment)
41 | )
42 |
43 | core.debug(
44 | `environments that require active deployments: ${previous_environments}`
45 | )
46 |
47 | // iterate over the previous environments and check to see if they have an active deployment
48 | let results = []
49 | for (const previous_environment of previous_environments) {
50 | core.debug(`checking if ${previous_environment} has an active deployment`)
51 | const is_active = await activeDeployment(
52 | octokit,
53 | context,
54 | previous_environment,
55 | sha
56 | )
57 |
58 | if (!is_active) {
59 | core.error(
60 | `🚦 deployment order checks failed as ${COLORS.highlight}${previous_environment}${COLORS.reset} does not have an active deployment at sha: ${sha}`
61 | )
62 | results.push({environment: previous_environment, active: false})
63 | continue
64 | }
65 |
66 | core.debug(
67 | `deployment for ${previous_environment} is active at sha: ${sha}`
68 | )
69 | results.push({environment: previous_environment, active: true})
70 | }
71 |
72 | // if all previous environments have active deployments, we can proceed with the deployment
73 | if (results.every(result => result.active === true)) {
74 | core.info(
75 | `🚦 deployment order checks passed as all previous environments have active deployments`
76 | )
77 | return {valid: true, results: results}
78 | }
79 |
80 | // set an output that contains all the environments that do not have active deployments but need them for a deployment to proceed
81 | const needs_to_be_deployed = results
82 | .filter(result => !result.active)
83 | .map(result => result.environment)
84 | .join(',')
85 | core.setOutput('needs_to_be_deployed', needs_to_be_deployed)
86 |
87 | // if we made it this far, it means that not all previous environments have active deployments and we cannot proceed
88 | return {valid: false, results: results}
89 | }
90 |
--------------------------------------------------------------------------------
/__tests__/functions/identical-commit-check.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import {identicalCommitCheck} from '../../src/functions/identical-commit-check.js'
4 | import {COLORS} from '../../src/functions/colors.js'
5 |
6 | const saveStateMock = vi.spyOn(core, 'saveState')
7 | const setOutputMock = vi.spyOn(core, 'setOutput')
8 | const infoMock = vi.spyOn(core, 'info')
9 |
10 | var context
11 | var octokit
12 | beforeEach(() => {
13 | vi.clearAllMocks()
14 |
15 | context = {
16 | repo: {
17 | owner: 'corp',
18 | repo: 'test'
19 | },
20 | payload: {
21 | comment: {
22 | id: '1'
23 | }
24 | }
25 | }
26 |
27 | octokit = {
28 | rest: {
29 | repos: {
30 | get: vi.fn().mockReturnValue({
31 | data: {
32 | default_branch: 'main'
33 | }
34 | }),
35 | getBranch: vi.fn().mockReturnValue({
36 | data: {
37 | commit: {
38 | sha: 'abcdef',
39 | commit: {
40 | tree: {
41 | sha: 'deadbeef'
42 | }
43 | }
44 | }
45 | }
46 | }),
47 | getCommit: vi.fn().mockReturnValue({
48 | data: {
49 | commit: {
50 | tree: {
51 | sha: 'deadbeef'
52 | }
53 | }
54 | }
55 | }),
56 | listDeployments: vi.fn().mockReturnValue({
57 | data: [
58 | {
59 | sha: 'deadbeef',
60 | id: 123395608,
61 | created_at: '2023-02-01T21:30:40Z',
62 | payload: {
63 | type: 'some-other-type'
64 | }
65 | },
66 | {
67 | sha: 'beefdead',
68 | id: 785395609,
69 | created_at: '2023-02-01T20:26:33Z',
70 | payload: {
71 | type: 'branch-deploy'
72 | }
73 | }
74 | ]
75 | })
76 | }
77 | }
78 | }
79 | })
80 |
81 | test('checks if the default branch sha and deployment sha are identical, and they are', async () => {
82 | expect(
83 | await identicalCommitCheck(octokit, context, 'production')
84 | ).toStrictEqual(true)
85 | expect(infoMock).toHaveBeenCalledWith(
86 | `🟰 the latest deployment tree sha is ${COLORS.highlight}equal${COLORS.reset} to the default branch tree sha`
87 | )
88 | expect(setOutputMock).toHaveBeenCalledWith('continue', 'false')
89 | expect(setOutputMock).toHaveBeenCalledWith('environment', 'production')
90 | expect(setOutputMock).not.toHaveBeenCalledWith('sha', 'abcdef')
91 | expect(saveStateMock).not.toHaveBeenCalledWith('sha', 'abcdef')
92 | })
93 |
94 | test('checks if the default branch sha and deployment sha are identical, and they are not', async () => {
95 | octokit.rest.repos.getCommit = vi.fn().mockReturnValue({
96 | data: {
97 | commit: {
98 | tree: {
99 | sha: 'beefdead'
100 | }
101 | }
102 | }
103 | })
104 |
105 | expect(
106 | await identicalCommitCheck(octokit, context, 'production')
107 | ).toStrictEqual(false)
108 | expect(infoMock).toHaveBeenCalledWith(
109 | `📍 latest commit sha on ${COLORS.highlight}main${COLORS.reset}: ${COLORS.info}abcdef${COLORS.reset}`
110 | )
111 | expect(infoMock).toHaveBeenCalledWith(
112 | `🌲 latest default ${COLORS.info}branch${COLORS.reset} tree sha: ${COLORS.info}deadbeef${COLORS.reset}`
113 | )
114 | expect(infoMock).toHaveBeenCalledWith(
115 | `🌲 latest ${COLORS.info}deployment${COLORS.reset} tree sha: ${COLORS.info}beefdead${COLORS.reset}`
116 | )
117 | expect(infoMock).toHaveBeenCalledWith(
118 | `🚀 a ${COLORS.success}new deployment${COLORS.reset} will be created based on your configuration`
119 | )
120 | expect(setOutputMock).toHaveBeenCalledWith('continue', 'true')
121 | expect(setOutputMock).toHaveBeenCalledWith('environment', 'production')
122 | expect(setOutputMock).toHaveBeenCalledWith('sha', 'abcdef')
123 | expect(saveStateMock).toHaveBeenCalledWith('sha', 'abcdef')
124 | })
125 |
--------------------------------------------------------------------------------
/src/functions/naked-command-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 | import dedent from 'dedent-js'
4 | import {LOCK_METADATA} from './lock-metadata.js'
5 | import {API_HEADERS} from './api-headers.js'
6 |
7 | const thumbsDown = '-1'
8 | const docs =
9 | 'https://github.com/github/branch-deploy/blob/main/docs/naked-commands.md'
10 |
11 | // Helper function to check if a naked command was issued
12 | // :param body: The body of the issueops command
13 | // :param param_separator: The separator used to seperate the command from the parameters
14 | // :param triggers: All the triggers for the Action rolled up into an Array
15 | // :returns: true if a naked command was issued, false otherwise
16 | export async function nakedCommandCheck(
17 | body,
18 | param_separator,
19 | triggers,
20 | octokit,
21 | context
22 | ) {
23 | var nakedCommand = false
24 | core.debug(`before - nakedCommandCheck: body: ${body}`)
25 | body = body.trim()
26 |
27 | // ////// checking for lock flags ////////
28 | // if the body contains the globalFlag, exit right away as environments are not relevant
29 | const globalFlag = core.getInput('global_lock_flag').trim()
30 | if (body.includes(globalFlag)) {
31 | core.debug('global lock flag found in naked command check')
32 | return nakedCommand
33 | }
34 |
35 | // remove any lock flags from the body
36 | LOCK_METADATA.lockInfoFlags.forEach(flag => {
37 | body = body.replace(flag, '').trim()
38 | })
39 |
40 | // remove the --reason from the body if it exists
41 | if (body.includes('--reason')) {
42 | core.debug(
43 | `'--reason' found in comment body: ${body} - attempting to remove for naked command checks`
44 | )
45 | body = body.split('--reason')[0].trim()
46 | core.debug(`comment body after '--reason' removal: ${body}`)
47 | }
48 | ////////// end lock flag checks //////////
49 |
50 | // first remove any params
51 | // Seperate the issueops command on the 'param_separator'
52 | var paramCheck = body.split(param_separator)
53 | paramCheck.shift() // remove everything before the 'param_separator'
54 | const params = paramCheck.join(param_separator) // join it all back together (in case there is another separator)
55 | // if there is anything after the 'param_separator'; output it, log it, and remove it from the body for env checks
56 | if (params !== '') {
57 | body = body.split(`${param_separator}${params}`)[0].trim()
58 | core.debug(
59 | `params were found and removed for naked command checks: ${params}`
60 | )
61 | }
62 |
63 | core.debug(`after - nakedCommandCheck: body: ${body}`)
64 |
65 | // loop through all the triggers and check to see if the command is a naked command
66 | for (const trigger of triggers) {
67 | if (body === trigger) {
68 | nakedCommand = true
69 | core.warning(
70 | `🩲 naked commands are ${COLORS.warning}not${COLORS.reset} allowed based on your configuration: ${COLORS.highlight}${body}${COLORS.reset}`
71 | )
72 | core.warning(
73 | `📚 view the documentation around ${COLORS.highlight}naked commands${COLORS.reset} to learn more: ${docs}`
74 | )
75 |
76 | const message = dedent(`
77 | ### Missing Explicit Environment
78 |
79 | #### Suggestion
80 |
81 | \`\`\`text
82 | ${body}
83 | \`\`\`
84 |
85 | #### Explanation
86 |
87 | This style of command is known as a "naked command" and is not allowed based on your configuration. "Naked commands" are commands that do not explicitly specify an environment, for example \`${body}\` would be a "naked command" whereas \`${body} \` would not be.
88 |
89 | > View the [documentation](${docs}) to learn more
90 | `)
91 |
92 | // add a comment to the issue with the message
93 | await octokit.rest.issues.createComment({
94 | ...context.repo,
95 | issue_number: context.issue.number,
96 | body: message,
97 | headers: API_HEADERS
98 | })
99 |
100 | // add a reaction to the issue_comment to indicate failure
101 | await octokit.rest.reactions.createForIssueComment({
102 | ...context.repo,
103 | comment_id: context.payload.comment.id,
104 | content: thumbsDown,
105 | headers: API_HEADERS
106 | })
107 |
108 | break
109 | }
110 | }
111 |
112 | return nakedCommand
113 | }
114 |
--------------------------------------------------------------------------------
/__tests__/functions/is-timestamp-older.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {vi, expect, describe, test, beforeEach} from 'vitest'
3 | import {isTimestampOlder} from '../../src/functions/is-timestamp-older.js'
4 |
5 | beforeEach(() => {
6 | vi.clearAllMocks()
7 | })
8 |
9 | describe('isTimestampOlder', () => {
10 | test('throws if timestampA is missing', () => {
11 | expect(() => isTimestampOlder(undefined, '2024-10-15T11:00:00Z')).toThrow(
12 | 'One or both timestamps are missing or empty.'
13 | )
14 | expect(() => isTimestampOlder(null, '2024-10-15T11:00:00Z')).toThrow(
15 | 'One or both timestamps are missing or empty.'
16 | )
17 | expect(() => isTimestampOlder('', '2024-10-15T11:00:00Z')).toThrow(
18 | 'One or both timestamps are missing or empty.'
19 | )
20 | })
21 |
22 | test('throws if timestampB is missing', () => {
23 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', undefined)).toThrow(
24 | 'One or both timestamps are missing or empty.'
25 | )
26 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', null)).toThrow(
27 | 'One or both timestamps are missing or empty.'
28 | )
29 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', '')).toThrow(
30 | 'One or both timestamps are missing or empty.'
31 | )
32 | })
33 |
34 | test('throws if both timestamps are invalid', () => {
35 | expect(() => isTimestampOlder('bad', 'bad')).toThrow(
36 | /format YYYY-MM-DDTHH:MM:SSZ/
37 | )
38 | expect(() => isTimestampOlder('notadate', '2024-10-15T11:00:00Z')).toThrow(
39 | /format YYYY-MM-DDTHH:MM:SSZ/
40 | )
41 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', 'notadate')).toThrow(
42 | /format YYYY-MM-DDTHH:MM:SSZ/
43 | )
44 | expect(() => isTimestampOlder({}, '2024-10-15T11:00:00Z')).toThrow(
45 | /format YYYY-MM-DDTHH:MM:SSZ/
46 | )
47 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', {})).toThrow(
48 | /format YYYY-MM-DDTHH:MM:SSZ/
49 | )
50 | expect(() => isTimestampOlder([], '2024-10-15T11:00:00Z')).toThrow(
51 | /format YYYY-MM-DDTHH:MM:SSZ/
52 | )
53 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', [])).toThrow(
54 | /format YYYY-MM-DDTHH:MM:SSZ/
55 | )
56 | expect(() => isTimestampOlder('2024-10-15T11:00:00Z', 1)).toThrow(
57 | /format YYYY-MM-DDTHH:MM:SSZ/
58 | )
59 | expect(() => isTimestampOlder(1, '2024-10-15T11:00:00Z')).toThrow(
60 | /format YYYY-MM-DDTHH:MM:SSZ/
61 | )
62 | })
63 |
64 | test('returns true if timestampA is older than timestampB', () => {
65 | expect(
66 | isTimestampOlder('2024-10-15T11:00:00Z', '2024-10-16T11:00:00Z')
67 | ).toBe(true)
68 | expect(core.debug).toHaveBeenCalledWith(
69 | '2024-10-15T11:00:00Z is older than 2024-10-16T11:00:00Z'
70 | )
71 | })
72 |
73 | test('returns false if timestampA is newer than timestampB', () => {
74 | expect(
75 | isTimestampOlder('2024-10-17T11:00:00Z', '2024-10-16T11:00:00Z')
76 | ).toBe(false)
77 | expect(core.debug).toHaveBeenCalledWith(
78 | '2024-10-17T11:00:00Z is not older than 2024-10-16T11:00:00Z'
79 | )
80 | })
81 |
82 | test('returns false if timestampA equals timestampB', () => {
83 | expect(
84 | isTimestampOlder('2024-10-16T11:00:00Z', '2024-10-16T11:00:00Z')
85 | ).toBe(false)
86 | expect(core.debug).toHaveBeenCalledWith(
87 | '2024-10-16T11:00:00Z is not older than 2024-10-16T11:00:00Z'
88 | )
89 | })
90 |
91 | test('accepts valid leap year date', () => {
92 | // Feb 29, 2024 is valid (leap year)
93 | expect(() =>
94 | isTimestampOlder('2024-02-29T12:00:00Z', '2024-10-15T11:00:00Z')
95 | ).not.toThrow()
96 | expect(
97 | isTimestampOlder('2024-02-29T12:00:00Z', '2024-10-15T11:00:00Z')
98 | ).toBe(true)
99 | expect(
100 | isTimestampOlder('2024-10-15T11:00:00Z', '2024-02-29T12:00:00Z')
101 | ).toBe(false)
102 | })
103 |
104 | test('throws an error on js silent date contructor corrections', () => {
105 | // Invalid date: 2024-02-30T12:00:00Z actually becomes 2024-03-01T12:00:00Z (gross)
106 | expect(() =>
107 | isTimestampOlder('2024-02-30T12:00:00Z', '2024-10-15T11:00:00Z')
108 | ).toThrow(/Invalid date format/)
109 | expect(() =>
110 | isTimestampOlder('2024-10-15T11:00:00Z', '2024-02-30T12:00:00Z')
111 | ).toThrow(/Invalid date format/)
112 | })
113 | })
114 |
--------------------------------------------------------------------------------
/__tests__/functions/post.test.js:
--------------------------------------------------------------------------------
1 | import * as github from '@actions/github'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import * as core from '@actions/core'
4 |
5 | import {post} from '../../src/functions/post.js'
6 | import {COLORS} from '../../src/functions/colors.js'
7 | import * as postDeploy from '../../src/functions/post-deploy.js'
8 | import * as contextCheck from '../../src/functions/context-check.js'
9 |
10 | const validBooleanInputs = {
11 | skip_completing: false
12 | }
13 | const validInputs = {
14 | status: 'success',
15 | successful_deploy_labels: '',
16 | successful_noop_labels: '',
17 | failed_deploy_labels: '',
18 | failed_noop_labels: '',
19 | skip_successful_noop_labels_if_approved: 'false',
20 | skip_successful_deploy_labels_if_approved: 'false'
21 | }
22 |
23 | const validStates = {
24 | sha: 'abc123',
25 | ref: 'test-ref',
26 | comment_id: '123',
27 | noop: 'false',
28 | deployment_id: '456',
29 | environment: 'production',
30 | token: 'test-token',
31 | approved_reviews_count: '1',
32 | environment_url: 'https://example.com',
33 | review_decision: 'APPROVED',
34 | fork: 'false',
35 | params: 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432',
36 | parsed_params: JSON.stringify({
37 | config: {db: {host: 'localhost', port: 5432}},
38 | _: ['LOG_LEVEL=debug']
39 | }),
40 | deployment_start_time: '2024-01-01T00:00:00Z'
41 | }
42 |
43 | const setFailedMock = vi.spyOn(core, 'setFailed').mockImplementation(() => {})
44 | const setWarningMock = vi.spyOn(core, 'warning').mockImplementation(() => {})
45 | const infoMock = vi.spyOn(core, 'info').mockImplementation(() => {})
46 |
47 | beforeEach(() => {
48 | vi.clearAllMocks()
49 | vi.spyOn(core, 'error').mockImplementation(() => {})
50 | vi.spyOn(core, 'debug').mockImplementation(() => {})
51 | vi.spyOn(core, 'getBooleanInput').mockImplementation(name => {
52 | return validBooleanInputs[name]
53 | })
54 | vi.spyOn(core, 'getInput').mockImplementation(name => {
55 | return validInputs[name]
56 | })
57 | vi.spyOn(core, 'getState').mockImplementation(name => {
58 | return validStates[name]
59 | })
60 |
61 | vi.spyOn(postDeploy, 'postDeploy').mockImplementation(() => {
62 | return undefined
63 | })
64 |
65 | vi.spyOn(contextCheck, 'contextCheck').mockImplementation(() => {
66 | return true
67 | })
68 |
69 | vi.spyOn(github, 'getOctokit').mockImplementation(() => {
70 | return true
71 | })
72 | })
73 |
74 | test('successfully runs post() Action logic', async () => {
75 | expect(await post()).toBeUndefined()
76 | expect(infoMock).toHaveBeenCalledWith(
77 | `🧑🚀 commit SHA: ${COLORS.highlight}${validStates.sha}${COLORS.reset}`
78 | )
79 | })
80 |
81 | test('successfully runs post() Action logic when environment_url is not defined', async () => {
82 | const noEnvironmentUrl = {
83 | environment_url: null
84 | }
85 |
86 | vi.spyOn(core, 'getState').mockImplementation(name => {
87 | return noEnvironmentUrl[name]
88 | })
89 |
90 | expect(await post()).toBeUndefined()
91 | })
92 |
93 | test('exits due to an invalid Actions context', async () => {
94 | vi.spyOn(contextCheck, 'contextCheck').mockImplementation(() => {
95 | return false
96 | })
97 |
98 | expect(await post()).toBeUndefined()
99 | })
100 |
101 | test('exits due to a bypass being set', async () => {
102 | const bypassed = {
103 | bypass: 'true'
104 | }
105 | vi.spyOn(core, 'getState').mockImplementation(name => {
106 | return bypassed[name]
107 | })
108 | expect(await post()).toBeUndefined()
109 | expect(setWarningMock).toHaveBeenCalledWith(
110 | `⛔ ${COLORS.highlight}bypass${COLORS.reset} set, exiting`
111 | )
112 | })
113 |
114 | test('skips the process of completing a deployment', async () => {
115 | const skipped = {
116 | skip_completing: 'true'
117 | }
118 | vi.spyOn(core, 'getBooleanInput').mockImplementation(name => {
119 | return skipped[name]
120 | })
121 | expect(await post()).toBeUndefined()
122 | expect(infoMock).toHaveBeenCalledWith(
123 | `⏩ ${COLORS.highlight}skip_completing${COLORS.reset} set, exiting`
124 | )
125 | })
126 |
127 | test('throws an error', async () => {
128 | try {
129 | vi.spyOn(github, 'getOctokit').mockImplementation(() => {
130 | throw new Error('test error')
131 | })
132 | await post()
133 | } catch (e) {
134 | expect(e.message).toBe('test error')
135 | expect(setFailedMock).toHaveBeenCalledWith('test error')
136 | }
137 | })
138 |
--------------------------------------------------------------------------------
/docs/enforced-deployment-order.md:
--------------------------------------------------------------------------------
1 | # 🚦 Enforced Deployment Order
2 |
3 | ## What is Enforced Deployment Order?
4 |
5 | Enforced Deployment Order is a feature that allows you to specify a strict sequence in which deployments must occur across different environments. By defining an enforced deployment order, you ensure that deployments to subsequent environments only happen after successful deployments to preceding environments. This helps maintain the integrity and stability of your deployment pipeline by preventing out-of-order deployments that could introduce issues or inconsistencies.
6 |
7 | This feature is entirely optional and can be enabled easily should your project or team require it.
8 |
9 | If you do not set/enable this feature, deployments will proceed without any enforced order (the default behavior).
10 |
11 | ## How Does Enforced Deployment Order Work?
12 |
13 | When you enable enforced deployment order, you define a specific sequence of environments in which deployments must occur. This sequence is set with the `enforced_deployment_order` input option.
14 |
15 | Let's assume you have three environments: `development`, `staging`, and `production`. If you set the `enforced_deployment_order` input to `development,staging,production`, then deployments must occur in the following order: `development` -> `staging` -> `production`. If you were to attempt a `.deploy to production` command without having first deployed to `development` and `staging`, the deployment would fail and tell you why.
16 |
17 | The branch-deploy Action determines which environments have been successfully deployed by using GitHub's GraphQL API to query each environment for its _latest_ deployment.
18 |
19 | Here is how that process takes place under the hood:
20 |
21 | 1. A request to the GraphQL API is made to fetch the latest deployment for a given environment and sort it to the most recent one based on its `CREATED_AT` timestamp
22 | 2. The `deployment.state` attribute is evaluated to determine if the deployment is currently `ACTIVE` or not. If it is not active, then the deployment has not yet been deployed to that environment. If the deployment is active then we do an extra check to see if the `deployment.commit.oid` matches the current commit SHA that is being requested for deployment. If it is an exact match, then the most recent deployment for that environment is indeed active for the commit we are trying to deploy and it satisfies the enforced deployment order. If it is not an exact match, then we know that the most recent deployment for that environment is not active for the commit we are trying to deploy and it does not satisfy the enforced deployment order.
23 |
24 | It should be noted that if a "rollback" style deployment is used (ex: `.deploy main to `), then all "enforced deployment order" checks are skipped so that a rollback deployment can be performed to any environment at any time.
25 |
26 | ## Why Use Enforced Deployment Order?
27 |
28 | Using enforced deployment order can help maintain the integrity and stability of your deployment pipeline. By ensuring that deployments occur in a specific sequence, you can:
29 |
30 | - Prevent issues that may arise from deploying to production before testing in staging.
31 | - Ensure that each environment is properly validated before moving to the next.
32 | - Maintain a clear and predictable deployment process.
33 |
34 | ## How to Configure
35 |
36 | To enable enforced deployment order, set the `enforced_deployment_order` input in your workflow file. The value for `enforced_deployment_order` is a comma-separated string that specifies the order of environments from left to right. Here is an example configuration:
37 |
38 | ```yaml
39 | - uses: github/branch-deploy@vX.X.X
40 | id: branch-deploy
41 | with:
42 | environment_targets: development,staging,production # <-- these are the defined environments that are available for deployment
43 | enforced_deployment_order: development,staging,production # <-- here is where the enforced deployment order is set - it is read from left to right
44 | ```
45 |
46 | ## Closing Notes
47 |
48 | Using enforced deployment order is entirely optional and may not be necessary for all projects or teams. However, if you find that your deployment pipeline would benefit from a strict sequence of deployments, this feature can help you maintain the integrity and stability of your deployments. It should be noted that requiring a strict deployment order may introduce some overhead, complexity, and friction to your deployment process, so it is important to weigh the benefits against the costs and determine if this feature is right for your project or team.
49 |
--------------------------------------------------------------------------------
/src/functions/commit-safety-checks.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 | import {isTimestampOlder} from './is-timestamp-older.js'
4 |
5 | // A helper method to ensure that the commit being used is safe for deployment
6 | // These safety checks are supplemental to the checks found in `src/functions/prechecks.js`
7 | // :param context: The context of the event
8 | // :param data: An object containing data such as the sha, the created_at time for the comment, and more
9 | export async function commitSafetyChecks(context, data) {
10 | const commit = data.commit
11 | const inputs = data.inputs
12 | const sha = data.sha
13 | const comment_created_at = context?.payload?.comment?.created_at
14 | const commit_created_at = commit?.author?.date // fetch the timestamp that the commit was authored (format: "2024-10-21T19:10:24Z" - String)
15 | const verified_at = commit?.verification?.verified_at
16 | core.debug(`comment_created_at: ${comment_created_at}`)
17 | core.debug(`commit_created_at: ${commit_created_at}`)
18 | core.debug(`verified_at: ${verified_at}`)
19 |
20 | // Defensive: Ensure required fields exist
21 | if (!comment_created_at) {
22 | throw new Error('Missing context.payload.comment.created_at')
23 | }
24 | if (!commit_created_at) {
25 | throw new Error('Missing commit.author.date')
26 | }
27 |
28 | const isVerified = commit?.verification?.verified === true ? true : false
29 | core.debug(`isVerified: ${isVerified}`)
30 | core.setOutput('commit_verified', isVerified)
31 | core.saveState('commit_verified', isVerified)
32 |
33 | // check to ensure that the commit was authored before the comment was created
34 | if (isTimestampOlder(comment_created_at, commit_created_at)) {
35 | return {
36 | message: `### ⚠️ Cannot proceed with deployment\n\nThe latest commit is not safe for deployment. It was authored after the trigger comment was created.`,
37 | status: false,
38 | isVerified: isVerified
39 | }
40 | }
41 |
42 | // begin the commit verification checks
43 | if (isVerified) {
44 | core.info(`🔑 commit signature is ${COLORS.success}valid${COLORS.reset}`)
45 | } else if (inputs.commit_verification === true && isVerified === false) {
46 | core.warning(`🔑 commit signature is ${COLORS.error}invalid${COLORS.reset}`)
47 | } else {
48 | // if we make it here, the commit is not valid but that is okay because commit verification is not enabled
49 | core.debug(
50 | `🔑 commit does not contain a verified signature but ${COLORS.highlight}commit signing is not required${COLORS.reset} - ${COLORS.success}OK${COLORS.reset}`
51 | )
52 | }
53 |
54 | // If commit verification is enabled and the commit signature is not valid (or it is missing / undefined), exit
55 | if (inputs.commit_verification === true && isVerified === false) {
56 | return {
57 | message: `### ⚠️ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`${commit?.verification?.reason}\`\n\n> The commit signature is not valid. Please ensure the commit has been properly signed and try again.`,
58 | status: false,
59 | isVerified: isVerified
60 | }
61 | }
62 |
63 | // if commit_verification is enabled and the verified_at timestamp is not present, throw an error
64 | if (inputs.commit_verification === true && !verified_at) {
65 | return {
66 | message: `### ⚠️ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`${commit?.verification?.reason}\`\n\n> The commit signature is not valid as there is no valid \`verified_at\` date. Please ensure the commit has been properly signed and try again.`,
67 | status: false,
68 | isVerified: isVerified
69 | }
70 | }
71 |
72 | // check to ensure that the commit signature was authored before the comment was created
73 | // even if the commit signature is valid, we still want to reject it if it was authored after the comment was created
74 | if (
75 | inputs.commit_verification === true &&
76 | isTimestampOlder(comment_created_at, verified_at)
77 | ) {
78 | return {
79 | message: `### ⚠️ Cannot proceed with deployment\n\nThe latest commit is not safe for deployment. The commit signature was verified after the trigger comment was created. Please try again if you recently pushed a new commit.`,
80 | status: false,
81 | isVerified: isVerified
82 | }
83 | }
84 |
85 | // if we make it through all the checks, we can return a success object
86 | return {
87 | message: 'success',
88 | status: true,
89 | isVerified: isVerified
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/docs/merge-commit-strategy.md:
--------------------------------------------------------------------------------
1 | # Merge Commit Workflow Strategy
2 |
3 | > Note: This section is rather advanced and entirely optional. This workflow also complements the other alternate workflow called "[Unlock on Merge Mode](unlock-on-merge.md)".
4 |
5 | At GitHub, we use custom logic to compare the latest deployment with the merge commit created when a pull request is merged to our default branch. This helps to save CI time, and prevent redundant deployments. If a user deploys a pull request, it succeeds, and then the pull request is merged, we will not deploy the merge commit. This is because the merge commit is the same as the latest deployment.
6 |
7 | This Action comes bundled with an alternate workflow to help facilitate exactly this. Before explaining how this works, let's first review why this might be useful.
8 |
9 | Example scenario 1:
10 |
11 | 1. You have a pull request with a branch deployment created by this Action
12 | 2. No one else except for you has created a deployment
13 | 3. You click the merge button on the pull request you just deployed
14 | 4. The "merge commit workflow strategy" is triggered on merge to your default branch
15 | 5. The workflow compares the latest deployment with the merge commit and finds they are identical
16 | 6. The workflow uses logic to exit as it does not need to deploy the merge commit since it is the same as the latest deployment
17 |
18 | Example scenario 2:
19 |
20 | 1. You have a pull request with a branch deployment created by this Action
21 | 2. You create a deployment on your pull request
22 | 3. You go to make a cup of coffee and while doing so, your teammate creates a deployment on their own (different) pull request
23 | 4. You click the merge button on the pull request you just deployed (which is now silently out of date)
24 | 5. The "merge commit workflow strategy" is triggered on merge to your default branch
25 | 6. The workflow compares the latest deployment with the merge commit and finds they are different
26 | 7. The workflow uses logic to deploy the merge commit since it is different than the latest deployment
27 |
28 | This should help explain why this strategy is useful. It helps to save CI time and prevent redundant deployments. If you are not using this strategy, you will end up deploying the merge commit even if it is the same as the latest deployment if you do a deployment every time a pull request is merged (rather common).
29 |
30 | ## Using the Merge Commit Workflow Strategy
31 |
32 | To use the advanced merge commit workflow strategy, you will need to do the following:
33 |
34 | 1. Create a new Actions workflow file in your repository that will be triggered on merge to your default branch
35 | 2. Add a job that calls the branch-deploy Action
36 | 3. Add configuration to the Action telling it to use the custom merge commit workflow strategy
37 |
38 | Below is a sample workflow with plenty of in-line comments to help you along:
39 |
40 | ```yaml
41 | name: deploy
42 | on:
43 | push:
44 | branches:
45 | - main # <-- This is the default branch for your repository
46 |
47 | jobs:
48 | deploy:
49 | if: github.event_name == 'push' # Merge commits will trigger a push event
50 | environment: production # You can configure this to whatever you call your production environment
51 | runs-on: ubuntu-latest
52 | steps:
53 | # Call the branch-deploy Action - name it something else if you want (I did here for clarity)
54 | - name: deployment check
55 | uses: github/branch-deploy@vX.X.X # replace with the latest version of this Action
56 | id: deployment-check # ensure you have an 'id' set so you can reference the output of the Action later on
57 | with:
58 | merge_deploy_mode: "true" # required, tells the Action to use the merge commit workflow strategy
59 | environment: production # optional, defaults to 'production'
60 |
61 | # Now we can conditionally 'gate' our deployment logic based on the output of the Action
62 | # If the Action returns 'true' for the 'continue' output, we can continue with our deployment logic
63 | # Otherwise, all subsequent steps will be skipped
64 |
65 | # Check out the repository
66 | - uses: actions/checkout@v6
67 | if: ${{ steps.deployment-check.outputs.continue == 'true' }} # only run if the Action returned 'true' for the 'continue' output
68 | with:
69 | ref: ${{ steps.deployment-check.outputs.sha }} # checkout the EXACT sha of the default branch for deployment (latest commit on the default branch)
70 |
71 | # Do your deployment here! (However you want to do it)
72 | # This could be deployment logic via SSH, Terraform, AWS, Heroku, etc.
73 | - name: fake regular deploy
74 | if: ${{ steps.deployment-check.outputs.continue == 'true' }} # only run if the Action returned 'true' for the 'continue' output
75 | run: echo "I am doing a fake regular deploy"
76 | ```
77 |
--------------------------------------------------------------------------------
/src/functions/unlock-on-merge.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {unlock} from './unlock.js'
3 | import {LOCK_METADATA} from './lock-metadata.js'
4 | import {checkLockFile} from './check-lock-file.js'
5 | import {checkBranch} from './lock.js'
6 | import {constructValidBranchName} from './valid-branch-name.js'
7 | import {COLORS} from './colors.js'
8 |
9 | // Helper function to automatically find, and release a deployment lock when a pull request is merged
10 | // :param octokit: the authenticated octokit instance
11 | // :param context: the context object
12 | // :param environment_targets: the environment targets to check for unlocking
13 | // :return: true if all locks were released successfully, false otherwise
14 | export async function unlockOnMerge(octokit, context, environment_targets) {
15 | // first, check the context to ensure that the event is a pull request 'closed' event and that the pull request was merged
16 | if (
17 | context?.eventName !== 'pull_request' ||
18 | context?.payload?.action !== 'closed' ||
19 | context?.payload?.pull_request?.merged !== true
20 | ) {
21 | core.warning(
22 | `this workflow can only run in the context of a ${COLORS.highlight}merged${COLORS.reset} pull request`
23 | )
24 | core.info(
25 | `event name: ${context?.eventName}, action: ${context?.payload?.action}, merged: ${context?.payload?.pull_request?.merged}`
26 | )
27 |
28 | // many pull requests in a project will end up being closed without being merged, so we can just log this so its clear
29 | if (context?.payload?.action === 'closed') {
30 | core.info(
31 | `pull request was closed but not merged so this workflow will not run - OK`
32 | )
33 | }
34 |
35 | return false
36 | }
37 |
38 | // loop through all the environment targets and check each one for a lock associated with this merged pull request
39 | var releasedEnvironments = []
40 | for (const environment of environment_targets.split(',')) {
41 | // construct the lock branch name for this environment
42 | var lockBranch = `${constructValidBranchName(environment)}-${LOCK_METADATA.lockBranchSuffix}`
43 |
44 | // Check if the lock branch exists
45 | const branchExists = await checkBranch(octokit, context, lockBranch)
46 |
47 | // if the lock branch does not exist at all, then there is no lock to release
48 | if (!branchExists) {
49 | core.info(
50 | `⏩ no lock branch found for environment ${COLORS.highlight}${environment}${COLORS.reset} - skipping...`
51 | )
52 | continue
53 | }
54 |
55 | // attempt to fetch the lockFile for this branch
56 | var lockFile = await checkLockFile(octokit, context, lockBranch)
57 |
58 | // check to see if the lockFile exists and if it does, check to see if it has a link property
59 | if (lockFile && lockFile?.link) {
60 | // if the lockFile has a link property, find the PR number from the link
61 | var prNumber = lockFile.link.split('/pull/')[1].split('#issuecomment')[0]
62 | core.info(
63 | `🔍 checking lock for PR ${COLORS.info}${prNumber}${COLORS.reset} (env: ${COLORS.highlight}${environment}${COLORS.reset})`
64 | )
65 |
66 | // if the PR number matches the PR number of the merged pull request, then this lock is associated with the merged pull request
67 | if (prNumber === context.payload.pull_request.number.toString()) {
68 | // release the lock
69 | var result = await unlock(
70 | octokit,
71 | context,
72 | null, // reactionId
73 | environment,
74 | true // silent
75 | )
76 |
77 | // if the result is 'removed lock - silent', then the lock was successfully removed - append to the array for later use
78 | if (result === 'removed lock - silent') {
79 | releasedEnvironments.push(environment)
80 | } else {
81 | core.debug(`unlock result for unlock-on-merge: ${result}`)
82 | }
83 |
84 | // log the result and format the output as it will always be a string ending with '- silent'
85 | var resultFmt = result.replace('- silent', '')
86 | core.info(
87 | `🔓 ${resultFmt.trim()} - environment: ${COLORS.highlight}${environment}${COLORS.reset}`
88 | )
89 | } else {
90 | core.info(
91 | `⏩ lock for PR ${COLORS.info}${prNumber}${COLORS.reset} (env: ${COLORS.highlight}${environment}${COLORS.reset}) is not associated with PR ${COLORS.info}${context.payload.pull_request.number}${COLORS.reset} - skipping...`
92 | )
93 | }
94 | } else {
95 | core.info(
96 | `⏩ no lock file found for environment ${COLORS.highlight}${environment}${COLORS.reset} - skipping...`
97 | )
98 | continue
99 | }
100 | }
101 |
102 | // if we get here, all locks had a best effort attempt to be released
103 | core.setOutput('unlocked_environments', releasedEnvironments.join(','))
104 | return true
105 | }
106 |
--------------------------------------------------------------------------------
/src/functions/branch-ruleset-checks.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 | import {API_HEADERS} from './api-headers.js'
4 | import {SUGGESTED_RULESETS} from './suggested-rulesets.js'
5 | import {ERROR} from './templates/error.js'
6 |
7 | export async function branchRulesetChecks(context, octokit, data) {
8 | const branch = data.branch
9 | const useSecurityWarnings = data?.use_security_warnings !== false
10 |
11 | // Exit early if the user has disabled security warnings
12 | if (!useSecurityWarnings) {
13 | return {success: true}
14 | }
15 |
16 | try {
17 | const {data: branchRules} = await octokit.rest.repos.getBranchRules({
18 | ...context.repo,
19 | branch,
20 | headers: API_HEADERS
21 | })
22 |
23 | core.debug(
24 | `branch ${COLORS.highlight}rulesets${COLORS.reset}: ${JSON.stringify(branchRules)}`
25 | )
26 |
27 | const failed_checks = []
28 |
29 | // Leave a warning if no rulesets are defined
30 | if (branchRules.length === 0) {
31 | core.warning(
32 | `🔐 branch ${COLORS.highlight}rulesets${COLORS.reset} are not defined for branch ${COLORS.highlight}${branch}${COLORS.reset}`
33 | )
34 | failed_checks.push('missing_branch_rulesets')
35 | } else {
36 | // Loop through the suggested rulesets and check them against the branch rules
37 | SUGGESTED_RULESETS.forEach(suggestedRule => {
38 | const {type: ruleType, parameters: ruleParameters} = suggestedRule
39 |
40 | const branchRule = branchRules.find(rule => rule.type === ruleType)
41 |
42 | if (!branchRule) {
43 | logMissingRule(branch, ruleType, failed_checks)
44 | } else if (ruleParameters) {
45 | checkRuleParameters(
46 | branch,
47 | ruleType,
48 | ruleParameters,
49 | branchRule,
50 | failed_checks
51 | )
52 | }
53 | })
54 | }
55 |
56 | logWarnings(failed_checks)
57 |
58 | // If there are no failed checks, log a success message
59 | if (failed_checks.length === 0) {
60 | core.info(
61 | `🔐 branch ruleset checks ${COLORS.success}passed${COLORS.reset}`
62 | )
63 | }
64 |
65 | return {success: failed_checks.length === 0, failed_checks}
66 | } catch (error) {
67 | if (
68 | error.status === ERROR.messages.upgrade_or_public.status &&
69 | error.message.includes(ERROR.messages.upgrade_or_public.message)
70 | ) {
71 | core.debug(ERROR.messages.upgrade_or_public.help_text)
72 | return {success: false, failed_checks: ['upgrade_or_public_required']}
73 | } else {
74 | throw error
75 | }
76 | }
77 | }
78 |
79 | function logMissingRule(branch, ruleType, failed_checks) {
80 | core.warning(
81 | `🔐 branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${branch}${COLORS.reset} is missing a rule of type ${COLORS.highlight}${ruleType}${COLORS.reset}`
82 | )
83 | failed_checks.push(`missing_${ruleType}`)
84 | }
85 |
86 | function checkRuleParameters(
87 | branch,
88 | ruleType,
89 | ruleParameters,
90 | branchRule,
91 | failed_checks
92 | ) {
93 | Object.keys(ruleParameters).forEach(key => {
94 | if (branchRule.parameters[key] !== ruleParameters[key]) {
95 | if (key === 'required_approving_review_count') {
96 | handleReviewCountMismatch(branch, ruleType, branchRule, failed_checks)
97 | } else {
98 | logParameterMismatch(branch, ruleType, key, failed_checks)
99 | }
100 | }
101 | })
102 | }
103 |
104 | function handleReviewCountMismatch(
105 | branch,
106 | ruleType,
107 | branchRule,
108 | failed_checks
109 | ) {
110 | if (branchRule.parameters['required_approving_review_count'] === 0) {
111 | core.warning(
112 | `🔐 branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${branch}${COLORS.reset} contains the required_approving_review_count parameter but it is set to 0`
113 | )
114 | failed_checks.push(`mismatch_${ruleType}_required_approving_review_count`)
115 | } else {
116 | core.debug(
117 | `required_approving_review_count is ${branchRule.parameters['required_approving_review_count']} - OK`
118 | )
119 | }
120 | }
121 |
122 | function logParameterMismatch(branch, ruleType, key, failed_checks) {
123 | core.warning(
124 | `🔐 branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${branch}${COLORS.reset} contains a rule of type ${COLORS.highlight}${ruleType}${COLORS.reset} with a parameter ${COLORS.highlight}${key}${COLORS.reset} which does not match the suggested parameter`
125 | )
126 | failed_checks.push(`mismatch_${ruleType}_${key}`)
127 | }
128 |
129 | function logWarnings(failed_checks) {
130 | if (failed_checks.length > 0) {
131 | core.warning(
132 | `😨 the following branch ruleset warnings were detected: ${failed_checks.join(', ')}`
133 | )
134 | core.warning(
135 | `📚 your branch ruleset settings may be insecure - please review the documentation: https://github.com/github/branch-deploy/blob/main/docs/branch-rulesets.md`
136 | )
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/docs/checks.md:
--------------------------------------------------------------------------------
1 | # Checks ✅
2 |
3 | > This feature was originally requested via [#73](https://github.com/github/branch-deploy/issues/73) and further improved in [#321](https://github.com/github/branch-deploy/issues/321) and [#362](https://github.com/github/branch-deploy/issues/362)
4 |
5 | The branch-deploy Action contains a useful input option to help give developers more control over how CI checks are handled during the deployment process. Some teams may have very strict controls over deployments and require **all status checks** to pass before a deployment can start. In this case, all CI checks must pass and that includes required, or non-required checks. Other teams may have a more relaxed approach and only require certain checks to pass before a deployment can start. This is where the `checks` input option comes in handy.
6 |
7 | ## Required CI Checks
8 |
9 | First, let's explain what a "required" CI check is in case you're not familiar. A required CI check is a check that must pass before a pull request can be merged. This is a setting that can be configured in the repository settings under the "Branches" section. This section is shown in the screenshot below:
10 |
11 | 
12 |
13 | > This example came directly from this respository's settings
14 |
15 | So in this particular repository, the following CI checks are required:
16 |
17 | - `test`
18 | - `package-check`
19 | - `lint`
20 | - `actions-config-validation`
21 |
22 | Any other CI checks that run on a pull request are not required and are considered non-required checks.
23 |
24 | ## Using the `checks` Input Option
25 |
26 | This section will contain a few examples of how you can use the `checks` option
27 |
28 | ### Example 1: All CI Checks Must Pass
29 |
30 | This example shows how you can use the `checks` option to require all CI checks to pass before a deployment can start. This is the **default** behavior of the Action if you do not specify the `checks` option.
31 |
32 | ```yaml
33 | - name: branch-deploy
34 | uses: github/branch-deploy@vX.X.X # replace with the latest version of this Action
35 | id: branch-deploy
36 | with:
37 | checks: "all" # all CI checks (required or not) must pass before a deployment can start to any environment
38 | ```
39 |
40 | ### Example 2: Only Required CI Checks Must Pass
41 |
42 | This example shows how you can use the `checks` option to require only the **required** CI checks to pass before a deployment can start. This is useful if you have non-required checks that you don't want to block a deployment.
43 |
44 | ```yaml
45 | - name: branch-deploy
46 | uses: github/branch-deploy@vX.X.X # replace with the latest version of this Action
47 | id: branch-deploy
48 | with:
49 | checks: "required" # only required CI checks must pass before a deployment can start to any environment
50 | ```
51 |
52 | The screenshot below demonstrates how this option works in a real-world scenario. You can see how there are two CI checks defined on the pull request. One is called `test1` which is **required** and **passing**. The other is called `test2` which is **not required** and **failing**. Since the `checks` option is set to `required`, the deployment will start because the required check is passing and is the only status check taken into consideration for a deployment.
53 |
54 | 
55 |
56 | ### Example 3: Only specific CI checks must pass (required or not)
57 |
58 | If you only care about a specific CI check, you can use the `checks` option with a comma-separated list of checks that must pass. For example, if you only care about the `test1` check, you can do the following:
59 |
60 | ```yaml
61 | - uses: github/branch-deploy@vX.X.X # replace with the latest version of this Action
62 | id: branch-deploy
63 | with:
64 | checks: 'test1' # we only care about the check named `test1`
65 | ```
66 |
67 | This will require that a CI check named `test1` must pass before a deployment (to any environment) can start. If the `test1` check is in any state other than **passing** (successful), the deployment will not start. This option will scan all CI checks (required or not) and only the checks that are specified in the `checks` option will be considered are blocking for deployment. If you have 10 failing CI checks, and only one passing check, and that check is the only one specified in the `checks` option, the deployment will start.
68 |
69 | Please note that just because you can _deploy_, doesn't always mean you can merge. You should always review the configuration options of this Action and the branch protection (or ruleset) polices within your repository and ensure that they are configured to meet your team's requirements.
70 |
71 | > If you project has a lot of CI tests, no worries! You could do `test1,test2,` to specify multiple checks that must pass.
72 |
73 | 
74 |
75 | ### Example 4: Only certain CI checks are ignored
76 |
77 | ```yaml
78 | - uses: github/branch-deploy@vX.X.X # replace with the latest version of this Action
79 | id: branch-deploy
80 | with:
81 | ignored_checks: 'test2,test3' # these two checks are ignored and deployment will be allowed even if they are failing (and required)
82 | ```
83 |
84 | 
85 |
--------------------------------------------------------------------------------
/src/functions/admin.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import * as github from '@actions/github'
3 | import githubUsernameRegex from 'github-username-regex-js'
4 | import {retry} from '@octokit/plugin-retry'
5 | import {COLORS} from './colors.js'
6 | import {API_HEADERS} from './api-headers.js'
7 |
8 | // Helper function to check if a user exists in an org team
9 | // :param actor: The user to check
10 | // :param orgTeams: An array of org/team names
11 | // :returns: True if the user is in the org team, false otherwise
12 | async function orgTeamCheck(actor, orgTeams) {
13 | // This pat needs org read permissions if you are using org/teams to define admins
14 | const adminsPat = core.getInput('admins_pat')
15 |
16 | // If no admin_pat is provided, then we cannot check for org team memberships
17 | if (!adminsPat || adminsPat.length === 0 || adminsPat === 'false') {
18 | core.warning(
19 | `🚨 no ${COLORS.highlight}admins_pat${COLORS.reset} provided, skipping admin check for org team membership`
20 | )
21 | return false
22 | }
23 |
24 | // Create a new octokit client with the admins_pat and the retry plugin
25 | const octokit = github.getOctokit(adminsPat, {
26 | additionalPlugins: [retry]
27 | })
28 |
29 | // Loop through all org/team names
30 | for (const orgTeam of orgTeams) {
31 | // Split the org/team name into org and team
32 | var [org, team] = orgTeam.split('/')
33 |
34 | try {
35 | // Make an API call to get the org id
36 | const orgData = await octokit.rest.orgs.get({
37 | org: org,
38 | headers: API_HEADERS
39 | })
40 | const orgId = orgData.data.id
41 |
42 | // Make an API call to get the team id
43 | const teamData = await octokit.rest.teams.getByName({
44 | org: org,
45 | team_slug: team,
46 | headers: API_HEADERS
47 | })
48 | const teamId = teamData.data.id
49 |
50 | // This API call checks if the user exists in the team for the given org
51 | const result = await octokit.request(
52 | `GET /organizations/${orgId}/team/${teamId}/members/${actor}`
53 | )
54 |
55 | // If the status code is a 204, the user is in the team
56 | if (result.status === 204) {
57 | core.debug(`${actor} is in ${orgTeam}`)
58 | return true
59 | // If some other status code occurred, return false and output a warning
60 | } else {
61 | core.warning(`non 204 response from org team check: ${result.status}`)
62 | }
63 | } catch (error) {
64 | core.debug(`orgTeamCheck() error.status: ${error.status}`)
65 | // If any of the API calls returns a 404, the user is not in the team
66 | if (error.status === 404) {
67 | core.debug(`${actor} is not a member of the ${orgTeam} team`)
68 | // If some other error occurred, output a warning
69 | } else {
70 | core.warning(`error checking org team membership: ${error}`)
71 | }
72 | }
73 | }
74 |
75 | // If we get here, the user is not in any of the org teams
76 | return false
77 | }
78 |
79 | // Helper function to check if a user is set as an admin for branch-deployments
80 | // :param context: The GitHub Actions event context
81 | // :returns: true if the user is an admin, false otherwise (Boolean)
82 | export async function isAdmin(context) {
83 | // Get the admins string from the action inputs
84 | const admins = core.getInput('admins')
85 |
86 | core.debug(`raw admins value: ${admins}`)
87 |
88 | // Sanitized the input to remove any whitespace and split into an array
89 | const adminsSanitized = admins
90 | .split(',')
91 | .map(admin => admin.trim().toLowerCase())
92 |
93 | // loop through admins
94 | var handles = []
95 | var orgTeams = []
96 | adminsSanitized.forEach(admin => {
97 | // If the item contains a '/', then it is a org/team
98 | if (admin.includes('/')) {
99 | orgTeams.push(admin)
100 | }
101 | // Otherwise, it is a github handle
102 | else {
103 | // Check if the github handle is valid
104 | if (githubUsernameRegex.test(admin)) {
105 | // Add the handle to the list of handles and remove @ from the start of the handle
106 | handles.push(admin.replace('@', ''))
107 | } else {
108 | core.debug(
109 | `${admin} is not a valid GitHub username... skipping admin check`
110 | )
111 | }
112 | }
113 | })
114 |
115 | const isAdminMsg = `🔮 ${COLORS.highlight}${context.actor}${COLORS.reset} is an ${COLORS.highlight}admin`
116 |
117 | // Check if the user is in the admin handle list
118 | if (handles.includes(context.actor.toLowerCase())) {
119 | core.debug(`${context.actor} is an admin via handle reference`)
120 | core.info(isAdminMsg)
121 | return true
122 | }
123 |
124 | // Check if the user is in the org/team list
125 | if (orgTeams.length > 0) {
126 | const result = await orgTeamCheck(context.actor, orgTeams)
127 | if (result) {
128 | core.debug(`${context.actor} is an admin via org team reference`)
129 | core.info(isAdminMsg)
130 | return true
131 | }
132 | }
133 |
134 | // If we get here, the user is not an admin
135 | core.debug(`${context.actor} is not an admin`)
136 | return false
137 | }
138 |
--------------------------------------------------------------------------------
/__tests__/functions/naked-command-check.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import {nakedCommandCheck} from '../../src/functions/naked-command-check.js'
4 | import {COLORS} from '../../src/functions/colors.js'
5 |
6 | const docs =
7 | 'https://github.com/github/branch-deploy/blob/main/docs/naked-commands.md'
8 | const warningMock = vi.spyOn(core, 'warning')
9 |
10 | var context
11 | var octokit
12 | var triggers
13 | var param_separator
14 |
15 | beforeEach(() => {
16 | vi.clearAllMocks()
17 |
18 | process.env.INPUT_GLOBAL_LOCK_FLAG = '--global'
19 |
20 | triggers = ['.deploy', '.noop', '.lock', '.unlock', '.wcid']
21 | param_separator = '|'
22 |
23 | context = {
24 | repo: {
25 | owner: 'corp',
26 | repo: 'test'
27 | },
28 | issue: {
29 | number: 1
30 | },
31 | payload: {
32 | comment: {
33 | id: '1'
34 | }
35 | }
36 | }
37 |
38 | octokit = {
39 | rest: {
40 | reactions: {
41 | createForIssueComment: vi.fn().mockReturnValueOnce({
42 | data: {}
43 | })
44 | },
45 | issues: {
46 | createComment: vi.fn().mockReturnValueOnce({
47 | data: {}
48 | })
49 | }
50 | }
51 | }
52 | })
53 |
54 | test('checks the command and finds that it is naked', async () => {
55 | const body = '.deploy'
56 | expect(
57 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
58 | ).toBe(true)
59 | expect(warningMock).toHaveBeenCalledWith(
60 | `🩲 naked commands are ${COLORS.warning}not${COLORS.reset} allowed based on your configuration: ${COLORS.highlight}${body}${COLORS.reset}`
61 | )
62 | expect(warningMock).toHaveBeenCalledWith(
63 | `📚 view the documentation around ${COLORS.highlight}naked commands${COLORS.reset} to learn more: ${docs}`
64 | )
65 | })
66 |
67 | test('checks the command and finds that it is naked (noop)', async () => {
68 | const body = '.noop'
69 | expect(
70 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
71 | ).toBe(true)
72 | })
73 |
74 | test('checks the command and finds that it is naked (lock)', async () => {
75 | const body = '.lock'
76 | expect(
77 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
78 | ).toBe(true)
79 | })
80 |
81 | test('checks the command and finds that it is naked (lock) with a reason', async () => {
82 | const body = '.lock --reason I am testing a big change'
83 | expect(
84 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
85 | ).toBe(true)
86 | })
87 |
88 | test('checks the command and finds that it is NOT naked (lock) with a reason', async () => {
89 | const body = '.lock production --reason I am testing a big change'
90 | expect(
91 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
92 | ).toBe(false)
93 | })
94 |
95 | test('checks the command and finds that it is naked (unlock)', async () => {
96 | const body = '.unlock'
97 | expect(
98 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
99 | ).toBe(true)
100 | })
101 |
102 | test('checks the command and finds that it is NOT naked because it is global', async () => {
103 | const body = '.unlock --global'
104 | expect(
105 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
106 | ).toBe(false)
107 | })
108 |
109 | test('checks the command and finds that it is naked (alias)', async () => {
110 | const body = '.wcid'
111 | expect(
112 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
113 | ).toBe(true)
114 | })
115 |
116 | test('checks the command and finds that it is naked (whitespaces)', async () => {
117 | const body = '.deploy '
118 | expect(
119 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
120 | ).toBe(true)
121 | })
122 |
123 | test('checks the command and finds that it is not naked', async () => {
124 | const body = '.deploy production'
125 | expect(
126 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
127 | ).toBe(false)
128 | })
129 |
130 | test('checks the command and finds that it is not naked with "to"', async () => {
131 | const body = '.deploy to production'
132 | expect(
133 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
134 | ).toBe(false)
135 | })
136 |
137 | test('checks the command and finds that it is not naked with an alias lock command', async () => {
138 | const body = '.wcid staging '
139 | expect(
140 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
141 | ).toBe(false)
142 | })
143 |
144 | test('checks the command and finds that it is naked with params', async () => {
145 | const body = '.deploy | cpus=1 memory=2g,3g env=production'
146 | expect(
147 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
148 | ).toBe(true)
149 | })
150 |
151 | test('checks the command and finds that it is naked with params and extra whitespace', async () => {
152 | const body = '.deploy | cpus=1 memory=2g,3g env=production'
153 | expect(
154 | await nakedCommandCheck(body, param_separator, triggers, octokit, context)
155 | ).toBe(true)
156 | })
157 |
--------------------------------------------------------------------------------
/src/functions/identical-commit-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {COLORS} from './colors.js'
3 | import {API_HEADERS} from './api-headers.js'
4 |
5 | // Helper function to check if the current deployment's ref is identical to the merge commit
6 | // :param octokit: the authenticated octokit instance
7 | // :param context: the context object
8 | // :param environment: the environment to check
9 | // :return: true if the current deployment's ref is identical to the merge commit, false otherwise
10 | export async function identicalCommitCheck(octokit, context, environment) {
11 | // get the owner and the repo from the context
12 | const {owner, repo} = context.repo
13 |
14 | // find the default branch
15 | const {data: repoData} = await octokit.rest.repos.get({
16 | owner,
17 | repo,
18 | headers: API_HEADERS
19 | })
20 | const defaultBranchName = repoData.default_branch
21 | core.debug(`default branch name: ${defaultBranchName}`)
22 |
23 | // get the latest commit on the default branch of the repo
24 | const {data: defaultBranchData} = await octokit.rest.repos.getBranch({
25 | owner,
26 | repo,
27 | branch: defaultBranchName,
28 | headers: API_HEADERS
29 | })
30 | const defaultBranchTreeSha = defaultBranchData.commit.commit.tree.sha
31 | core.debug(`default branch tree sha: ${defaultBranchTreeSha}`)
32 |
33 | const latestDefaultBranchCommitSha = defaultBranchData.commit.sha
34 | core.info(
35 | `📍 latest commit sha on ${COLORS.highlight}${defaultBranchName}${COLORS.reset}: ${COLORS.info}${latestDefaultBranchCommitSha}${COLORS.reset}`
36 | )
37 |
38 | // find the latest deployment with the payload type of branch-deploy
39 | const {data: deploymentsData} = await octokit.rest.repos.listDeployments({
40 | owner,
41 | repo,
42 | environment,
43 | sort: 'created_at',
44 | direction: 'desc',
45 | per_page: 100,
46 | headers: API_HEADERS
47 | })
48 | // loop through all deployments and look for the latest deployment with the payload type of branch-deploy
49 | var latestDeploymentTreeSha
50 | var createdAt
51 | var deploymentId
52 | for (const deployment of deploymentsData) {
53 | if (deployment.payload.type === 'branch-deploy') {
54 | latestDeploymentTreeSha = deployment.sha
55 | createdAt = deployment.created_at
56 | deploymentId = deployment.id
57 |
58 | // get the tree sha of the latest deployment
59 | const commitData = await octokit.rest.repos.getCommit({
60 | owner,
61 | repo,
62 | ref: latestDeploymentTreeSha,
63 | headers: API_HEADERS
64 | })
65 | latestDeploymentTreeSha = commitData.data.commit.tree.sha
66 | break
67 | } else {
68 | core.debug(
69 | `deployment.payload.type is not of the branch-deploy type: ${deployment.payload.type} - skipping...`
70 | )
71 | continue
72 | }
73 | }
74 |
75 | core.info(
76 | `🌲 latest default ${COLORS.info}branch${COLORS.reset} tree sha: ${COLORS.info}${defaultBranchTreeSha}${COLORS.reset}`
77 | )
78 | core.info(
79 | `🌲 latest ${COLORS.info}deployment${COLORS.reset} tree sha: ${COLORS.info}${latestDeploymentTreeSha}${COLORS.reset}`
80 | )
81 | core.debug('💡 latest deployment with payload type of "branch-deploy"')
82 | core.debug(`🕛 latest deployment created at: ${createdAt}`)
83 | core.debug(`🧮 latest deployment id: ${deploymentId}`)
84 |
85 | // if the latest deployment sha is identical to the latest commit on the default branch then return true
86 | const result = latestDeploymentTreeSha === defaultBranchTreeSha
87 |
88 | if (result) {
89 | core.info(
90 | `🟰 the latest deployment tree sha is ${COLORS.highlight}equal${COLORS.reset} to the default branch tree sha`
91 | )
92 | core.info(
93 | `🌲 identical commit trees will ${COLORS.highlight}not${COLORS.reset} be re-deployed based on your configuration`
94 | )
95 | core.info(
96 | `✅ deployments for the ${COLORS.highlight}${environment}${COLORS.reset} environment are ${COLORS.success}up to date${COLORS.reset}`
97 | )
98 | core.setOutput('continue', 'false')
99 | core.setOutput('environment', environment)
100 | } else {
101 | // if the latest deployment sha is not identical to the latest commit on the default branch then we need to create a new deployment
102 | // this deployment should use the latest commit on the default branch to ensure that the repository is deployed at its latest state
103 | // a scenario where this might occur is if the default branch is force-pushed and you need to start a new deployment from the latest commit on the default branch
104 | core.info(
105 | `💡 the latest deployment tree sha is ${COLORS.highlight}not${COLORS.reset} equal to the default branch tree sha`
106 | )
107 | core.info(
108 | `🧑🚀 commit sha to deploy: ${COLORS.highlight}${latestDefaultBranchCommitSha}${COLORS.reset}`
109 | )
110 | core.info(
111 | `🚀 a ${COLORS.success}new deployment${COLORS.reset} will be created based on your configuration`
112 | )
113 | core.setOutput('continue', 'true')
114 | core.setOutput('environment', environment)
115 | core.setOutput('sha', latestDefaultBranchCommitSha)
116 | core.saveState('sha', latestDefaultBranchCommitSha)
117 | }
118 |
119 | return result
120 | }
121 |
--------------------------------------------------------------------------------
/__tests__/functions/outdated-check.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {vi, expect, test, beforeEach} from 'vitest'
3 | import {isOutdated} from '../../src/functions/outdated-check.js'
4 | import {COLORS} from '../../src/functions/colors.js'
5 |
6 | const debugMock = vi.spyOn(core, 'debug')
7 | const warningMock = vi.spyOn(core, 'warning')
8 |
9 | var context
10 | var octokit
11 | var data
12 |
13 | beforeEach(() => {
14 | vi.clearAllMocks()
15 |
16 | data = {
17 | outdated_mode: 'strict',
18 | mergeStateStatus: 'CLEAN',
19 | stableBaseBranch: {
20 | data: {
21 | commit: {sha: 'beefdead'},
22 | name: 'stable-branch'
23 | },
24 | status: 200
25 | },
26 | baseBranch: {
27 | data: {
28 | commit: {sha: 'deadbeef'},
29 | name: 'test-branch'
30 | },
31 | status: 200
32 | },
33 | pr: {
34 | data: {
35 | head: {
36 | ref: 'test-ref',
37 | sha: 'abc123'
38 | },
39 | base: {
40 | ref: 'base-ref'
41 | }
42 | },
43 | status: 200
44 | }
45 | }
46 |
47 | context = {
48 | repo: {
49 | owner: 'corp',
50 | repo: 'test'
51 | }
52 | }
53 |
54 | octokit = {
55 | rest: {
56 | repos: {
57 | compareCommits: vi
58 | .fn()
59 | .mockReturnValue({data: {behind_by: 0}, status: 200})
60 | }
61 | }
62 | }
63 | })
64 |
65 | test('checks if the branch is out-of-date via commit comparison and finds that it is not', async () => {
66 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
67 | branch: 'test-branch|stable-branch',
68 | outdated: false
69 | })
70 | })
71 |
72 | test('checks if the branch is out-of-date via commit comparison and finds that it is not, when the stable branch and base branch are the same (i.e a PR to main)', async () => {
73 | data.baseBranch = data.stableBaseBranch
74 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
75 | branch: 'stable-branch|stable-branch',
76 | outdated: false
77 | })
78 | })
79 |
80 | test('checks if the branch is out-of-date via commit comparison and finds that it is, when the stable branch and base branch are the same (i.e a PR to main)', async () => {
81 | data.baseBranch = data.stableBaseBranch
82 |
83 | octokit.rest.repos.compareCommits = vi
84 | .fn()
85 | .mockReturnValue({data: {behind_by: 1}, status: 200})
86 |
87 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
88 | branch: 'stable-branch',
89 | outdated: true
90 | })
91 | })
92 |
93 | test('checks if the branch is out-of-date via commit comparison and finds that it is not using outdated_mode pr_base', async () => {
94 | data.outdated_mode = 'pr_base'
95 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
96 | branch: 'test-branch',
97 | outdated: false
98 | })
99 | expect(debugMock).toHaveBeenCalledWith(
100 | 'checking isOutdated with pr_base mode'
101 | )
102 | })
103 |
104 | test('checks if the branch is out-of-date via commit comparison and finds that it is not using outdated_mode default_branch', async () => {
105 | data.outdated_mode = 'default_branch'
106 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
107 | branch: 'stable-branch',
108 | outdated: false
109 | })
110 | expect(debugMock).toHaveBeenCalledWith(
111 | 'checking isOutdated with default_branch mode'
112 | )
113 | })
114 |
115 | test('checks if the branch is out-of-date via commit comparison and finds that it is', async () => {
116 | octokit.rest.repos.compareCommits = vi
117 | .fn()
118 | .mockReturnValue({data: {behind_by: 1}, status: 200})
119 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
120 | branch: 'test-branch',
121 | outdated: true
122 | })
123 | expect(debugMock).toHaveBeenCalledWith('checking isOutdated with strict mode')
124 | expect(warningMock).toHaveBeenCalledWith(
125 | `The PR branch is behind the base branch by ${COLORS.highlight}1 commit${COLORS.reset}`
126 | )
127 | })
128 |
129 | test('checks if the branch is out-of-date via commit comparison and finds that it is by many commits', async () => {
130 | octokit.rest.repos.compareCommits = vi
131 | .fn()
132 | .mockReturnValue({data: {behind_by: 45}, status: 200})
133 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
134 | branch: 'test-branch',
135 | outdated: true
136 | })
137 | expect(debugMock).toHaveBeenCalledWith('checking isOutdated with strict mode')
138 | expect(warningMock).toHaveBeenCalledWith(
139 | `The PR branch is behind the base branch by ${COLORS.highlight}45 commits${COLORS.reset}`
140 | )
141 | })
142 |
143 | test('checks if the branch is out-of-date via commit comparison and finds that it is only behind the stable branch', async () => {
144 | octokit.rest.repos.compareCommits = vi
145 | .fn()
146 | .mockImplementationOnce(() =>
147 | Promise.resolve({data: {behind_by: 0}, status: 200})
148 | )
149 | .mockImplementationOnce(() =>
150 | Promise.resolve({data: {behind_by: 1}, status: 200})
151 | )
152 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
153 | branch: 'stable-branch',
154 | outdated: true
155 | })
156 | expect(debugMock).toHaveBeenCalledWith('checking isOutdated with strict mode')
157 | })
158 |
159 | test('checks the mergeStateStatus and finds that it is BEHIND', async () => {
160 | data.mergeStateStatus = 'BEHIND'
161 | expect(await isOutdated(context, octokit, data)).toStrictEqual({
162 | branch: 'test-branch',
163 | outdated: true
164 | })
165 | expect(debugMock).toHaveBeenCalledWith(
166 | 'mergeStateStatus is BEHIND - exiting isOutdated logic early'
167 | )
168 | })
169 |
--------------------------------------------------------------------------------
/docs/deploying-commit-SHAs.md:
--------------------------------------------------------------------------------
1 | # Deploying Commit SHAs
2 |
3 | ## TL;DR
4 |
5 | Instead of this:
6 |
7 | ```yaml
8 | - name: branch-deploy
9 | id: branch-deploy
10 | uses: github/branch-deploy@vX.X.X
11 |
12 | - name: checkout
13 | if: steps.branch-deploy.outputs.continue == 'true'
14 | uses: actions/checkout@v6
15 | with:
16 | ref: ${{ steps.branch-deploy.outputs.ref }} # <-- This is the branch name, can be risky
17 | ```
18 |
19 | Do this:
20 |
21 | ```yaml
22 | - name: branch-deploy
23 | id: branch-deploy
24 | uses: github/branch-deploy@vX.X.X
25 |
26 | - name: checkout
27 | if: steps.branch-deploy.outputs.continue == 'true'
28 | uses: actions/checkout@v6
29 | with:
30 | ref: ${{ steps.branch-deploy.outputs.sha }} # <-- uses an exact commit SHA - safe!
31 | ```
32 |
33 | This ensures you are deploying the __exact__ commit SHA that branch-deploy has determined is safe to deploy. This is a best practice for security, reliability, and safety during deployments.
34 |
35 | Don't worry, this is still a _branch deployment_, you are just telling your deployment process to use the __exact commit SHA__ that the branch points to rather than the branch name itself which is mutable.
36 |
37 | ## Introduction
38 |
39 | Deploying commit SHAs (Secure Hash Algorithms) is a best practice in software development and deployment processes. This document explains the importance of deploying commit SHAs, focusing on aspects of security, reliability, and safety. It also provides an overview of how commit SHAs work under the hood in Git and how this contributes to the overall safety of the deployment process.
40 |
41 | ## Importance of Deploying Commit SHAs
42 |
43 | ### Security
44 |
45 | 1. Immutable References: Commit SHAs are immutable references to specific states of the codebase. Once a commit is created, its SHA cannot be changed. This ensures that the exact code being deployed is known and cannot be altered without changing the SHA.
46 | 2. Verification: Using commit SHAs allows for the verification of the code being deployed. Security tools can check the integrity of the code by comparing the SHA of the deployed code with the expected SHA. Commits can be signed with GPG keys to further enhance security.
47 | 3. Auditability: Deploying specific commit SHAs provides a clear audit trail. It is easy to track which code was deployed and when, making it easier to investigate and resolve security incidents.
48 |
49 | ### Reliability
50 |
51 | 1. Consistency: Deploying commit SHAs ensures that the same code is deployed across different environments (e.g., staging, production). This consistency reduces the risk of discrepancies and bugs that may arise from deploying different versions of the code.
52 | 2. Reproducibility: With commit SHAs, deployments are reproducible. If an issue arises, it is possible to redeploy the exact same code by referencing the same SHA, ensuring that the environment is identical to the previous deployment.
53 | 3. Rollback: In case of a failure, rolling back to a previous commit SHA is straightforward. This allows for quick recovery and minimizes downtime. In the context of this project, it is best to deploy the `main` (stable) branch during rollbacks. However, this project does support deploying specific commit SHAs through the [`allow_sha_deployments`](./sha-deployments.md) input option.
54 |
55 | ### Safety
56 |
57 | 1. Atomic Changes: Each commit SHA represents an atomic change to the codebase. Deploying commit SHAs ensures that all changes in a commit are deployed together, reducing the risk of partial deployments that can lead to inconsistencies.
58 | 2. Isolation: Commit SHAs isolate changes, making it easier to identify and isolate issues. If a deployment fails, it is easier to pinpoint the problematic commit and address the issue. For example, if a specific commit introduces a bug, rolling back to the previous commit SHA can resolve the issue.
59 | 3. Predictability: Deploying commit SHAs provides predictability in the deployment process. Knowing exactly what code is being deployed reduces uncertainty and increases confidence in the deployment process.
60 |
61 | ## How Commit SHAs Work in Git
62 |
63 | ### Under the Hood
64 |
65 | 1. SHA-1 Hashing: Git uses the SHA-1 hashing algorithm to generate a unique identifier (SHA) for each commit. This SHA is a 40-character hexadecimal string that uniquely represents the commit.
66 | 2. Content-Based: The SHA is generated based on the content of the commit, including the changes made, the commit message, the author, and the timestamp. This ensures that even a small change in the commit will result in a different SHA.
67 | 3. Immutable: Once a commit is created, its SHA cannot be changed. This immutability ensures that the commit reference is stable and reliable.
68 |
69 | ## How Commits Compare to Branches or Tags
70 |
71 | Branches and tags can be moved or updated to point to different commits. This mutability can lead to inconsistencies and unexpected changes in the deployed code. For this reason, deploying commit SHAs is preferred over deploying branches or tags. Now this might be somewhat confusing as the name of this project is `branch-deploy`. This is because at a high-level we are indeed deploying _branches_ but in reality, we are deploying the exact _commit_ that the branch points to. This is often the latest commit on the branch but it does not have to be based on the input options provided.
72 |
73 | ## Conclusion
74 |
75 | Deploying commit SHAs is a best practice that enhances the security, reliability, and safety of the deployment process. By leveraging the immutable and content-based nature of commit SHAs, organizations can ensure that their deployments are consistent, reproducible, and traceable. Understanding how commit SHAs work under the hood in Git further underscores their importance in maintaining the integrity and stability of the codebase during deployments.
76 |
77 | This Action will take care of all the heavy lifting for you under the hood when it comes to commits. It will set an output named `sha` on __every single deployment__ that will point to the exact commit SHA that you should utilize for your deployment process.
78 |
--------------------------------------------------------------------------------
/src/functions/inputs.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {stringToArray} from '../functions/string-to-array.js'
3 |
4 | // Helper function to validate the input values
5 | // :param inputName: The name of the input being validated (string)
6 | // :param inputValue: The input value to validate (string)
7 | // :param validValues: An array of valid values for the input (array)
8 | function validateInput(inputName, inputValue, validValues) {
9 | if (!validValues.includes(inputValue)) {
10 | throw new Error(
11 | `Invalid value for '${inputName}': ${inputValue}. Must be one of: ${validValues.join(
12 | ', '
13 | )}`
14 | )
15 | }
16 | }
17 |
18 | // Helper function to parse and validate integer inputs
19 | // :param inputName: The name of the input being parsed (string)
20 | // :returns: The parsed integer value
21 | function getIntInput(inputName) {
22 | const value = parseInt(core.getInput(inputName), 10)
23 | if (isNaN(value)) {
24 | throw new Error(`Invalid value for ${inputName}: must be an integer`)
25 | }
26 | return value
27 | }
28 |
29 | // Helper function to get all the inputs for the Action
30 | // :returns: An object containing all the inputs
31 | export function getInputs() {
32 | var environment = core.getInput('environment', {required: true})
33 | const trigger = core.getInput('trigger', {required: true})
34 | const reaction = core.getInput('reaction')
35 | const stable_branch = core.getInput('stable_branch')
36 | const noop_trigger = core.getInput('noop_trigger')
37 | const lock_trigger = core.getInput('lock_trigger')
38 | const production_environments = stringToArray(
39 | core.getInput('production_environments')
40 | )
41 | const environment_targets = core.getInput('environment_targets')
42 | const draft_permitted_targets = core.getInput('draft_permitted_targets')
43 | const unlock_trigger = core.getInput('unlock_trigger')
44 | const help_trigger = core.getInput('help_trigger')
45 | const lock_info_alias = core.getInput('lock_info_alias')
46 | const global_lock_flag = core.getInput('global_lock_flag')
47 | const update_branch = core.getInput('update_branch')
48 | const outdated_mode = core.getInput('outdated_mode')
49 | const required_contexts = core.getInput('required_contexts')
50 | const allowForks = core.getBooleanInput('allow_forks')
51 | const skipCi = core.getInput('skip_ci')
52 | var checks = core.getInput('checks')
53 | const skipReviews = core.getInput('skip_reviews')
54 | const mergeDeployMode = core.getBooleanInput('merge_deploy_mode')
55 | const unlockOnMergeMode = core.getBooleanInput('unlock_on_merge_mode')
56 | const admins = core.getInput('admins')
57 | const environment_urls = core.getInput('environment_urls')
58 | const param_separator = core.getInput('param_separator')
59 | const permissions = stringToArray(core.getInput('permissions'))
60 | const sticky_locks = core.getBooleanInput('sticky_locks')
61 | const sticky_locks_for_noop = core.getBooleanInput('sticky_locks_for_noop')
62 | const allow_sha_deployments = core.getBooleanInput('allow_sha_deployments')
63 | const disable_naked_commands = core.getBooleanInput('disable_naked_commands')
64 | const enforced_deployment_order = stringToArray(
65 | core.getInput('enforced_deployment_order')
66 | )
67 | const commit_verification = core.getBooleanInput('commit_verification')
68 | const ignored_checks = stringToArray(core.getInput('ignored_checks'))
69 | const use_security_warnings = core.getBooleanInput('use_security_warnings')
70 | const allow_non_default_target_branch_deployments = core.getBooleanInput(
71 | 'allow_non_default_target_branch_deployments'
72 | )
73 | const deployment_confirmation = core.getBooleanInput(
74 | 'deployment_confirmation'
75 | )
76 | const deployment_confirmation_timeout = getIntInput(
77 | 'deployment_confirmation_timeout'
78 | )
79 |
80 | // validate inputs
81 | validateInput('update_branch', update_branch, ['disabled', 'warn', 'force'])
82 | validateInput('outdated_mode', outdated_mode, [
83 | 'pr_base',
84 | 'default_branch',
85 | 'strict'
86 | ])
87 |
88 | if (checks === 'all' || checks === 'required') {
89 | validateInput('checks', checks, ['all', 'required'])
90 | } else {
91 | checks = stringToArray(checks)
92 | }
93 |
94 | // rollup all the inputs into a single object
95 | return {
96 | trigger: trigger,
97 | reaction: reaction,
98 | environment: environment,
99 | stable_branch: stable_branch,
100 | noop_trigger: noop_trigger,
101 | lock_trigger: lock_trigger,
102 | production_environments: production_environments,
103 | environment_targets: environment_targets,
104 | unlock_trigger: unlock_trigger,
105 | global_lock_flag: global_lock_flag,
106 | help_trigger: help_trigger,
107 | lock_info_alias: lock_info_alias,
108 | update_branch: update_branch,
109 | outdated_mode: outdated_mode,
110 | required_contexts: required_contexts,
111 | allowForks: allowForks,
112 | skipCi: skipCi,
113 | checks: checks,
114 | skipReviews: skipReviews,
115 | draft_permitted_targets,
116 | admins: admins,
117 | permissions: permissions,
118 | allow_sha_deployments: allow_sha_deployments,
119 | disable_naked_commands: disable_naked_commands,
120 | mergeDeployMode: mergeDeployMode,
121 | unlockOnMergeMode: unlockOnMergeMode,
122 | environment_urls: environment_urls,
123 | param_separator: param_separator,
124 | sticky_locks: sticky_locks,
125 | sticky_locks_for_noop: sticky_locks_for_noop,
126 | enforced_deployment_order: enforced_deployment_order,
127 | commit_verification: commit_verification,
128 | ignored_checks: ignored_checks,
129 | deployment_confirmation: deployment_confirmation,
130 | deployment_confirmation_timeout: deployment_confirmation_timeout,
131 | use_security_warnings: use_security_warnings,
132 | allow_non_default_target_branch_deployments:
133 | allow_non_default_target_branch_deployments
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/docs/custom-deployment-messages.md:
--------------------------------------------------------------------------------
1 | # Custom Deployment Messages ✏️
2 |
3 | > This is useful to display to the user the status of your deployment. For example, you could display the results of a `terraform apply` in the deployment comment
4 |
5 | There are two ways to add custom deployment messages to your PRs. You can either use a custom markdown file (suggested) or use the GitHub Actions environment to dynamically pass data to the post run workflow which will be rendered as markdown into a comment on your pull request.
6 |
7 | ## Custom Markdown File (suggested)
8 |
9 | > This option is highly recommended over the latter "GitHub Actions Environment" option. This is because GitHub Actions has some limitations on the size of data that can be passed around in environment variables. This becomes an issue if your deployment message is large (contains many changes, think Terraform plan/apply) as your deployment will crash with an `Argument list too long` error.
10 |
11 | To use a custom markdown file as your post deployment message, simply use the [`deploy_message_path`](https://github.com/github/branch-deploy/blob/37b50ea86202af7b5505b62bf3eb326da0614b60/action.yml#L124-L127) input option to point to a markdown file in your repository that you wish to render into the comment on your pull request. You don't even need to set this value if you want to just use the [default file path](https://github.com/github/branch-deploy/blob/37b50ea86202af7b5505b62bf3eb326da0614b60/action.yml#L127) of `".github/deployment_message.md"` and place your markdown file there.
12 |
13 | If a markdown file exists at the designated path, it will be used and rendered with [nunjucks](http://mozilla.github.io/nunjucks/) which is a [Jinja](https://jinja.palletsprojects.com/en/3.1.x/) like template engine for NodeJS. Since we are using nunjucks and are leveraging template rendering, a few variables are available for you to use in your markdown file (should you choose to use them):
14 |
15 | - `environment` - The name of the environment (String)
16 | - `environment_url` - The URL of the environment (String) {Optional}
17 | - `status` - The status of the deployment (String) - `success`, `failure`, or `unknown`
18 | - `noop` - Whether or not the deployment is a noop (Boolean)
19 | - `ref` - The ref of the deployment (String)
20 | - `sha` - The sha of the deployment (String)
21 | - `actor` - The GitHub username of the actor who triggered the deployment (String)
22 | - `approved_reviews_count` - The number of approved reviews on the pull request at the time of deployment (String of a number)
23 | - `review_decision` - The review status of the pull request (String or null) - Ex: `APPROVED`, `REVIEW_REQUIRED`, `CHANGES_REQUESTED`, `null` etc.
24 | - `deployment_id` - The ID of the deployment (Int or null in the case of `.noop` deployments)
25 | - `fork` - Whether or not the repository is a fork (Boolean)
26 | - `params` - The raw string of deployment parameters (String)
27 | - `parsed_params` - A string representation of the parsed deployment parameters (String)
28 | - `deployment_end_time` - The time the deployment ended - this value is not _exact_ but it is very close (String) [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) UTC format
29 | - `logs` - The URL to the logs of the deployment (String)
30 | - `commit_verified` - Whether or not the commit was verified (Boolean)
31 | - `total_seconds` - The total number of seconds the deployment took to complete (String of a number)
32 |
33 | If you wish to see a live example of how this works, and how to use the variables, you can check out this [example](https://github.com/github/branch-deploy/blob/691e5df06b952d1f22c2fee49f97e33a8a8c64db/__tests__/templates/test_deployment_message.md) which is used in this repo's unit tests and is self-documenting.
34 |
35 | Here is an example of what the final product could look like:
36 |
37 | 
38 |
39 | > To learn more about these changes you can view the pull request that implemented them [here](https://github.com/github/branch-deploy/pull/174)
40 |
41 | ## GitHub Actions Environment (not suggested)
42 |
43 | > This option is not suggested as it comes with inherent limitations. See the "Custom Markdown File" section above for more information. It is highly recommended to use the custom markdown file option instead. However, if you are unable to use the custom markdown file option, this is an alternative
44 |
45 | You can use the GitHub Actions environment to export custom deployment messages from your workflow to be referenced in the post run workflow for the `branch-deploy` Action that comments results back to your PR
46 |
47 | Simply set the environment variable `DEPLOY_MESSAGE` to the message you want to be displayed in the post run workflow
48 |
49 | Bash Example:
50 |
51 | ```bash
52 | echo "DEPLOY_MESSAGE=" >> $GITHUB_ENV
53 | ```
54 |
55 | Actions Workflow Example:
56 |
57 | ```yaml
58 | # Do some fake "noop" deployment logic here
59 | - name: fake noop deploy
60 | if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' }}
61 | run: |
62 | echo "DEPLOY_MESSAGE=I would have **updated** 1 server" >> $GITHUB_ENV
63 | echo "I am doing a fake noop deploy"
64 | ```
65 |
66 | ## Additional Custom Message Examples 📚
67 |
68 | ### Adding newlines to your message
69 |
70 | ```bash
71 | echo "DEPLOY_MESSAGE=NOOP Result:\nI would have **updated** 1 server" >> $GITHUB_ENV
72 | ```
73 |
74 | ### Multi-line strings ([reference](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-2))
75 |
76 | ```bash
77 | echo 'DEPLOY_MESSAGE<> $GITHUB_ENV
78 | echo "$SOME_MULTI_LINE_STRING_HERE" >> $GITHUB_ENV
79 | echo 'EOF' >> $GITHUB_ENV
80 | ```
81 |
82 | > Where `$SOME_MULTI_LINE_STRING_HERE` is a bash variable containing a multi-line string
83 |
84 | ### Adding a code block to your message
85 |
86 | ```bash
87 | echo "DEPLOY_MESSAGE=\`\`\`yaml\nname: value\n\`\`\`" >> $GITHUB_ENV
88 | ```
89 |
90 | ## How does this work? 🤔
91 |
92 | To add custom messages to our final deployment message we need to use the GitHub Actions environment. This is so that we can dynamically pass data into the post action workflow that leaves a comment on our PR. The post action workflow will look to see if this environment variable is set (`DEPLOY_MESSAGE`). If the variable is set, it adds to to the PR comment. Otherwise, it will use a simple comment body that doesn't include the custom message.
93 |
--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
1 | # Branch Deploy Usage Guide 📚
2 |
3 | This document is a quick guide / cheatsheet for using the `branch-deploy` Action
4 |
5 | > This guide assumes default configuration options
6 |
7 | ## Help 🗨️
8 |
9 | To view your available commands, environment targets, and how your workflow is specifically configured, you can run the following command:
10 |
11 | `.help`
12 |
13 | ## Deployment 🚀
14 |
15 | Deployments respect your repository's branch protection settings. You can trigger either a regular or noop deployment:
16 |
17 | - `.deploy` - Triggers a regular deployment using the default environment (think "Terraform apply" for example)
18 | - `.noop` - Triggers a noop deployment (think "Terraform plan" for example)
19 | - `.deploy ` - Triggers a deployment for the specified environment
20 | - `.noop ` - Triggers a noop deployment for the specified environment
21 | - `.deploy ` - Trigger a rollback deploy to your stable branch (main, master, etc)
22 | - `.noop ` - Trigger a rollback noop to your stable branch (main, master, etc)
23 |
24 | > [!NOTE]
25 | > `.noop` does not require a PR approval or review in order to be executed. It is intended to be run before an approval or PR review is completed in most use cases.
26 |
27 | ## Deployment Locks 🔒
28 |
29 | If you need to lock deployments so that only you can trigger them, you can use the following set of commands:
30 |
31 | - `.lock` - Locks deployments (sticky) so that only you can trigger them - uses the default environment (usually production)
32 | - `.lock --reason ` - Lock deployments with a reason (sticky) - uses the default environment (usually production)
33 | - `.unlock` - Removes the current deployment lock (if one exists) - uses the default environment (usually production)
34 | - `.lock --info` - Displays info about the current deployment lock if one exists - uses the default environment (usually production)
35 | - `.wcid` - An alias for `.lock --info`, it means "where can I deploy" - uses the default environment (usually production)
36 | - `.lock ` - Locks deployments (sticky) so that only you can trigger them - uses the specified environment
37 | - `.lock --reason ` - Lock deployments with a reason (sticky) - uses the specified environment
38 | - `.lock --info` - Displays info about the current deployment lock if one exists - uses the specified environment
39 | - `.unlock ` - Removes the current deployment lock (if one exists) - uses the specified environment
40 | - `.lock --global` - Locks deployments globally (sticky) so that only you can trigger them - blocks all environments
41 | - `.lock --global --reason ` - Lock deployments globally with a reason (sticky) - blocks all environments
42 | - `.unlock --global` - Removes the current global deployment lock (if one exists)
43 | - `.lock --global --info` - Displays info about the current global deployment lock if one exists
44 |
45 | > Note: A deployment lock blocks deploys for all environments. **sticky** locks will also persist until someone removes them with `.unlock`
46 |
47 | It should be noted that anytime you use a `.lock`, `.unlock`, or `.lock --details` command without an environment, it will use the default environment target. This is usually `production` and can be configured in your branch-deploy workflow definition.
48 |
49 | ## Deployment Rollbacks 🔙
50 |
51 | If something goes wrong and you need to redeploy the main/master/base branch of your repository, you can use the following set of commands:
52 |
53 | - `.deploy main` - Rolls back to the `main` branch in production (or the defined default environment)
54 | - `.deploy main to ` - Rolls back to the `main` branch in the specified environment
55 | - `.noop main` - Rolls back to the `main` branch in production as a noop deploy
56 | - `.noop main to ` - Rolls back to the `main` branch in the specified environment as a noop deploy
57 |
58 | > Note: The `stable_branch` option can be configured in your branch-deploy workflow definition. By default it is the `main` branch but it can be changed to `master` or any other branch name.
59 |
60 | ## Environment Targets 🏝️
61 |
62 | Environment targets are used to target specific environments for deployments. These are specifically defined in the Actions workflow and could be anything you want. Common examples are `production`, `staging`, `development`, etc.
63 |
64 | To view what environments are available in your workflow, you can run the `.help` command.
65 |
66 | `.deploy` will always use the default environment target unless you specify one. If you are ever unsure what environment to use, please contact your team member who setup the workflow.
67 |
68 | > Note: You can learn more about environment targets [here](https://github.com/github/branch-deploy#environment-targets)
69 |
70 | ## Deployment Permissions 🔑
71 |
72 | In order to run any branch deployment commands, you need the following permissions:
73 |
74 | - `write` or `admin` permissions to the repository
75 | - You must either be the owner of the current deployment lock, or there must be no deployment lock
76 |
77 | ## Enforced Deployment Order 🚦
78 |
79 | If you have enabled the `enforced_deployment_order` feature, it is best to refer to the dedicated [documentation](./enforced-deployment-order.md) for more information.
80 |
81 | ## Example Workflow 📑
82 |
83 | An example workflow for using this Action might look like this:
84 |
85 | > All commands assume the default environment target of `production`
86 |
87 | 1. A user creates an awesome new feature for their website
88 | 2. The user creates a branch, commits their changes, and pushes the branch to GitHub
89 | 3. The user opens a pull request to the `main` branch from their feature branch
90 | 4. Once CI is passing and the user has the proper reviews on their pull request, they can continue
91 | 5. The user grabs the deployment lock as they need an hour or two for validating their change -> `.lock`
92 | 6. The lock is claimed and now only the user who claimed it can deploy
93 | 7. The user runs `.noop` to get a preview of their changes
94 | 8. All looks good so the user runs `.deploy` and ships their code to production from their branch
95 |
96 | > If anything goes wrong, the user can run `.deploy main` to rollback to the `main` branch
97 |
98 | 9. After an hour or so, all looks good so they merge their changes to the `main` branch
99 | 10. Upon merging, they comment on their merged pull request `.unlock` to remove the lock
100 | 11. Done!
101 |
--------------------------------------------------------------------------------