├── .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 | Coverage: 100%Coverage100% -------------------------------------------------------------------------------- /.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.\n
Click 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 | ![confirm](./assets/deployment-approved.png) 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 | ![reject](./assets/deployment-rejected.png) 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 | ![timeout](./assets/deployment-timeout.png) 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 | ![missing_non_fast_forward](./assets/rules/missing_non_fast_forward.png) 27 | 28 | ### `missing_deletion` 29 | 30 | Solution: Enable the **Restrict deletions** rule 31 | 32 | ![missing_deletion](./assets/rules/missing_deletion.png) 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 | ![mismatch_required_status_checks_strict_required_status_checks_policy](./assets/rules/mismatch_required_status_checks_strict_required_status_checks_policy.png) 39 | 40 | ### `missing_pull_request` 41 | 42 | Solution: Enable the **Require a pull request before merging** rule 43 | 44 | ![missing_pull_request](./assets/rules/missing_pull_request.png) 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 | ![mismatch_pull_request_dismiss_stale_reviews_on_push](./assets/rules/mismatch_pull_request_dismiss_stale_reviews_on_push.png) 51 | 52 | ### `mismatch_pull_request_require_code_owner_review` 53 | 54 | Solution: Enable the **Require review from Code Owners** rule 55 | 56 | ![mismatch_pull_request_require_code_owner_review](./assets/rules/mismatch_pull_request_require_code_owner_review.png) 57 | 58 | ### `mismatch_pull_request_required_approving_review_count` 59 | 60 | Solution: Ensure that the **Required approvals** setting is not `0` 61 | 62 | ![mismatch_pull_request_required_approving_review_count](./assets/rules/mismatch_pull_request_required_approving_review_count.png) 63 | 64 | ### `missing_required_deployments` 65 | 66 | Solution: Enable the **Require deployments to succeed** rule 67 | 68 | ![missing_required_deployments](./assets/rules/missing_required_deployments.png) 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 | ![sha-deployment](assets/sha-deployment.png) 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 | ![required-ci-checks](assets/required-ci-checks.png) 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 | ![required-ci-checks-example](assets/required-ci-checks-example.png) 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 | ![only-using-checks-for-one-ci-job.png](assets/only-using-checks-for-one-ci-job.png) 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 | ![ignored-checks-example](assets/ignore-ci-checks.png) 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 | ![Example of custom deployment message](assets/custom-comment.png) 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 | --------------------------------------------------------------------------------