diff --git a/.gitattributes b/.gitattributes index af309373..176320ec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ dist/** -diff linguist-generated=true +script/** -linguist-detectable diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 10325ca9..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,74 +0,0 @@ -# Copilot Instructions - -## Environment Setup - -Bootstrap the project by running: - -```bash -npm install -``` - -## Testing - -Ensure all unit tests pass by running the following: - -```bash -npm run test -``` - -This project should include unit tests for all lines, functions, and branches of code. - -This project **requires 100% test coverage** of code. - -Unit tests should exist in the `__tests__` directory. They are powered by `jest`. - -## Bundling - -The final commit should always be a bundle of the code. This is done by running the following command: - -```bash -npm run all -``` - -This uses Vercel's `ncc` to bundle JS code for running in GitHub Actions. - -## Project Guidelines - -- Follow: - - Object-Oriented best practices, especially abstraction and encapsulation - - GRASP Principles, especially Information Expert, Creator, Indirection, Low Coupling, High Cohesion, and Pure Fabrication - - SOLID principles, especially Dependency Inversion, Open/Closed, and Single Responsibility -- Base new work on latest `main` branch -- Changes should maintain consistency with existing patterns and style. -- 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. -- 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. -- 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. -- 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. -- Hard-coded strings should almost always be constant variables. -- In writing code, take the following as preferences but not rules: - - understandability over concision - - syntax, expressions, and blocks that are common across many languages over language-specific syntax. - - more descriptive names over brevity of variable, function, and class names - - the use of whitespace (newlines) over compactness of files - - naming of variables and methods that lead to expressions and blocks reading more like English sentences. - - less lines of code over more. Keep changes minimal and focused. - -## Pull Request Requirements - -- All tests must pass. -- The linter must pass. -- Documentation must be up-to-date. -- The body of the Pull Request should: - - contain a summary of the changes - - make special note of any changes to dependencies - - comment on the security of the changes being made and offer suggestions for further securing the code - -## Repository Organization - -- `.github/` - GitHub configurations and settings -- `docs/` - Main documentation storage -- `script/` - Repository maintenance scripts -- `src/` - Main code for the project. This is where the main application/service code lives -- `__tests__/` - Tests for the project. This is where the unit tests live -- `dist/` - This is where the JS compiled code lives for the GitHub Action -- `action.yml` - The GitHub Action file. This is where the GitHub Action is defined diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 00000000..bf209e39 --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,32 @@ +name: acceptance +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + acceptance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 + with: + persist-credentials: false + + - name: setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pin@v6.4.0 + with: + node-version-file: .node-version + cache: 'npm' + + - name: install dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: package + run: npm run package + + - name: acceptance + run: npm run acceptance diff --git a/.github/workflows/actions-config-validation.yml b/.github/workflows/actions-config-validation.yml index 5020a897..b3805ba0 100644 --- a/.github/workflows/actions-config-validation.yml +++ b/.github/workflows/actions-config-validation.yml @@ -14,7 +14,7 @@ jobs: actions-config-validation: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 1e147faa..4d2a1da8 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -12,15 +12,15 @@ jobs: contents: read steps: - name: checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: persist-credentials: false - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pin@v6.4.0 with: node-version-file: .node-version cache: 'npm' - name: install dependencies - run: npm ci + run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7fac9365..168616df 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,20 +12,22 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: persist-credentials: false - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pin@v6.4.0 with: node-version-file: .node-version cache: 'npm' - name: install dependencies - run: npm ci + run: npm ci --ignore-scripts --no-audit --no-fund - name: lint run: | npm run format-check + npm run typecheck + npm run typecheck:runtime npm run lint diff --git a/.github/workflows/old/sample-workflow.yml b/.github/workflows/old/sample-workflow.yml index 82229113..912f9b1d 100644 --- a/.github/workflows/old/sample-workflow.yml +++ b/.github/workflows/old/sample-workflow.yml @@ -18,19 +18,22 @@ # runs-on: ubuntu-latest # steps: # # Need to checkout for testing the Action in this repo -# - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3.0.2 +# - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 +# with: +# persist-credentials: false # # Start the branch deployment # - uses: ./ # id: branch-deploy # with: -# admins: grantbirki +# admins: octocat # # Check out the ref from the output of the IssueOps command -# - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3.0.2 +# - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 # if: ${{ steps.branch-deploy.outputs.continue == 'true' }} # with: # ref: ${{ steps.branch-deploy.outputs.sha }} +# persist-credentials: false # # Do some fake "noop" deployment logic here # - name: fake noop deploy diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml index 62d78cec..5e86c8e4 100644 --- a/.github/workflows/package-check.yml +++ b/.github/workflows/package-check.yml @@ -15,27 +15,28 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: persist-credentials: false - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pin@v6.4.0 with: node-version-file: .node-version cache: 'npm' - name: install dependencies - run: npm ci + run: npm ci --ignore-scripts --no-audit --no-fund - name: rebuild the dist/ directory - run: npm run bundle + run: npm run package - name: compare the expected and actual dist/ directories run: | - if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then - echo "Detected uncommitted changes after build. See status below:" - git diff + if [[ -n "$(git status --porcelain --untracked-files=all -- dist/)" ]]; then + echo "Detected changes in dist/ after build. See status below:" + git status --short --untracked-files=all -- dist/ + git diff -- dist/ exit 1 fi id: diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..fa83e5fa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,187 @@ +name: release + +on: + push: + branches: [main] + paths: [src/version.ts] + +permissions: {} +concurrency: {group: release, queue: max} + +jobs: + release: + if: ${{ github.ref == 'refs/heads/main' && (github.repository == 'github/branch-deploy' || vars.ENABLE_RELEASES == 'true') }} + runs-on: ubuntu-latest + permissions: + artifact-metadata: write + attestations: write + contents: write + id-token: write + env: + ATTESTED_FILES: |- + action.yml + dist/index.js + dist/index.js.map + dist/licenses.txt + dist/package.json + dist/sourcemap-register.cjs + dist/sourcemap-register.js + + steps: + - name: checkout the release source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pin@v6.4.0 + with: + node-version-file: .node-version + + - name: validate the release + id: version + env: + GH_TOKEN: ${{ github.token }} + REF_PROTECTED: ${{ github.ref_protected }} + run: | + set -euo pipefail + fail() { echo "::error::$*"; exit 1; } + + [[ "$REF_PROTECTED" == 'true' ]] || fail 'The release workflow must run from a protected ref.' + [[ $(git rev-parse HEAD) == "$GITHUB_SHA" ]] || fail 'The checkout does not match the frozen source commit.' + version=$(node --input-type=module --eval "import {VERSION} from './src/version.ts'; process.stdout.write(VERSION)") + [[ "$version" =~ ^v([0-9]+)\.[0-9]+\.[0-9]+$ ]] || fail "src/version.ts must export a stable vMAJOR.MINOR.PATCH version; received $version." + major="v${BASH_REMATCH[1]}" + + if release=$(gh release view "$version" --json assets,isDraft,isPrerelease,targetCommitish 2>/dev/null); then + [[ $(jq -r .targetCommitish <<< "$release") == "$GITHUB_SHA" ]] || fail "Existing release $version targets another commit." + [[ $(jq -r '[.isDraft,.isPrerelease,(.assets | length)] | @tsv' <<< "$release") == $'false\tfalse\t0' ]] || fail "Existing release $version is not a published, stable, assetless release." + fi + + if git show-ref --verify --quiet "refs/tags/$version"; then + tag_info=$(git for-each-ref --format='%(objecttype) %(*objecttype) %(*objectname)' "refs/tags/$version") + [[ "$tag_info" == "tag commit $GITHUB_SHA" ]] || fail "Existing exact tag $version is not an annotated tag for this commit." + fi + + latest_tag=$({ git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -vxF "$version" | sort -V | tail -n 1; } || true) + if [[ -n "$latest_tag" ]]; then + newest=$(printf '%s\n%s\n' "$latest_tag" "$version" | sort -V | tail -n 1) + [[ "$newest" == "$version" ]] || fail "$version must be newer than the latest stable tag $latest_tag." + fi + + releases=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name') + previous=$({ grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' <<< "$releases" | grep -vxF "$version" | sort -V | tail -n 1; } || true) + + { + echo "major=$major" + echo "previous=$previous" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + + - name: build and check the action + run: | + npm ci --ignore-scripts --no-audit --no-fund + npm run check + npm run package + + - name: verify the committed bundle + run: | + set -euo pipefail + actual=$(find action.yml dist -type f -print | sort) + if [[ "$actual" != "$ATTESTED_FILES" ]]; then + echo '::error::The deployed file set does not match the seven attested subjects.' + printf 'Expected:\n%s\nActual:\n%s\n' "$ATTESTED_FILES" "$actual" + exit 1 + fi + if [[ -n $(git status --porcelain --untracked-files=all) ]]; then + echo '::error::The release build did not reproduce the committed repository.' + git status --short --untracked-files=all + git diff + exit 1 + fi + + - name: attest the deployed action files + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # pin@v4.1.0 + with: + subject-path: ${{ env.ATTESTED_FILES }} + + - name: verify the deployed action attestations + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + while IFS= read -r subject; do + for attempt in 1 2 3 4 5; do + if gh attestation verify "$subject" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-digest "$GITHUB_SHA" \ + --source-ref "$GITHUB_REF" \ + --deny-self-hosted-runners; then + break + fi + [[ "$attempt" != 5 ]] || { echo "::error::Attestation verification failed for $subject."; exit 1; } + sleep 3 + done + done <<< "$ATTESTED_FILES" + + - name: publish and verify the release + env: + GH_TOKEN: ${{ github.token }} + MAJOR: ${{ steps.version.outputs.major }} + PREVIOUS: ${{ steps.version.outputs.previous }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + fail() { echo "::error::$*"; exit 1; } + + if git show-ref --verify --quiet "refs/tags/$VERSION"; then + tag_sha=$(git rev-parse "refs/tags/$VERSION") + else + tag_sha=$(gh api --method POST "repos/$GITHUB_REPOSITORY/git/tags" \ + -f tag="$VERSION" -f message="$VERSION Release" -f object="$GITHUB_SHA" -f type=commit --jq .sha) + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$tag_sha" + fi + exact_ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" --jq '[.object.type,.object.sha] | @tsv') + tag_info=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_sha" --jq '[.tag,.object.type,.object.sha] | @tsv') + [[ "$exact_ref" == "tag"$'\t'"$tag_sha" && "$tag_info" == "$VERSION"$'\tcommit\t'"$GITHUB_SHA" ]] || fail 'The exact tag is not the expected annotated tag for the frozen source commit.' + + if ! gh release view "$VERSION" >/dev/null 2>&1; then + notes=(--generate-notes) + [[ -z "$PREVIOUS" ]] || notes+=(--notes-start-tag "$PREVIOUS") + if [[ "$MAJOR" == "v12" ]]; then + migration_notes=$'## v12 migration\n\nBranch Deploy v12 includes breaking changes for security defaults and public action contracts. Review the v11 to v12 upgrade guide before moving production workflows: https://github.com/'"$GITHUB_REPOSITORY"$'/blob/main/docs/v11-to-v12-upgrade-guide.md' + notes+=(--notes "$migration_notes") + fi + gh release create "$VERSION" --verify-tag --target "$GITHUB_SHA" --title "$VERSION" --latest "${notes[@]}" + fi + + verification= + for attempt in 1 2 3 4 5 6; do + if verification=$(gh release verify "$VERSION" --repo "$GITHUB_REPOSITORY" --format json); then break; fi + [[ "$attempt" != 6 ]] || fail "Immutable release verification failed for $VERSION." + sleep 5 + done + + [[ $(jq -r .verificationResult.statement.subject[0].digest.sha1 <<< "$verification") == "$tag_sha" ]] || fail 'The release attestation does not identify the exact annotated tag.' + [[ $(jq -r .verificationResult.statement.subject[0].uri <<< "$verification") == "pkg:github/$GITHUB_REPOSITORY@$VERSION" ]] || fail 'The release attestation identifies an unexpected release.' + [[ $(jq -r .verificationResult.statement.predicateType <<< "$verification") == 'https://in-toto.io/attestation/release/v0.2' ]] || fail 'The release attestation has an unexpected predicate.' + release=$(gh release view "$VERSION" --json assets,isDraft,isImmutable,isPrerelease,targetCommitish) + [[ $(jq -r .targetCommitish <<< "$release") == "$GITHUB_SHA" ]] || fail "Release $VERSION targets another commit." + [[ $(jq -r '[.isDraft,.isImmutable,.isPrerelease,(.assets | length)] | @tsv' <<< "$release") == $'false\ttrue\tfalse\t0' ]] || fail "Release $VERSION is not immutable, stable, and assetless." + [[ $(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name) == "$VERSION" ]] || fail "$VERSION is not the latest release." + ! gh release view "$MAJOR" >/dev/null 2>&1 || fail "A GitHub Release must not be attached to the movable $MAJOR tag." + + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$MAJOR" >/dev/null 2>&1; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$MAJOR" -f sha="$tag_sha" -F force=true + else + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$MAJOR" -f sha="$tag_sha" + fi + + exact_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" --jq .object.sha) + major_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$MAJOR" --jq .object.sha) + tag_info=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_sha" --jq '[.tag,.object.type,.object.sha] | @tsv') + [[ "$exact_sha" == "$tag_sha" && "$major_sha" == "$tag_sha" ]] || fail 'The exact and major tags do not share the expected annotated tag object.' + [[ "$tag_info" == "$VERSION"$'\tcommit\t'"$GITHUB_SHA" ]] || fail 'The release tags do not target the frozen source commit.' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36434196..812d7073 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,18 +12,18 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: persist-credentials: false - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pin@v6.4.0 with: node-version-file: .node-version cache: 'npm' - name: install dependencies - run: npm ci + run: npm ci --ignore-scripts --no-audit --no-fund - name: test run: npm run ci-test diff --git a/.github/workflows/update-latest-release-tag.yml b/.github/workflows/update-latest-release-tag.yml deleted file mode 100644 index e2341ac2..00000000 --- a/.github/workflows/update-latest-release-tag.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Update Latest Release Tag -run-name: Update ${{ github.event.inputs.major_version_tag }} with ${{ github.event.inputs.source_tag }} - -on: - workflow_dispatch: - inputs: - source_tag: - description: 'The tag or reference to use as the source (example: v8.0.0)' - required: true - default: vX.X.X - major_version_tag: - description: 'The major release tag to update with the source (example: v8)' - required: true - default: vX - -permissions: - contents: write - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 - - - name: git config - run: | - git config user.name github-actions - git config user.email github-actions@github.com - - - name: tag new target - env: - SOURCE_TAG: ${{ github.event.inputs.source_tag }} - MAJOR_VERSION_TAG: ${{ github.event.inputs.major_version_tag }} - run: git tag -f ${MAJOR_VERSION_TAG} ${SOURCE_TAG} - - - name: push new tag - env: - MAJOR_VERSION_TAG: ${{ github.event.inputs.major_version_tag }} - run: git push origin ${MAJOR_VERSION_TAG} --force diff --git a/.node-version b/.node-version index 25649a2b..ca5c3500 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.9.0 +24.18.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..0171db74 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=10 diff --git a/.prettierignore b/.prettierignore index 1eae0cf6..2d0c0644 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ dist/ node_modules/ +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..eefcf45b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,316 @@ +# AGENTS.md + +## Scope and instruction design + +This file applies to the entire repository. It is the repository-specific instruction source for coding agents and automated contributors. + +Keep this file durable, small, and focused on rules that should apply every time work happens in this repository. Do not add one-off task plans, temporary investigation notes, local machine details, or long procedure transcripts here. Move detailed operating procedures to `docs/`, reusable workflows to skills, and directory-specific rules to a closer nested `AGENTS.md` if that scope ever appears. + +Codex loads project guidance from the repository root down to the working directory, and closer files override broader guidance. Codex also has a default project-instruction size budget, so this file should stay compact enough to load completely. If a new rule would make this file sprawl, prefer replacing repeated guidance with a pointer to the authoritative script, test, or document. + +`branch-deploy` is a public GitHub Action for IssueOps-based branch deployments. Treat every source file, test, generated artifact, branch name, commit, pull request, comment, workflow log, release artifact, example, and fixture as public information. + +The project prioritizes behavior preservation, a small dependency surface, strict static guarantees, reproducible committed bundles, and reviewable changes. Prefer the smallest change that completely solves the requested problem. + +## Public-repository safety + +- Never add credentials, tokens, cookies, private keys, authentication headers, customer data, private repository names, private URLs, internal hostnames, non-public infrastructure details, local registry or proxy configuration, or machine-specific identifiers. +- Never copy private material from another checkout, conversation, clipboard, log, browser session, or tool output into this repository. +- Do not commit absolute local paths. Pay particular attention to source maps, coverage output, archives, manifests, copied command output, and generated files. +- Keep fixtures, examples, branch names, commit messages, pull request text, comments, workflow summaries, and documentation generic and suitable for a public open-source repository. +- Before every commit, push, pull request, or public comment, review the relevant diff, staged content, untracked files, generated artifacts, commit metadata, branch history, PR title, and PR body for accidental disclosure. +- If a requested change appears to require non-public context, stop before publication and ask for explicit direction. + +## Repository and contribution boundary + +- Base new work on the repository's current default branch unless the maintainer specifies another base. +- Inspect `git status --short --branch` and the actual diff before editing, staging, committing, or opening a pull request. +- Preserve unrelated working-tree changes. Never discard, overwrite, amend, rebase, or force-push user or upstream work unless explicitly requested. +- Do not merge a pull request, publish a release, create or move tags, change repository settings, or bump the action version unless the current request explicitly authorizes that operation. +- Keep unrelated refactors, dependency upgrades, release bumps, base synchronization, workflow hardening, and behavior changes in separate pull requests. +- Make the intended head, base, public compatibility impact, and generated-artifact impact clear in PR work. + +## Product and architecture model + +The supported public product is the combination of `action.yml` and the committed JavaScript bundle under `dist/`. The TypeScript source tree is an implementation detail, not a supported package import surface. + +This repository is intentionally an action-only package: + +- `package.json` is private from npm's perspective. +- There is no supported npm library API. +- There are no emitted declarations, `lib/` build tree, `exports`, `types`, or source-import compatibility promise. +- Consumers are expected to reference the GitHub Action, not import `src/*.ts`. + +`action.yml` declares the GitHub-hosted Node action runtime and uses `dist/index.js` for both the main and post entrypoints. GitHub Actions executes the committed bundle directly; consumer workflows do not install dependencies or compile TypeScript when invoking the action. + +The package boundary is ESM. Preserve `"type": "module"`, ESM exports, import-time behavior, and the main/post lifecycle unless the maintainer explicitly authorizes a public runtime change. + +## Important files + +- `action.yml` defines the public action inputs, outputs, runtime, main entrypoint, and post entrypoint. +- `src/main.ts` contains import-time dispatch and the primary exported `run` entrypoint. +- `src/actions-core.ts` is the project-owned compatibility layer for the narrow GitHub Actions runner-command surface consumed by this action. +- `src/action-io.ts` centralizes typed input, output, and action-state keys. +- `src/trust-boundaries.ts` contains intentionally narrow assertions and legacy coercion boundaries that cannot be proven statically without changing runtime behavior. +- `src/types.ts` and related modules define shared domain models and discriminated unions. +- `src/functions/` contains the runtime behavior for prechecks, locks, deployments, comments, labels, parameters, rulesets, and post-deploy completion. +- `__tests__/` contains the native Node test suite, typed test helpers, contract tests, policy fixtures, and intentionally invalid fixtures. +- `tools/typescript-policy.ts` is the project-owned TypeScript compiler-API safety checker used by `npm run lint`. +- `tools/coverage-reporter.ts` validates native V8 coverage and the complete executable first-party source inventory. +- `script/test` is the canonical native Node test entrypoint. +- `dist/` contains the committed ncc output executed by the GitHub Actions runner. +- `docs/maintainer-guide.md` documents the automatic immutable release process. +- `.github/workflows/` contains the required CI, package reproduction, schema validation, and release workflows. + +## Action contract + +The action metadata and typed registries define the public interface. Contract tests currently enforce all action inputs, outputs, state keys, accepted literal values, default values, required flags, output writes, state producers and consumers, and runner entrypoints. + +When changing action inputs or outputs: + +- Update `action.yml`, `src/action-io.ts`, `src/functions/inputs.ts` when relevant, schema fixtures, documentation, and contract tests together. +- Preserve defaults, required flags, accepted literals, stringification, and output timing unless the request explicitly authorizes a behavior change. +- Do not rename state or output keys as cleanup. +- Do not introduce undeclared outputs, untyped raw key strings, or scattered direct `core.getInput`, `core.setOutput`, `core.saveState`, or `core.getState` calls. +- Treat additions, removals, default changes, accepted-value changes, and entrypoint changes as public API changes requiring explicit authorization and release consideration. + +Action state is serialized by the GitHub Actions runner protocol. Values read back from state are strings even when the saved value was originally a boolean, number, object, `null`, or `undefined`. Do not normalize that behavior merely because a stronger TypeScript model appears desirable. + +Preserve the main/post dispatch rules. Post mode depends on the saved `isPost` state string, and normal import-time execution depends on the existing CI/test sentinels. The legacy-named `BRANCH_DEPLOY_VITEST_TEST` variable remains an import-dispatch compatibility boundary even though Vitest is no longer a dependency. + +## Behavior-preservation standard + +Maintenance, docs, dependency, tooling, and typing pull requests should have zero intentional runtime behavior changes unless the request explicitly says otherwise. + +Preserve observable behavior, especially: + +- Function names, named exports, import-time side effects, and statement ordering where ordering is observable. +- Input trimming, required-input errors, boolean parsing, command escaping, state serialization, output serialization, and runner-command formatting. +- Existing literal results for success, noop, safe exit, failure, alternate modes, lock results, deployment results, and structured operation results. +- Existing `false`, `null`, `"null"`, `"GLOBAL_REQUEST"`, empty-string, and `undefined` sentinels in their established paths. +- Swallowed-versus-thrown error behavior and HTTP 403, 404, 409, 422, retry, pagination, and status handling. +- Error text, status access, stack access, annotation content, comments, reactions, labels, and ordering. +- GitHub REST and GraphQL routes, headers, arguments, preview media types, pagination, retries, and response handling. +- Lock branch names, lock JSON shape, timestamps, metadata, ownership, sticky behavior, unlock behavior, global-lock behavior, ambiguity handling, and cleanup ordering. +- Deployment payloads, statuses, environment names, environment URLs, task names, polling intervals, and post-deploy completion behavior. +- Template escaping, rendered bytes, parameter parsing, positional arguments, short and long options, equals syntax, coercion, and nested dot paths. + +If typing or a refactor exposes an existing bug, characterize the current behavior with a test and defer the behavior correction to a separate, explicitly scoped pull request. + +## TypeScript and coding rules + +Local development and CI use the exact Node version in `.node-version`; `script/test` fails when the running Node version differs. `action.yml` declares the GitHub Actions runtime. Do not raise the runtime floor, switch module systems, change the TypeScript target, or rely on newer Node APIs without explicit approval and version-specific documentation. + +Preserve the strict compiler posture in `tsconfig.json` and `tsconfig.runtime.json`, including `strict`, `noUncheckedIndexedAccess`, `noUncheckedSideEffectImports`, `noPropertyAccessFromIndexSignature`, `exactOptionalPropertyTypes`, `useUnknownInCatchVariables`, `isolatedModules`, `verbatimModuleSyntax`, `erasableSyntaxOnly`, explicit `.ts` relative import specifiers, and `skipLibCheck: false`. + +`npm run lint` is the repository-owned TypeScript policy checker, not ESLint. Preserve its guarantees: no TypeScript suppression directives, explicit `any`, unsafe `any` flow, non-null assertions, scattered unsafe assertions, `var`, broad loose equality, CommonJS, direct or indirect `eval`, `Function` construction, floating promises, non-Error throws, unsafe template interpolation, unsafe conditions, and other policy-covered hazards. + +External GitHub payloads, saved state, decoded lock JSON, template data, environment values, REST responses, GraphQL responses, and caught errors are not trustworthy merely because an SDK has optimistic types. Keep unavoidable unsafe assertions centralized in `src/trust-boundaries.ts`; the test-only `__tests__/unsafe-fixtures.ts` helper is the other intentional assertion escape hatch. + +Use the existing domain types and discriminated unions to correlate status values with fields that are actually present. Prefer named request objects over growing positional or mode-boolean APIs. + +Prefer small typed functions and direct control flow. Add abstractions only when they remove real duplication or match an existing pattern. Do not introduce classes, inheritance, service containers, repositories, factories, speculative interfaces, enums, namespaces, parameter properties, decorators, path aliases, or transform-dependent syntax without explicit review. + +Use `readonly` properties and arrays where production code does not mutate values. Give exported runtime functions, parsers, predicates, state-machine helpers, and literal-significant functions explicit return types. Use inference for simple private helpers when the inferred type is clear. + +Use `as const satisfies` for literal registries and configuration constants where it preserves narrow values while checking the intended shape. Use bracket access for environment and other index-signature values. Use `node:` specifiers for Node standard-library imports. + +Prefer `const`; use `let` only for genuine reassignment. Use explicit `String`, joining, or JSON formatting when implicit conversion could stringify objects, arrays, nullish values, or unknown values. + +Keep comments focused on why a non-obvious invariant exists. Do not narrate straightforward code. Match existing naming and module boundaries before inventing helpers. Do not opportunistically refactor adjacent code in a bug fix, dependency update, or documentation change. + +## Dependencies and installation + +Use npm and the committed `package-lock.json`. The supported install command is: + +```bash +npm ci --ignore-scripts --no-audit --no-fund +``` + +Do not run dependency install, fetch, update, or package-manager execution commands unless the maintainer has explicitly approved that work. When dependency work is approved, use the protected package-manager path required by the maintainer or environment, use exact direct versions, and do not print or commit machine registry, proxy, or credential configuration. + +All direct runtime and development dependencies are exact pins. `__tests__/dependency-policy.test.ts` is the executable allowlist and graph policy. It verifies approved direct package names and versions, root lockfile agreement, absence of unjustified overrides, public npm registry resolutions, integrity digests, no Git/file/local/private-registry dependencies, zero optional packages, zero install-script packages, and expected graph counts. + +Dependency changes require a narrow justification, direct and transitive consumer review, version-range and engine review, lifecycle-script and advisory review, license and bundle-impact review, lockfile churn review, dependency-policy updates, and generated-bundle review when affected. + +Do not replace protocol clients, template engines, or parsers with incomplete local implementations merely to reduce package count. A dependency reduction is successful only when behavior, security, maintenance cost, and artifact reproducibility remain stronger overall. + +## Local GitHub Actions core compatibility + +`src/actions-core.ts` replaces only the narrow `@actions/core` surface actually used by this project. It is an internal runner-protocol adapter, not a general toolkit replacement. + +Preserve byte-level behavior for: + +- Input environment-name normalization, trimming, required-input handling, and exact error text. +- YAML 1.2 boolean spellings and malformed-boolean errors. +- String, boxed-string, number, boolean, object, array, `null`, and `undefined` conversion. +- `GITHUB_OUTPUT` and `GITHUB_STATE` file-command heredocs, delimiter generation, delimiter collision rejection, UTF-8 append behavior, and platform newlines. +- Missing command-file environment variables and missing file paths. +- Deprecated stdout command fallback, including the existing leading newline from `setOutput`. +- Command and property escaping for percent signs, carriage returns, line feeds, colons, and commas. +- `STATE_` reads. +- Debug, informational, warning, and error output. +- `Error` conversion for warning and error annotations. +- `setFailed` setting `process.exitCode = 1` before emitting the error. + +Do not expand the adapter with unused summaries, OIDC, environment export, path mutation, masking, groups, notices, or command echoing without a real production consumer and focused compatibility tests. + +## Testing and validation + +Stable commands: + +```bash +npm run format +npm run format-check +npm run typecheck +npm run typecheck:runtime +npm run lint +npm run test +npm run check +npm run package +npm run all +``` + +`npm run format` rewrites source formatting. `npm run format-check`, `npm run typecheck`, `npm run typecheck:runtime`, and `npm run lint` are non-mutating. `npm run test` is non-mutating with respect to tracked files but recreates ignored `coverage/`. `npm run check` runs formatting verification, both TypeScript projects, the TypeScript policy, and the complete native Node suite. `npm run package` rebuilds `dist/`; `npm run all` checks and then packages. + +The test suite uses the native Node test runner through `script/test`. Do not reintroduce Vitest, Jest, Babel, a coverage wrapper, or a compatibility implementation of another test API without explicit approval. + +`script/test` verifies the exact `.node-version`, discovers `__tests__/**/*.test.ts` deterministically, sets the import-dispatch sentinel, runs each test file in an isolated process with no intra-file concurrency, enables pinned experimental ESM module mocking and native coverage features, writes LCOV output, and routes coverage through the project-owned reporter. + +Every test must pass. Skipped, todo, cancelled, and failed tests are not acceptable substitutes for coverage. Executable first-party source must maintain 100% native line, branch, and function coverage. Do not add broad coverage exclusions. + +Use `node:test` and `node:assert/strict` directly. Prefer explicit awaited values over assertion DSLs. Register table cases with deterministic loops and stable names. Use the narrow helpers in `__tests__/node-test-helpers.ts`, install ESM module mocks before dynamic imports, keep stable mocked-export identities, and rely on process-per-file isolation instead of production dependency-injection seams solely for tests. + +For documentation-only or instruction-only changes, verify claims against current scripts, package metadata, action metadata, workflows, and tests. Run formatting verification only for files covered by the formatter. Do not regenerate `dist/` when runtime source is unchanged. + +## Bundling and committed distribution + +The committed distribution contract consists of exactly: + +- `dist/index.js` +- `dist/index.js.map` +- `dist/licenses.txt` +- `dist/package.json` +- `dist/sourcemap-register.cjs` +- `dist/sourcemap-register.js` + +Any source change that affects packaging must regenerate the bundle with `npm run package` or `npm run all`. Do not manually edit generated `dist/` files. + +Package reproduction is exact. The `package-check` workflow installs from the lockfile, rebuilds `dist/`, and fails on tracked or untracked `dist/` changes. + +Review generated changes semantically, not only by size or hash. Compare filenames, hashes, sizes, licenses, source-map loaders, package boundary, module inventory, runtime dependencies, action strings, defaults, state/output keys, API routes, API arguments, control flow, errors, serialization, templates, and private/local path exposure. + +## Continuous integration + +Preserve existing workflow and required-check identities: + +- `actions-config-validation` validates action metadata. +- `lint` runs formatting verification, both TypeScript projects, and the TypeScript policy. +- `test` runs the complete native Node suite and coverage contract. +- `package-check` proves exact bundle reproduction. +- `acceptance` rebuilds and executes the ESM bundle through its main and post entrypoints. + +CI uses `.node-version` and repository scripts. Do not duplicate long tool flags in workflow YAML when a script is the source of truth. + +Checkout credentials should not persist in workflows that do not need to push. Keep `persist-credentials: false` where present. + +Release-critical third-party actions are pinned to full commit SHAs. Do not loosen those pins. Treat broad workflow-action pinning or replacement outside the release chain as a separate security-maintenance scope rather than incidental churn. + +## Release process + +`src/version.ts` is the sole normal release input. Only stable `vMAJOR.MINOR.PATCH` versions are accepted. + +A normal release is a dedicated, reviewed version-bump pull request that changes `src/version.ts`, runs complete checks, regenerates the committed distribution, and includes only the version source plus mechanically affected bundle artifacts unless another change is explicitly part of the release. + +The `release` workflow runs from protected `main` when `src/version.ts` changes. It freezes the merge SHA, reinstalls from the exact lockfile with lifecycle scripts disabled, runs checks, rebuilds and verifies the committed distribution, attests `action.yml` and all six distribution files, verifies direct attestations, creates or validates an annotated exact-version tag, creates an assetless stable immutable GitHub Release, verifies release integrity, and moves the ordinary `vMAJOR` compatibility tag only after exact release verification. + +Do not manually push release tags, attach arbitrary release assets, create release candidates, create a `vMAJOR.MINOR` alias, move the major tag outside the verified workflow, or bump `src/version.ts` as part of unrelated work. + +Do not describe the compact single-job release workflow as SLSA Build Level 3. It provides GitHub build provenance and immutable-release integrity but does not isolate the build in a separate reusable workflow boundary. + +## Live acceptance for runtime changes + +Unit, type, policy, and bundle checks are necessary but may not be sufficient for deployed behavior, runner protocol, dependency, bundler, or main/post lifecycle changes. + +For high-risk runtime changes, use an exact candidate commit SHA in a public-safe consumer repository rather than a mutable branch reference. Remember that `issue_comment` workflows execute the workflow definition from the consumer repository's default branch, so reliable live acceptance may require a reviewed temporary default-branch pin before comments can exercise the candidate. + +Useful IssueOps acceptance can include `.wcid`, `.help`, `.noop`, `.deploy`, stable-branch or rollback deploys, `.unlock`, merge-deploy mode, and unlock-on-merge mode. Record the resolved action SHA, selected checkout SHA, key outputs, step conclusions, deployment state, lock state, comments, and post-action result. Any candidate commit change invalidates earlier exact-SHA live results. + +After acceptance, restore consumer workflow references through normal review, remove harmless markers, delete temporary branches, verify that no test lock remains, and preserve unrelated existing locks or pull requests. + +## Documentation and Markdown + +- Keep public documentation accurate with executable scripts, package metadata, action metadata, workflows, and tests. +- Do not hard-wrap Markdown prose. Keep each paragraph and list item on one source line and let the renderer wrap it. +- Preserve structural line breaks for headings, blank lines, lists, tables, block quotes, code fences, and explicit hard breaks. +- Use repository-relative links for tracked documentation. +- Avoid duplicating volatile version, package-count, input-count, output-count, or workflow facts unless a test or update process keeps them synchronized. +- Describe security properties precisely. Distinguish exact lockfile reproducibility, disabled install scripts, provenance, immutable releases, branch-deploy locks, workflow concurrency, and full hermeticity rather than treating them as synonyms. +- Keep contributor-facing instructions understandable without assuming knowledge of private conversations or local machine setup. + +## Pull request expectations + +- Keep the title short and descriptive. +- Keep the body concise and focused on purpose, important behavior or security boundaries, dependency changes, generated artifacts, and public compatibility impact. +- Do not paste routine local validation transcripts when CI exposes the same result. +- Call out intentional deviations, remaining risks, generated-artifact changes, and deferred behavior fixes. +- Prefer reviewable commits that separate policy/tooling, runtime behavior, tests, dependency resolution, documentation, and generated distribution when that separation helps reviewers. +- The final head, not an intermediate commit, is the acceptance unit. +- Wait for existing required checks and inspect review comments against the current head before recommending merge. + +## Change-specific rules + +Documentation-only or instruction-only changes: + +- Verify factual claims against the repository. +- Keep examples public-safe. +- Do not regenerate `dist/` when runtime source is unchanged. + +Dependency changes: + +- Establish the current graph and bundle inventory before editing. +- Use an approved protected dependency-fetch path. +- Update package manifests, lockfile, dependency-policy expectations, licenses, docs, and bundle only where the actual result requires it. +- Reject unrelated resolution churn. +- Prove removed packages or overrides are absent and retained transitive versions remain expected. +- Run complete checks and package reproduction when packaging is affected. + +Runtime behavior changes: + +- Add characterization tests before changing poorly specified behavior. +- Preserve main/post dispatch, action state, inputs, outputs, API calls, locks, deployments, templates, and error handling unless explicitly changing that contract. +- Run complete checks, rebuild `dist/`, and perform a semantic bundle audit. +- Use exact-SHA live acceptance when the observable action path is materially affected. + +Test or tooling migrations: + +- Preserve behavior cases and stable test names where practical. +- Do not weaken coverage, compiler, or policy gates. +- Prove test isolation, module-mock behavior, source inventory, deterministic reporting, dependency exposure, and install-script exposure. +- Treat experimental Node features as exact-version-pinned test-only boundaries. + +Action metadata changes: + +- Update typed registries, schema fixtures, documentation, and contract tests with `action.yml`. +- Preserve `runs.using`, `runs.main`, and `runs.post` unless explicitly authorized. +- Treat any input, output, default, or accepted-value change as a public compatibility decision. + +Release changes: + +- Keep ordinary feature work separate from `src/version.ts`. +- Verify the exact version transition and generated bundle diff. +- Do not publish from a feature branch or create tags manually. +- After an authorized release merge, monitor build, attestation, tag, release, immutability, latest-release, and major-alias verification to completion. + +## Definition of done + +A change is complete only when the applicable conditions are satisfied: + +- The requested behavior or maintenance goal is implemented without unapproved scope expansion. +- The worktree and branch contain no unrelated edits or private material. +- Formatting, type checking, linting, tests, coverage, action-contract checks, dependency policy, package reproduction, and generated-artifact review have been run as appropriate for the change. +- Runtime source changes that affect packaging include regenerated and semantically reviewed `dist/` files. +- Existing CI check identities remain intact and green. +- High-risk runtime changes have appropriate exact-SHA live acceptance. +- Pull request metadata and commit history are concise, accurate, public-safe, and targeted to the intended repository and branch. +- No release, tag, version bump, settings change, target change, merge, or destructive cleanup occurred without explicit authorization. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4aa1a229..a635c365 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,13 +4,13 @@ All contributions are welcome and greatly appreciated! ## Steps to Contribute ๐Ÿ’ก -> 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 +> Check the `.node-version` file in the repository root to see which Node.js version is required for local development. The JavaScript action runtime is declared separately in `action.yml`. A version manager such as [nodenv](https://github.com/nodenv/nodenv) can use `.node-version` automatically. 1. Fork this repository 2. Commit your changes 3. Test your changes (learn how to test below) 4. Open a pull request back to this repository - > Make sure to run `npm run all` as your final commit! + > For runtime source changes, run `npm run all` and commit the regenerated `dist/` artifacts. Do not regenerate `dist/` for documentation-only changes. 5. Notify the maintainers of this repository for peer review and approval 6. Merge! @@ -20,7 +20,7 @@ The maintainers of this repository will create a new release with your changes s ## Testing ๐Ÿงช -This project requires **100%** test coverage +This project requires every test to pass and **100%** line, branch, and function coverage > 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. @@ -32,27 +32,25 @@ Simply run the following command to execute the entire test suite: npm run test ``` -> Note: this requires that you have already run `npm install` +Run the complete non-mutating formatting, typecheck, safety-policy, and test suite with: -### Testing directly with IssueOps - -> See the testing FAQs below for more information on this process - -You can test your changes by doing the following steps: +```bash +npm run check +``` -1. Commit your changes to the `main` branch on your fork -2. Open a new pull request -3. Run IssueOps commands on the pull request you just opened (`.deploy`, `.noop`, `.deploy main`, etc) -4. Ensure that all IssueOps commands work as expected on your testing PR +> Note: these commands require that you have already run `npm ci --ignore-scripts --no-audit --no-fund` -### Testing FAQs ๐Ÿค” +`npm run test` does not update the tracked coverage badge. The badge reflects the three enforced native Node coverage thresholds and the requirement that every test passes. -Answers to questions you might have around testing +The suite uses `node:test`, native V8 coverage, and the exact Node version in `.node-version`. ESM module mocking and coverage are experimental test-only features pinned to that development runtime. `npm run lint` runs the repository's TypeScript compiler-API safety policy; formatting remains the responsibility of Prettier. -Q: Why do I have to commit my changes to `main`? +### Running the native acceptance suite -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-) +Runtime changes must be tested against a freshly rebuilt `dist/index.js`: -Q: How can I test my changes once my PR is merged and *before* a new release is created? +```bash +npm run package +npm run acceptance +``` -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 +The native Node acceptance harness runs the committed action bundle through its real main/post lifecycle against a strict local GitHub API mock. It is the normal acceptance gate and is also available directly as `script/acceptance` when `dist/index.js` is already current. diff --git a/README.md b/README.md index 5a628b19..af0aca79 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Branch Deploy Action ๐Ÿš€ -[![test](https://github.com/github/branch-deploy/actions/workflows/test.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/test.yml) [![package-check](https://github.com/github/branch-deploy/actions/workflows/package-check.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/package-check.yml) [![lint](https://github.com/github/branch-deploy/actions/workflows/lint.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/lint.yml) [![actions-config-validation](https://github.com/github/branch-deploy/actions/workflows/actions-config-validation.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/actions-config-validation.yml) [![coverage](./badges/coverage.svg)](./badges/coverage.svg) +[![test](https://github.com/github/branch-deploy/actions/workflows/test.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/test.yml) [![acceptance](https://github.com/github/branch-deploy/actions/workflows/acceptance.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/acceptance.yml) [![package-check](https://github.com/github/branch-deploy/actions/workflows/package-check.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/package-check.yml) [![lint](https://github.com/github/branch-deploy/actions/workflows/lint.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/lint.yml) [![actions-config-validation](https://github.com/github/branch-deploy/actions/workflows/actions-config-validation.yml/badge.svg)](https://github.com/github/branch-deploy/actions/workflows/actions-config-validation.yml) [![coverage](./badges/coverage.svg)](./badges/coverage.svg) A GitHub Action to enable branch deployments using IssueOps! @@ -109,9 +109,10 @@ jobs: # Checkout your project's repository based on the commit SHA provided by the branch-deploy step # It is important to only ever operate on the commit SHA (where possible) as commit SHA's are immutable and you know exactly what you are deploying - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.0 with: ref: ${{ steps.branch-deploy.outputs.sha }} + persist-credentials: false # Do some fake "noop" deployment logic here # conditionally run a noop deployment @@ -130,6 +131,10 @@ jobs: You can check out further examples by checking out our [examples](docs/examples.md) documentation +## Upgrading to v12 + +Branch Deploy v12 contains breaking changes. Review the [v11 to v12 upgrade guide](docs/v11-to-v12-upgrade-guide.md) before upgrading from v11. + ## About ๐Ÿ’ก Before we get into details, let's first define a few key terms below: @@ -205,7 +210,9 @@ jobs: runs-on: ubuntu-latest steps: # Checkout your projects repository - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.0 + with: + persist-credentials: false ``` Sets up your `demo` job, uses an ubuntu runner, and checks out your repo - Just some standard setup for a general Action. We also add an `if:` statement here to only run this workflow on pull request comments to make it a little cleaner @@ -266,14 +273,14 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d | `help_trigger` | `false` | `.help` | The string to look for in comments as an IssueOps help trigger. Example: ".help" | | `lock_info_alias` | `false` | `.wcid` | An alias or shortcut to get details about the current lock (if it exists) Example: ".info" - Hubbers will find the ".wcid" default helpful ("where can I deploy") | | `permissions` | `true` | `write,admin` | The allowed GitHub permissions an actor can have to invoke IssueOps commands - Example: "write,admin" | -| `commit_verification` | `false` | `"false"` | Whether or not to enforce commit verification before a deployment can continue. Default is `"false"`. This input option is excellent to enforce tighter security controls on your deployments. | +| `commit_verification` | `false` | `false` | Whether or not to enforce commit verification before a deployment can continue. Default is `false`. This input option is excellent to enforce tighter security controls on your deployments. | | `param_separator` | `false` | `\|` | The separator to use for parsing parameters in comments in deployment requests. Parameters will are saved as outputs and can be used in subsequent steps - See [Parameters](docs/parameters.md) for additional details | | `global_lock_flag` | `false` | `--global` | The flag to pass into the lock command to lock all environments. Example: "--global" | | `environment` | `false` | `production` | The name of the default environment to deploy to. Example: by default, if you type `.deploy`, it will assume "production" as the default environment | | `environment_targets` | `false` | `production,development,staging` | Optional (or additional) target environments to select for use with deployments. Example, "production,development,staging". Example usage: `.deploy to development`, `.deploy to production`, `.deploy to staging` | | `environment_urls` | `false` | `""` | Optional target environment URLs to use with deployments. This input option is a mapping of environment names to URLs and the environment names **must** match the `environment_targets` input option. This option is a comma separated list with pipes (`\|`) separating the environment from the URL. Note: `disabled` is a special keyword to disable an environment url if you enable this option. Format: `"\|,\|,etc"` Example: `"production\|https://myapp.com,development\|https://dev.myapp.com,staging\|disabled"` - See the [environment urls](#environment-urls) section for more details | | `draft_permitted_targets` | `false` | `""` | Optional environments which can allow "draft" pull requests to be deployed. By default, this input option is empty and no environments allow deployments sourced from a pull request in a "draft" state. Examples: `"development,staging"` | -| `environment_url_in_comment` | `false` | `"true"` | If the `environment_url` detected in the deployment should be appended to the successful deployment comment or not. Examples: `"true"` or `"false"` - See the [environment urls](#environment-urls) section for more details | +| `environment_url_in_comment` | `false` | `true` | If the `environment_url` detected in the deployment should be appended to the successful deployment comment or not. Use `true` or `false` - See the [environment urls](#environment-urls) section for more details | | `production_environments` | `false` | `production` | A comma separated list of environments that should be treated as "production". GitHub defines "production" as an environment that end users or systems interact with. Example: "production,production-eu". By default, GitHub will set the "production_environment" to "true" if the environment name is "production". This option allows you to override that behavior so you can use "prod", "prd", "main", "production-eu", etc. as your production environment name. ref: [#208](https://github.com/github/branch-deploy/issues/208) | | `stable_branch` | `false` | `main` | The name of a stable branch to deploy to (rollbacks). Example: "main" | | `update_branch` | `false` | `warn` | Determine how you want this Action to handle "out-of-date" branches. Available options: "disabled", "warn", "force". "disabled" means that the Action will not care if a branch is out-of-date. "warn" means that the Action will warn the user that a branch is out-of-date and exit without deploying. "force" means that the Action will force update the branch. Note: The "force" option is not recommended due to Actions not being able to re-run CI on commits originating from Actions itself | @@ -283,35 +290,38 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d | `checks` | `false` | `"all"` | This input defines how the branch-deploy Action will handle the status of CI checks on your PR/branch before deployments can continue. `"all"` requires that all CI checks must pass in order for a deployment to be triggered. `"required"` only waits for required CI checks to be passing. You can also pass in the names of your CI jobs in a comma separated list. View the [documentation](docs/checks.md) for more details. | | `ignored_checks` | `false` | `""` | A comma separated list of checks that will be ignored when determining if a deployment can continue. This setting allows you to skip failing, pending, or incomplete checks regardless of the `checks` setting above. Example: `"lint,markdown-formatting,update-pr-label"`. View the [documentation](docs/checks.md) for more details. | | `skip_reviews` | `false` | `""` | A comma separated list of environment that will not use reviews/approvals as a requirement for deployment. Use this options to explicitly bypass branch protection settings for a certain environment in your repository. Default is an empty string `""` - Example: `"development,staging"` | -| `allow_forks` | `false` | `"true"` | Allow branch deployments to run on repository forks. If you want to harden your workflows, this option can be set to false. Default is "true" | +| `allow_forks` | `false` | `false` | Allow branch deployments to run on repository forks. Default is `false`. Set this to `true` only when your workflow intentionally supports deployments from forked pull requests. | | `admins` | `false` | `"false"` | A comma separated list of GitHub usernames or teams that should be considered admins by this Action. Admins can deploy pull requests without the need for branch protection approvals. Example: "monalisa,octocat,my-org/my-team" | | `admins_pat` | `false` | `"false"` | A GitHub personal access token with "read:org" scopes. This is only needed if you are using the "admins" option with a GitHub org team. For example: "my-org/my-team" | -| `merge_deploy_mode` | `false` | `"false"` | Advanced configuration option for operations on merge commits. See the [merge commit docs](#merge-commit-workflow-strategy) below | -| `unlock_on_merge_mode` | `false` | `"false"` | Advanced configuration option for automatically releasing locks associated with a pull request when that pull request is merged. See the [unlock on merge mode](docs/unlock-on-merge.md) documentation for more details | -| `skip_completing` | `false` | `"false"` | If set to "true", skip the process of completing a deployment. You must manually create a deployment status after the deployment is complete. Default is "false" | -| `deploy_message_path` | `false` | `".github/deployment_message.md"` | The path to a markdown file which is used as a template for custom deployment messages. Example: `".github/deployment_message.md"` | -| `sticky_locks` | `false` | `"false"` | If set to `"true"`, locks will not be released after a deployment run completes. This applies to both successful, and failed deployments.Sticky locks are also known as ["hubot style deployment locks"](./docs/hubot-style-deployment-locks.md). They will persist until they are manually released by a user, or if you configure [another workflow with the "unlock on merge" mode](./docs/unlock-on-merge.md) to remove them automatically on PR merge. | -| `sticky_locks_for_noop` | `false` | `"false"` | If set to `"true"`, then sticky_locks will also be used for noop deployments. This can be useful in some cases but it often leads to locks being left behind when users test noop deployments. | -| `disable_lock` | `false` | `"false"` | If set to `"true"`, all deployment locking is disabled. Useful for workflows where concurrent deployments are safe (e.g. iOS/Android builds uploaded to TestFlight or the Play Store). When disabled, `.lock` and `.unlock` commands will return an informational message instead of modifying lock state. See the [disable lock](docs/locks.md#disabling-locks) documentation for more details. | -| `allow_sha_deployments` | `false` | `"false"` | If set to `"true"`, then you can deploy a specific sha instead of a branch. Example: `".deploy 1234567890abcdef1234567890abcdef12345678 to production"` - This is dangerous and potentially unsafe, [view the docs](docs/sha-deployments.md) to learn more | -| `disable_naked_commands` | `false` | `"false"` | If set to `"true"`, then naked commands will be disabled. Example: `.deploy` will not trigger a deployment. Instead, you must use `.deploy to production` to trigger a deployment. This is useful if you want to prevent accidental deployments from happening. View the [docs](docs/naked-commands.md) to learn more | +| `merge_deploy_mode` | `false` | `false` | Advanced configuration option for operations on merge commits. See the [merge commit docs](#merge-commit-workflow-strategy) below | +| `unlock_on_merge_mode` | `false` | `false` | Advanced configuration option for automatically releasing locks associated with a pull request when that pull request is merged. See the [unlock on merge mode](docs/unlock-on-merge.md) documentation for more details | +| `skip_completing` | `false` | `false` | If set to `true`, bypass the entire post-action completion path. Your workflow must manage final deployment status, comments, reactions, labels, and non-sticky lock cleanup. Default is `false`. | +| `deploy_message_path` | `false` | `".github/deployment_message.md"` | The repository-relative path to a trusted Markdown template for custom deployment messages. Branch Deploy fetches the file from the repository at the exact trusted workflow SHA; absolute paths, traversal segments, and runner filesystem paths are rejected. See the [custom deployment messages documentation](docs/custom-deployment-messages.md). | +| `sticky_locks` | `false` | `false` | If set to `true`, locks will not be released after a deployment run completes. This applies to both successful, and failed deployments. Sticky locks are also known as ["hubot style deployment locks"](./docs/hubot-style-deployment-locks.md). They will persist until they are manually released by a user, or if you configure [another workflow with the "unlock on merge" mode](./docs/unlock-on-merge.md) to remove them automatically on PR merge. | +| `sticky_locks_for_noop` | `false` | `false` | If set to `true`, then sticky_locks will also be used for noop deployments. This can be useful in some cases but it often leads to locks being left behind when users test noop deployments. | +| `disable_lock` | `false` | `false` | If set to `true`, deployments and noops do not inspect or acquire locks, post processing does not release locks, and interactive lock-related commands return an informational result without modifying lock state. Existing environment and global locks are ignored and left unchanged. Enable this only when concurrent deployments are safe. See the [disable lock documentation](docs/locks.md#disabling-locks). | +| `allow_sha_deployments` | `false` | `false` | If set to `true`, then you can deploy a specific sha instead of a branch. Example: `".deploy 1234567890abcdef1234567890abcdef12345678 to production"` - This is dangerous and potentially unsafe, [view the docs](docs/sha-deployments.md) to learn more | +| `disable_naked_commands` | `false` | `false` | If set to `true`, then naked commands will be disabled. Example: `.deploy` will not trigger a deployment. Instead, you must use `.deploy to production` to trigger a deployment. This is useful if you want to prevent accidental deployments from happening. View the [docs](docs/naked-commands.md) to learn more | | `successful_deploy_labels` | `false` | `""` | A comma separated list of labels to add to the pull request when a deployment is successful. Example: `"deployed,success"` | | `successful_noop_labels` | `false` | `""` | A comma separated list of labels to add to the pull request when a noop deployment is successful. Example: `"noop,success"` | | `failed_deploy_labels` | `false` | `""` | A comma separated list of labels to add to the pull request when a deployment fails. Example: `"failed,deploy-failed"` | | `failed_noop_labels` | `false` | `""` | A comma separated list of labels to add to the pull request when a noop deployment fails. Example: `"failed,noop-failed"` | -| `skip_successful_noop_labels_if_approved` | `false` | `"false"` | Whether or not the post run logic should skip adding successful noop labels if the pull request is approved. This can be useful if you add a label such as "ready-for-review" after a `.noop` completes but want to skip adding that label in situations where the pull request is already approved. | -| `skip_successful_deploy_labels_if_approved` | `false` | `"false"` | Whether or not the post run logic should skip adding successful deploy labels if the pull request is approved. This can be useful if you add a label such as "ready-for-review" after a `.deploy` completes but want to skip adding that label in situations where the pull request is already approved. | +| `skip_successful_noop_labels_if_approved` | `false` | `false` | Whether or not the post run logic should skip adding successful noop labels if the pull request is approved. This can be useful if you add a label such as "ready-for-review" after a `.noop` completes but want to skip adding that label in situations where the pull request is already approved. | +| `skip_successful_deploy_labels_if_approved` | `false` | `false` | Whether or not the post run logic should skip adding successful deploy labels if the pull request is approved. This can be useful if you add a label such as "ready-for-review" after a `.deploy` completes but want to skip adding that label in situations where the pull request is already approved. | | `enforced_deployment_order` | `false` | `""` | A comma separated list of environments that must be deployed in a specific order. Example: `"development,staging,production"`. If this is set then you cannot deploy to latter environments unless the former ones have a successful and active deployment on the latest commit first - See the [enforced deployment order docs](./docs/enforced-deployment-order.md) for more details | -| `use_security_warnings` | `false` | `"true"` | Whether or not to leave security related warnings in log messages during deployments. Default is `"true"` | -| `allow_non_default_target_branch_deployments` | `false` | `"false"` | Whether or not to allow deployments of pull requests that target a branch other than the default branch (aka stable branch) as their merge target. By default, this Action would reject the deployment of a branch named `feature-branch` if it was targeting `foo` instead of `main` (or whatever your default branch is). This option allows you to override that behavior and be able to deploy any branch in your repository regardless of the target branch. This option is potentially unsafe and should be used with caution as most default branches contain branch protection rules. Often times non-default branches do not contain these same branch protection rules. Follow along in this [issue thread](https://github.com/github/branch-deploy/issues/340) to learn more. | -| `deployment_confirmation` | `false` | `"false"` | Whether or not to require an additional confirmation before a deployment can continue. Default is `"false"`. If your project requires elevated security, it is highly recommended to enable this option - especially in open source projects where you might be deploying forks - [Deployment confirmation docs](./docs/deployment-confirmation.md) | -| `deployment_confirmation_timeout` | `false` | `60` | The number of seconds to wait for a deployment confirmation before timing out. Default is `60` seconds (1 minute). | +| `use_security_warnings` | `false` | `true` | Whether or not to leave security related warnings in log messages during deployments. Default is `true` | +| `allow_non_default_target_branch_deployments` | `false` | `false` | Whether or not to allow deployments of pull requests that target a branch other than the default branch (aka stable branch) as their merge target. By default, this Action would reject the deployment of a branch named `feature-branch` if it was targeting `foo` instead of `main` (or whatever your default branch is). This option allows you to override that behavior and be able to deploy any branch in your repository regardless of the target branch. This option is potentially unsafe and should be used with caution as most default branches contain branch protection rules. Often times non-default branches do not contain these same branch protection rules. Follow along in this [issue thread](https://github.com/github/branch-deploy/issues/340) to learn more. | +| `deployment_confirmation` | `false` | `false` | Whether or not to require an additional confirmation before a deployment can continue. Default is `false`. If your project requires elevated security, it is highly recommended to enable this option - especially in open source projects where you might be deploying forks - [Deployment confirmation docs](./docs/deployment-confirmation.md) | +| `deployment_confirmation_timeout` | `false` | `60` | The number of seconds to wait for a deployment confirmation before timing out. Must be a positive integer. Default is `60` seconds (1 minute). | ## Outputs ๐Ÿ“ค | Output | Description | | ------ | ----------- | -| `continue` | The string "true" if the deployment should continue, otherwise empty - Use this to conditionally control if your deployment should proceed or not - โญ The main output you should watch for when determining if a deployment shall carry on | +| `decision` | The preferred main action decision output. Values are `continue`, `complete`, `stop`, or `failure`. | +| `reason_code` | The preferred stable machine-readable reason code for the main action decision. | +| `result` | The preferred deterministic JSON string describing the versioned main action result. Parse it with `fromJSON(...)` in workflow expressions. | +| `continue` | Compatibility alias. The string "true" if the deployment should continue, otherwise empty - Use this to conditionally control if your deployment should proceed or not | | `fork` | The string "true" if the pull request is a fork, otherwise "false" | | `triggered` | The string "true" if the trigger was found, otherwise the string "false" | | `comment_body` | The comment body | @@ -333,7 +343,7 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d | `fork_label` | The API label field returned for the fork | | `fork_checkout` | The console command presented in the GitHub UI to checkout a given fork locally | | `fork_full_name` | The full name of the fork in "org/repo" format | -| `initial_reaction_id` | The reaction id for the initial reaction on the trigger comment | +| `initial_reaction_id` | The reaction id for the initial reaction on the trigger comment. This is empty when decorative reactions are disabled. | | `initial_comment_id` | The comment id for the "Deployment Triggered ๐Ÿš€" comment created by the action | | `actor_handle` | The handle of the user who triggered the action | | `global_lock_claimed` | The string "true" if the global lock was claimed | @@ -343,7 +353,7 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d | `review_decision` | The pull request review status. Can be one of a few values - examples: `APPROVED`, `REVIEW_REQUIRED`, `CHANGES_REQUESTED`, `skip_reviews`, `null` | | `is_outdated` | The string `"true"` if the branch is out-of-date, otherwise `"false"` | | `merge_state_status` | The status of the merge state. Can be one of a few values - examples: `"DIRTY"`, `"DRAFT"`, `"CLEAN"`, etc | -| `commit_status` | The status of the commit. Can be one of a few values - examples: `"SUCCESS"`, `null`, `"skip_ci"`, `"PENDING"`, `"FAILURE"` etc | +| `commit_status` | The status of the commit. Can be one of a few values - examples: `"SUCCESS"`, `null`, `"skip_ci"`, `"PENDING"`, `"FAILURE"`, or `"UNAVAILABLE"`. `UNAVAILABLE` blocks deployment because the complete CI status could not be verified. See the [v11 to v12 upgrade guide](docs/v11-to-v12-upgrade-guide.md). | | `approved_reviews_count` | The number of approved reviews on the pull request | | `needs_to_be_deployed` | A comma separated list of environments that need successful and active deployments before the current environment (that was requested) can be deployed. This output is tied to the `enforced_deployment_order` input option - See the [enforced deployment order docs](./docs/enforced-deployment-order.md) for more details | | `commit_verified` | The string `"true"` if the commit is verified, otherwise `"false"` | @@ -352,7 +362,9 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d ## Custom Deployment Messages โœ๏ธ -For documentation on how to customize the deployment messages, see the [custom deployment messages docs](./docs/custom-deployment-messages.md). +Set `deploy_message_path` to a repository-relative Markdown file such as `.github/deployment_message.md`. Branch Deploy fetches the template through GitHub's Contents API at the exact trusted workflow SHA rather than reading from the runner checkout, then renders it with a deliberately limited template grammar. Deployment output from `DEPLOY_MESSAGE` is available as `{{ results }}` and is inserted raw in one pass so output that resembles template syntax remains inert. + +For the supported variables, conditionals, escaping rules, and v12 migration examples, see the [custom deployment messages documentation](./docs/custom-deployment-messages.md). ## About Environments ๐ŸŒŽ @@ -454,7 +466,7 @@ Example: `"production|https://myapp.com,development|https://dev.myapp.com,stagin By enabling this option, you will get a "clickable" link on success (non-noop) deployment messages on pull requests. You will also be able to click the "View deployment" button in your repository's deployments page and be taken to the URL of the environment you deployed to. -If you wish to disable the "clickable" link on the successful deployment message, you can set the `environment_url_in_comment` input to `"false"`. +If you wish to disable the "clickable" link on the successful deployment message, you can set the `environment_url_in_comment` input to `false`. ## Rollbacks ๐Ÿ”„ @@ -468,7 +480,7 @@ The `` can be any branch you like but it is highly recommended th ## Security ๐Ÿ”’ -The IssueOps + branch-deploy model is significantly more secure than a traditional "deploy on merge" or "run on commit" model. Let's reference the workflow trigger that the branch-deploy model uses: +The IssueOps + Branch Deploy model has an important workflow-definition trust boundary. Consider the workflow trigger that this model commonly uses: ```yaml on: @@ -476,9 +488,11 @@ on: types: [created] ``` -Unlike the `on: pull_request` trigger, the `on: issue_comment` trigger only uses Actions workflow files from the default branch in GitHub. This means that a bad actor cannot open a PR with a malicious workflow edit and dump secrets, trigger bad deployments, or cause other issues. This means that any changes to the workflow files can be protected with branch protection rules to ensure only verified changes make it into your default branch. +Unlike the `on: pull_request` trigger, the `on: issue_comment` trigger uses the Actions workflow definition from the repository's default branch. A pull request therefore cannot change the workflow that responds to its comments, and branch protection can review and protect changes to that workflow. + +This protection applies only to the workflow definition. Pull request code checked out later remains controlled by that pull request. Running scripts, build tools, dependencies, or configuration from that checkout with access to secrets or a write-capable token can expose credentials or modify repository and deployment resources. -If your workflow checks out pull request code and then runs deployment helper scripts, templates, or other orchestration code from that checkout, review the [trusted checkout hardening guide](docs/trusted-checkouts.md). It explains how to run trusted helper code from your default branch while still deploying the exact working commit selected by branch-deploy. +If your workflow checks out pull request code, review the [trusted checkout hardening guide](docs/trusted-checkouts.md). It explains how to keep deployment helpers on a trusted default-branch checkout while deploying the exact working commit selected by Branch Deploy. Custom deployment templates are fetched separately from the repository at the exact trusted workflow SHA. To further harden your workflow files, it is strongly suggested to include the base permissions that this Action needs to run: @@ -503,12 +517,12 @@ It should also be noted that this Action has built in functions to check the per Here are some additional security best practices to consider: -- Set the `allow_forks` input option to `"false"` to completely prevent deployments to forks if you do not require them for your project. -- Set the `commit_verification` input option to `"true"` to enforce commit verification before a deployment can continue. This is an excellent way to enforce tighter security controls on your deployments. If a deployment is requested on a commit that does not have a verified signature, the deployment will be rejected. -- Ensure that your branch protection settings require that PRs have approvals before. This prevents users from deploying changes that have not been reviewed. -- Ensure that your branch protection settings require that PRs have some CI checks defined, and that those CI checks are required. This ensure that the code being deployed has passing CI checks. +- Leave the `allow_forks` input option at its default of `false` unless you intentionally support deployments from forked pull requests. +- Set the `commit_verification` input option to `true` to enforce commit verification before a deployment can continue. This is an excellent way to enforce tighter security controls on your deployments. If a deployment is requested on a commit that does not have a verified signature, the deployment will be rejected. +- Ensure that your branch protection settings require pull request approvals. This prevents users from deploying changes that have not been reviewed. +- Ensure that your branch protection settings define required CI checks. This ensures that the code being deployed has passing CI checks. - Set the [`deployment_confirmation: true`](./docs/deployment-confirmation.md) input option to require a final safety check of human approval before each deployment can continue. Ensure that you review the sha being used in the deployment confirmation comment with the sha that you expect to be deployed. -- Use a [trusted checkout](docs/trusted-checkouts.md) for deployment helper code and templates when your workflow also checks out pull request code for deployment. +- Use a [trusted checkout](docs/trusted-checkouts.md) for deployment helper code when your workflow also checks out pull request code for deployment. Branch Deploy independently fetches custom deployment templates at the exact trusted workflow SHA. ### Admins ๐Ÿ‘ฉโ€๐Ÿ”ฌ @@ -571,9 +585,9 @@ Checkout the [merge commit workflow strategy](docs/merge-commit-strategy.md) for ## Manual Deployment Control -If you need more fine tuned control over when the deployment status is set to `success` you can use the `skip_completing` option to prevent this Action from setting your deployment status to `success` after it completes. +If you need fine-grained control over completion, set `skip_completing: true` to bypass Branch Deploy's entire post-action completion path. -When using this option, you will need to manually set your deployment status depending on if you deployment succeeds or fails. +When using this option, your workflow is responsible for the final deployment status and any required completion comments, reactions, labels, and non-sticky lock cleanup. Branch Deploy does not perform those operations after the deployment jobs finish. An example workflow using this option can be found [here](https://github.com/github/branch-deploy/blob/main/docs/examples.md#multiple-jobs) @@ -663,15 +677,15 @@ Check out some of the links below to see how others are using this Action in the This section will cover a few suggestions and best practices that will help you when using this Action. 1. Suggest Updating Pull Request Branches - You should absolutely use this option when using the `branch-deploy` Action. This option can be found in your repository's `/settings` page - ![update-pr-branches](./docs//assets/update-branch-setting.png) + ![update-pr-branches](./docs/assets/update-branch-setting.png) 2. Enable Branch Protection Settings - It is always a good idea to enable branch protection settings for your repo, especially when using this Action 1. Require Pull Request Reviews - Enforce that pull requests have approvals, code owner approvals, and dismiss stale pull request approvals upon new commits ![use-pr-reviews](./docs/assets/pr-reviews.png) 2. Add Required Status Checks - Enforce that certain CI checks must pass before a pull request can be merged ![use-status-checks](./docs/assets/required-ci-checks.png) -3. If you don't need to deploy PR forks (perhaps your project is internal and not open source), you can set the `allow_forks` input to `"false"` to prevent deployments from running on forks. +3. If you need to deploy PR forks, explicitly set the `allow_forks` input to `true` and combine that with the normal review, CI, and trusted-checkout protections. 4. You should **always** (unless you have a certain restriction) use the `sha` output variable over the `ref` output variable when deploying. It is more reliable for deployments, and safer from a security perspective. More details about using commit SHAs for deployments can be found [here](./docs/deploying-commit-SHAs.md). -5. If your deployment workflow runs helper scripts or deployment message templates, consider using [trusted checkouts](docs/trusted-checkouts.md) so those helpers come from the protected default branch instead of the pull request checkout. +5. If your deployment workflow runs repository-owned helper scripts after checking out pull request code, use [trusted checkouts](docs/trusted-checkouts.md) so those helpers come from the protected default branch. Custom deployment templates do not need a checkout because Branch Deploy fetches them separately at the trusted workflow SHA. ## Alternate Command Syntax diff --git a/__tests__/action-contract.test.ts b/__tests__/action-contract.test.ts new file mode 100644 index 00000000..ffbc7f55 --- /dev/null +++ b/__tests__/action-contract.test.ts @@ -0,0 +1,313 @@ +import assert from 'node:assert/strict' +import {readFileSync, readdirSync} from 'node:fs' +import {relative, resolve} from 'node:path' +import {test} from 'node:test' +import {load} from 'js-yaml' +import { + ACTION_INPUT_KEYS, + ACTION_OUTPUT_KEYS, + ACTION_STATE_KEYS, + BOOLEAN_ACTION_INPUT_KEYS, + INTEGER_ACTION_INPUT_KEYS, + type ActionInputKey +} from '../src/action-io.ts' +import { + CHECKS_MODE_VALUES, + LITERAL_ACTION_INPUT_KEYS, + LITERAL_ACTION_INPUT_VALUES, + OUTDATED_MODE_VALUES, + UPDATE_BRANCH_VALUES +} from '../src/functions/inputs.ts' + +interface InputContract { + readonly default: string + readonly required: boolean +} + +const expectedInputContract = { + github_token: {default: '${{ github.token }}', required: true}, + status: {default: '${{ job.status }}', required: true}, + environment: {default: 'production', required: false}, + environment_targets: { + default: 'production,development,staging', + required: false + }, + draft_permitted_targets: {default: '', required: false}, + environment_urls: {default: '', required: false}, + environment_url_in_comment: {default: 'true', required: false}, + production_environments: {default: 'production', required: false}, + reaction: {default: 'eyes', required: false}, + trigger: {default: '.deploy', required: false}, + noop_trigger: {default: '.noop', required: false}, + lock_trigger: {default: '.lock', required: false}, + unlock_trigger: {default: '.unlock', required: false}, + help_trigger: {default: '.help', required: false}, + lock_info_alias: {default: '.wcid', required: false}, + permissions: {default: 'write,admin', required: true}, + commit_verification: {default: 'false', required: false}, + param_separator: {default: '|', required: false}, + global_lock_flag: {default: '--global', required: false}, + stable_branch: {default: 'main', required: false}, + update_branch: {default: 'warn', required: false}, + outdated_mode: {default: 'strict', required: false}, + required_contexts: {default: 'false', required: false}, + skip_ci: {default: '', required: false}, + checks: {default: 'all', required: false}, + ignored_checks: {default: '', required: false}, + skip_reviews: {default: '', required: false}, + allow_forks: {default: 'false', required: false}, + admins: {default: 'false', required: false}, + admins_pat: {default: 'false', required: false}, + merge_deploy_mode: {default: 'false', required: false}, + unlock_on_merge_mode: {default: 'false', required: false}, + skip_completing: {default: 'false', required: false}, + deploy_message_path: { + default: '.github/deployment_message.md', + required: false + }, + sticky_locks: {default: 'false', required: false}, + sticky_locks_for_noop: {default: 'false', required: false}, + disable_lock: {default: 'false', required: false}, + allow_sha_deployments: {default: 'false', required: false}, + disable_naked_commands: {default: 'false', required: false}, + successful_deploy_labels: {default: '', required: false}, + successful_noop_labels: {default: '', required: false}, + failed_deploy_labels: {default: '', required: false}, + failed_noop_labels: {default: '', required: false}, + skip_successful_noop_labels_if_approved: { + default: 'false', + required: false + }, + skip_successful_deploy_labels_if_approved: { + default: 'false', + required: false + }, + enforced_deployment_order: {default: '', required: false}, + use_security_warnings: {default: 'true', required: false}, + allow_non_default_target_branch_deployments: { + default: 'false', + required: false + }, + deployment_confirmation: {default: 'false', required: false}, + deployment_confirmation_timeout: {default: '60', required: false} +} as const satisfies Record + +const expectedBooleanInputKeys = [ + 'environment_url_in_comment', + 'commit_verification', + 'allow_forks', + 'merge_deploy_mode', + 'unlock_on_merge_mode', + 'skip_completing', + 'sticky_locks', + 'sticky_locks_for_noop', + 'disable_lock', + 'allow_sha_deployments', + 'disable_naked_commands', + 'skip_successful_noop_labels_if_approved', + 'skip_successful_deploy_labels_if_approved', + 'use_security_warnings', + 'allow_non_default_target_branch_deployments', + 'deployment_confirmation' +] as const satisfies readonly ActionInputKey[] + +const expectedIntegerInputKeys = [ + 'deployment_confirmation_timeout' +] as const satisfies readonly ActionInputKey[] + +const expectedLiteralInputKeys = [ + 'update_branch', + 'outdated_mode', + 'checks' +] as const satisfies readonly ActionInputKey[] + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new TypeError(`${label} must be an object`) + } + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function actionMetadata(): Record { + return requireRecord(load(readFileSync('action.yml', 'utf8')), 'action.yml') +} + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, {withFileTypes: true}).flatMap(entry => { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + return entry.isFile() && entry.name.endsWith('.ts') ? [path] : [] + }) +} + +function calledKeys(pattern: RegExp): Set { + const keys = new Set() + for (const path of sourceFiles(resolve('src'))) { + const source = readFileSync(path, 'utf8') + for (const match of source.matchAll(pattern)) { + const key = match.groups?.['key'] + if (key !== undefined) keys.add(key) + } + } + return keys +} + +test('action input and output registries exactly match action.yml', () => { + const metadata = actionMetadata() + const inputs = requireRecord(metadata['inputs'], 'action.yml inputs') + const outputs = requireRecord(metadata['outputs'], 'action.yml outputs') + + assert.deepStrictEqual( + [...ACTION_INPUT_KEYS].sort(), + Object.keys(inputs).sort() + ) + assert.deepStrictEqual( + [...ACTION_OUTPUT_KEYS].sort(), + Object.keys(outputs).sort() + ) + assert.strictEqual(ACTION_INPUT_KEYS.length, 50) + assert.strictEqual(ACTION_OUTPUT_KEYS.length, 41) +}) + +test('action input defaults, required flags, and accepted literals stay fixed', () => { + const inputs = requireRecord(actionMetadata()['inputs'], 'action.yml inputs') + const inputContract = Object.fromEntries( + Object.entries(inputs).map(([name, input]) => { + const definition = requireRecord(input, `input ${name}`) + return [ + name, + {default: definition['default'], required: definition['required']} + ] + }) + ) + + assert.deepStrictEqual(inputContract, expectedInputContract) + assert.deepStrictEqual(UPDATE_BRANCH_VALUES, ['disabled', 'warn', 'force']) + assert.deepStrictEqual(OUTDATED_MODE_VALUES, [ + 'pr_base', + 'default_branch', + 'strict' + ]) + assert.deepStrictEqual(CHECKS_MODE_VALUES, ['all', 'required']) +}) + +test('typed input registries stay complete and exact', () => { + assert.deepStrictEqual(BOOLEAN_ACTION_INPUT_KEYS, expectedBooleanInputKeys) + assert.strictEqual(BOOLEAN_ACTION_INPUT_KEYS.length, 16) + assert.deepStrictEqual(INTEGER_ACTION_INPUT_KEYS, expectedIntegerInputKeys) + assert.deepStrictEqual(LITERAL_ACTION_INPUT_KEYS, expectedLiteralInputKeys) + assert.deepStrictEqual(LITERAL_ACTION_INPUT_VALUES, { + update_branch: ['disabled', 'warn', 'force'], + outdated_mode: ['pr_base', 'default_branch', 'strict'], + checks: ['all', 'required'] + }) + + const registeredInputs: ReadonlySet = new Set(ACTION_INPUT_KEYS) + for (const key of [ + ...BOOLEAN_ACTION_INPUT_KEYS, + ...INTEGER_ACTION_INPUT_KEYS, + ...LITERAL_ACTION_INPUT_KEYS + ]) { + assert.strictEqual(registeredInputs.has(key), true) + } +}) + +test('runner entrypoints remain the Node 24 committed ESM bundle', () => { + const runs = requireRecord(actionMetadata()['runs'], 'action.yml runs') + assert.deepStrictEqual( + {using: runs['using'], main: runs['main'], post: runs['post']}, + { + using: 'node24', + main: 'dist/index.js', + post: 'dist/index.js' + } + ) +}) + +test('the committed distribution stays an exact ESM package', () => { + const distribution = readdirSync('dist', {withFileTypes: true}) + assert.deepStrictEqual(distribution.map(entry => entry.name).sort(), [ + 'index.js', + 'index.js.map', + 'licenses.txt', + 'package.json', + 'sourcemap-register.cjs', + 'sourcemap-register.js' + ]) + assert.strictEqual( + distribution.every(entry => entry.isFile()), + true + ) + + const packageMetadata: unknown = JSON.parse( + readFileSync('dist/package.json', 'utf8') + ) + assert.deepStrictEqual(packageMetadata, {type: 'module'}) + assert.match( + readFileSync('dist/index.js', 'utf8'), + /^import ['"]\.\/sourcemap-register\.cjs['"];?/u + ) +}) + +test('raw action input, output, and state calls stay inside action-io', () => { + const rawCalls = sourceFiles(resolve('src')) + .filter(path => relative(resolve('src'), path) !== 'action-io.ts') + .flatMap(path => { + const source = readFileSync(path, 'utf8') + const matches = [ + ...source.matchAll( + /\bcore\.(?:getInput|getBooleanInput|setOutput|saveState|getState)\s*\(/gu + ) + ] + return matches.map( + match => `${relative(process.cwd(), path)}:${String(match.index)}` + ) + }) + + assert.deepStrictEqual(rawCalls, []) +}) + +test('declared outputs and written outputs are exactly equal', () => { + const writtenOutputs = calledKeys( + /\bsetActionOutput\(\s*['"](?[^'"]+)['"]/g + ) + assert.deepStrictEqual( + [...writtenOutputs].sort(), + [...ACTION_OUTPUT_KEYS].sort() + ) +}) + +test('all post state has a producer and only initial_comment_id is write-only', () => { + const producedState = calledKeys( + /\bsaveActionState\(\s*['"](?[^'"]+)['"]/g + ) + const consumedState = calledKeys( + /\bgetActionState\(\s*['"](?[^'"]+)['"]/g + ) + const registeredState: ReadonlySet = new Set(ACTION_STATE_KEYS) + + assert.deepStrictEqual( + [...consumedState].filter(key => !producedState.has(key)), + [] + ) + assert.deepStrictEqual( + [...producedState].sort(), + [...ACTION_STATE_KEYS].sort() + ) + assert.deepStrictEqual( + [...producedState].filter(key => !registeredState.has(key)), + [] + ) + assert.deepStrictEqual( + [...consumedState].filter(key => !registeredState.has(key)), + [] + ) + assert.deepStrictEqual( + [...producedState].filter(key => !consumedState.has(key)), + ['initial_comment_id'] + ) +}) diff --git a/__tests__/action-io.test.ts b/__tests__/action-io.test.ts new file mode 100644 index 00000000..7e1a91b8 --- /dev/null +++ b/__tests__/action-io.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import { + assertCalledWith, + createMock, + installModuleMock +} from './node-test-helpers.ts' + +type ActionsCore = typeof import('../src/actions-core.ts') + +const getInputMock = createMock() +const getBooleanInputMock = createMock() +const setOutputMock = createMock() +const saveStateMock = createMock() +const getStateMock = createMock() + +installModuleMock(mock, new URL('../src/actions-core.ts', import.meta.url), { + getInput: getInputMock, + getBooleanInput: getBooleanInputMock, + setOutput: setOutputMock, + saveState: saveStateMock, + getState: getStateMock +}) + +const { + getActionInput, + getActionState, + getBooleanActionInput, + saveActionState, + setActionOutput +} = await import('../src/action-io.ts') + +beforeEach(() => { + getInputMock.mock.resetCalls() + getBooleanInputMock.mock.resetCalls() + setOutputMock.mock.resetCalls() + saveStateMock.mock.resetCalls() + getStateMock.mock.resetCalls() +}) + +test('typed input wrappers preserve toolkit arguments and results', () => { + getInputMock.mock.mockImplementation(() => 'production') + getBooleanInputMock.mock.mockImplementation(() => true) + + assert.strictEqual( + getActionInput('environment', {required: true}), + 'production' + ) + assert.strictEqual(getBooleanActionInput('allow_forks'), true) + assert.strictEqual( + getBooleanActionInput('allow_forks', { + required: true, + trimWhitespace: false + }), + true + ) + assertCalledWith(getInputMock, 'environment', {required: true}) + assertCalledWith(getBooleanInputMock, 'allow_forks', undefined) + assertCalledWith(getBooleanInputMock, 'allow_forks', { + required: true, + trimWhitespace: false + }) +}) + +test('typed output and state wrappers preserve toolkit value serialization', () => { + const structuredValue = {environment: 'production', noop: false} + getStateMock.mock.mockImplementation(() => 'false') + + setActionOutput('parsed_params', structuredValue) + saveActionState('noop', false) + + assertCalledWith(setOutputMock, 'parsed_params', structuredValue) + assertCalledWith(saveStateMock, 'noop', false) + assert.strictEqual(getActionState('noop'), 'false') + assertCalledWith(getStateMock, 'noop') +}) diff --git a/__tests__/actions-core.test.ts b/__tests__/actions-core.test.ts new file mode 100644 index 00000000..22544011 --- /dev/null +++ b/__tests__/actions-core.test.ts @@ -0,0 +1,346 @@ +import * as core from '../src/actions-core.ts' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {EOL, tmpdir} from 'node:os' +import {join} from 'node:path' +import {syncBuiltinESMExports} from 'node:module' +import { + afterEach, + beforeEach, + describe, + mock, + test, + type TestContext +} from 'node:test' +import type {Assert, Equal} from './node-test-helpers.ts' +import {stubEnv} from './node-test-helpers.ts' + +const originalExitCode = process.exitCode +let testDirectory: string + +beforeEach(() => { + testDirectory = mkdtempSync(join(tmpdir(), 'branch-deploy-core-')) + process.exitCode = undefined +}) + +afterEach(() => { + process.exitCode = originalExitCode + rmSync(testDirectory, {force: true, recursive: true}) + mock.restoreAll() + syncBuiltinESMExports() +}) + +function captureStdout(): { + read: () => string + exitCodes: () => readonly unknown[] +} { + const chunks: string[] = [] + const observedExitCodes: unknown[] = [] + + mock.method(process.stdout, 'write', (chunk: string | Uint8Array) => { + chunks.push(String(chunk)) + observedExitCodes.push(process.exitCode) + return true + }) + + return { + read: () => chunks.join(''), + exitCodes: () => observedExitCodes + } +} + +function createFileCommand( + context: TestContext, + name: 'GITHUB_OUTPUT' | 'GITHUB_STATE' +): string { + const filePath = join(testDirectory, name.toLowerCase()) + writeFileSync(filePath, '') + stubEnv(context, name, filePath) + return filePath +} + +function readFileCommandValue(filePath: string): string { + const [header, ...remainingLines] = readFileSync(filePath, 'utf8').split(EOL) + const delimiter = header?.split('<<')[1] + + assert.match(header ?? '', /^[^<]+< { + test('normalizes names, trims by default, and preserves whitespace on request', context => { + stubEnv(context, 'INPUT_DEPLOY_TARGET', ' production ') + + assert.strictEqual(core.getInput('deploy target'), 'production') + assert.strictEqual( + core.getInput('deploy target', {trimWhitespace: false}), + ' production ' + ) + assert.strictEqual(core.getInput('missing'), '') + }) + + test('enforces required inputs before trimming', context => { + assert.throws(() => core.getInput('missing', {required: true}), { + message: 'Input required and not supplied: missing' + }) + + stubEnv(context, 'INPUT_WHITESPACE', ' ') + assert.strictEqual(core.getInput('whitespace', {required: true}), '') + }) + + for (const [value, expected] of [ + ['true', true], + ['True', true], + ['TRUE', true], + ['false', false], + ['False', false], + ['FALSE', false] + ] as const) { + test(`parses the YAML boolean spelling ${value}`, context => { + stubEnv(context, 'INPUT_ENABLED', value) + assert.strictEqual(core.getBooleanInput('enabled'), expected) + }) + } + + test('rejects malformed boolean inputs with the toolkit error', context => { + stubEnv(context, 'INPUT_ENABLED', 'yes') + + assert.throws(() => core.getBooleanInput('enabled'), { + message: + 'Input does not meet YAML 1.2 "Core Schema" specification: enabled\n' + + 'Support boolean input list: `true | True | TRUE | false | False | FALSE`' + }) + }) + + test('trims boolean inputs unless trimming is disabled', context => { + stubEnv(context, 'INPUT_ENABLED', ' true ') + assert.strictEqual(core.getBooleanInput('enabled'), true) + assert.throws( + () => core.getBooleanInput('enabled', {trimWhitespace: false}), + { + message: + 'Input does not meet YAML 1.2 "Core Schema" specification: enabled\n' + + 'Support boolean input list: `true | True | TRUE | false | False | FALSE`' + } + ) + }) +}) + +describe('file commands', () => { + for (const [description, value, expected] of [ + ['string', 'plain', 'plain'], + ['boxed string', new String('boxed'), 'boxed'], + ['number', 42, '42'], + ['not-a-number', Number.NaN, 'null'], + ['infinity', Number.POSITIVE_INFINITY, 'null'], + ['boolean', false, 'false'], + ['object', {environment: 'production'}, '{"environment":"production"}'], + ['array', ['production', 2], '["production",2]'], + ['multiline unicode', 'first\r\nsecond\n๐Ÿš€', 'first\r\nsecond\n๐Ÿš€'], + ['null', null, ''], + ['undefined', undefined, ''] + ] as const) { + test(`serializes a ${description} output value`, context => { + const filePath = createFileCommand(context, 'GITHUB_OUTPUT') + + core.setOutput('result', value) + + assert.strictEqual(readFileCommandValue(filePath), expected) + }) + } + + test('writes state with the same heredoc protocol', context => { + const filePath = createFileCommand(context, 'GITHUB_STATE') + + core.saveState('deployment', {id: 123, active: true}) + + assert.strictEqual( + readFileCommandValue(filePath), + '{"id":123,"active":true}' + ) + }) + + test('appends multiline output and state records with unique delimiters', context => { + const outputPath = createFileCommand(context, 'GITHUB_OUTPUT') + const statePath = createFileCommand(context, 'GITHUB_STATE') + writeFileSync(outputPath, `existing-output${EOL}`) + writeFileSync(statePath, `existing-state${EOL}`) + const uuids = [ + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000003', + '00000000-0000-4000-8000-000000000004' + ] as const + let uuidIndex = 0 + mock.method(crypto, 'randomUUID', () => { + const uuid = uuids[uuidIndex] + uuidIndex += 1 + assert.ok(uuid !== undefined) + return uuid + }) + syncBuiltinESMExports() + + core.setOutput('first', 'one\r\ntwo\n๐Ÿš€') + core.setOutput('second', 'tail') + core.saveState('first-state', 'one\ntwo') + core.saveState('second-state', false) + + assert.strictEqual( + readFileSync(outputPath, 'utf8'), + `existing-output${EOL}` + + `first< { + stubEnv(context, 'GITHUB_OUTPUT', join(testDirectory, 'missing')) + assert.throws(() => core.setOutput('result', 'value'), { + message: `Missing file at path: ${join(testDirectory, 'missing')}` + }) + + stubEnv(context, 'GITHUB_STATE', join(testDirectory, 'missing-state')) + assert.throws(() => core.saveState('result', 'value'), { + message: `Missing file at path: ${join(testDirectory, 'missing-state')}` + }) + }) + + test('rejects delimiter collisions in names and values', context => { + createFileCommand(context, 'GITHUB_OUTPUT') + mock.method( + crypto, + 'randomUUID', + () => '00000000-0000-4000-8000-000000000000' + ) + syncBuiltinESMExports() + const delimiter = 'ghadelimiter_00000000-0000-4000-8000-000000000000' + + assert.throws(() => core.setOutput(`name-${delimiter}`, 'value'), { + message: `Unexpected input: name should not contain the delimiter "${delimiter}"` + }) + assert.throws(() => core.setOutput('name', `value-${delimiter}`), { + message: `Unexpected input: value should not contain the delimiter "${delimiter}"` + }) + assert.throws(() => core.setOutput('name', Symbol('value')), { + message: "Cannot read properties of undefined (reading 'includes')" + }) + }) +}) + +describe('stdout commands and logging', () => { + test('preserves output and state fallback bytes and escaping', context => { + const output = captureStdout() + stubEnv(context, 'GITHUB_OUTPUT', undefined) + stubEnv(context, 'GITHUB_STATE', undefined) + core.setOutput('missing-output', 'value') + core.saveState('missing-state', 'value') + process.env['GITHUB_OUTPUT'] = '' + process.env['GITHUB_STATE'] = '' + + core.setOutput('name:part,rest', 'line%\r\nend') + core.saveState('name:part,rest', 'line%\r\nend') + + assert.strictEqual( + output.read(), + `${EOL}::set-output name=missing-output::value${EOL}` + + `::save-state name=missing-state::value${EOL}` + + `${EOL}::set-output name=name%3Apart%2Crest::line%25%0D%0Aend${EOL}` + + `::save-state name=name%3Apart%2Crest::line%25%0D%0Aend${EOL}` + ) + }) + + test('serializes every supported value through the stdout fallback', context => { + const output = captureStdout() + stubEnv(context, 'GITHUB_OUTPUT', undefined) + stubEnv(context, 'GITHUB_STATE', undefined) + + for (const [name, value, expected] of [ + ['boxed', new String('boxed'), 'boxed'], + ['number', 42, '42'], + ['not-a-number', Number.NaN, 'null'], + ['infinity', Number.POSITIVE_INFINITY, 'null'], + ['boolean', false, 'false'], + ['object', {value: 'line%\r\nend'}, '{"value":"line%25\\r\\nend"}'], + ['array', ['production', 2], '["production",2]'], + ['null', null, ''], + ['undefined', undefined, ''] + ] as const) { + core.setOutput(name, value) + core.saveState(name, value) + + assert.ok( + output + .read() + .endsWith( + `${EOL}::set-output name=${name}::${expected}${EOL}` + + `::save-state name=${name}::${expected}${EOL}` + ) + ) + } + }) + + test('emits exact debug, warning, error, and informational bytes', () => { + const output = captureStdout() + + core.debug('debug%\r\nmessage') + core.warning(new Error('warning')) + core.warning('warning string') + core.error(new Error('error')) + core.error('error string') + core.info('plain%\r\ninfo') + + assert.strictEqual( + output.read(), + `::debug::debug%25%0D%0Amessage${EOL}` + + `::warning::Error: warning${EOL}` + + `::warning::warning string${EOL}` + + `::error::Error: error${EOL}` + + `::error::error string${EOL}` + + `plain%\r\ninfo${EOL}` + ) + }) + + test('sets the failure exit code before issuing the error command', () => { + const output = captureStdout() + + core.setFailed(new Error('failed')) + + assert.strictEqual(process.exitCode, 1) + assert.deepStrictEqual(output.exitCodes(), [1]) + assert.strictEqual(output.read(), `::error::Error: failed${EOL}`) + }) +}) + +test('state reads preserve the exact state environment key and value', context => { + stubEnv(context, 'STATE_mixed-name', ' raw state ') + + assert.strictEqual(core.getState('mixed-name'), ' raw state ') + assert.strictEqual(core.getState('MIXED-NAME'), '') +}) + +test('exports only the narrow consumed type surface', () => { + const assertType = ( + condition: Assert + ): void => assert.strictEqual(condition, true) + + assertType< + Equal + >(true) + assertType, string>>(true) + assertType, boolean>>(true) + assertType, void>>(true) + assertType, void>>(true) + assertType, string>>(true) + assertType, void>>(true) +}) diff --git a/__tests__/coverage-reporter.test.ts b/__tests__/coverage-reporter.test.ts new file mode 100644 index 00000000..09343d85 --- /dev/null +++ b/__tests__/coverage-reporter.test.ts @@ -0,0 +1,508 @@ +import assert from 'node:assert/strict' +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {test} from 'node:test' +import type {TestEvent} from 'node:test/reporters' +import {pathToFileURL} from 'node:url' +import { + default as coverageReporter, + inventoryExecutableSources, + reportCoverage, + validateCoverage, + validateTestSummary, + type CoverageRecord +} from '../tools/coverage-reporter.ts' + +const FULL_COVERAGE = { + totalLineCount: 10, + totalBranchCount: 4, + totalFunctionCount: 2, + coveredLineCount: 10, + coveredBranchCount: 4, + coveredFunctionCount: 2 +} as const + +function coverageRecord( + path: string, + overrides: Partial = {} +): CoverageRecord { + return {path, ...FULL_COVERAGE, ...overrides} +} + +type CoverageEvent = Extract +type SummaryEvent = Extract + +function coverageEvent(records: readonly CoverageRecord[]): CoverageEvent { + return { + type: 'test:coverage', + data: { + nesting: 0, + summary: { + files: records.map(record => ({ + ...record, + coveredLinePercent: 100, + coveredBranchPercent: 100, + coveredFunctionPercent: 100, + functions: [], + branches: [], + lines: [] + })), + thresholds: {line: 100, branch: 100, function: 100}, + totals: { + ...FULL_COVERAGE, + coveredLinePercent: 100, + coveredBranchPercent: 100, + coveredFunctionPercent: 100 + }, + workingDirectory: process.cwd() + } + } + } +} + +function summaryEvent( + counts: Partial = {}, + file: string | undefined = undefined +): SummaryEvent { + return { + type: 'test:summary', + data: { + counts: { + cancelled: 0, + passed: 1, + skipped: 0, + suites: 0, + tests: 1, + todo: 0, + topLevel: 1, + ...counts + }, + duration_ms: 1, + file, + success: true + } + } +} + +async function* events(...values: TestEvent[]): AsyncGenerator { + yield* values +} + +async function collectReporterOutput( + source: AsyncIterable, + root = process.cwd() +): Promise { + const output: string[] = [] + for await (const value of reportCoverage(source, root)) { + output.push(value) + } + return output +} + +test('inventories only executable TypeScript source', context => { + const root = mkdtempSync(join(tmpdir(), 'branch-deploy-coverage-')) + context.after(() => rmSync(root, {recursive: true, force: true})) + + for (const path of [ + 'src/main.ts', + 'src/nested/helper.ts', + 'src/types.ts', + 'src/vendor.d.ts', + 'src/readme.txt', + 'tools/acceptance/index.ts', + 'tools/check.ts' + ]) { + const absolutePath = resolve(root, path) + mkdirSync(dirname(absolutePath), {recursive: true}) + writeFileSync(absolutePath, '') + } + + assert.deepStrictEqual(inventoryExecutableSources(root), [ + 'src/main.ts', + 'src/nested/helper.ts', + 'tools/check.ts' + ]) +}) + +test('inventories acceptance harness source for acceptance coverage', context => { + const root = mkdtempSync(join(tmpdir(), 'branch-deploy-coverage-')) + context.after(() => rmSync(root, {recursive: true, force: true})) + + for (const path of [ + 'src/main.ts', + 'tools/acceptance/index.ts', + 'tools/acceptance/mock-github.ts', + 'tools/acceptance/types.ts', + 'tools/check.ts' + ]) { + const absolutePath = resolve(root, path) + mkdirSync(dirname(absolutePath), {recursive: true}) + writeFileSync(absolutePath, '') + } + + assert.deepStrictEqual(inventoryExecutableSources(root, 'acceptance'), [ + 'tools/acceptance/index.ts', + 'tools/acceptance/mock-github.ts' + ]) +}) + +test('accepts full coverage and ignores non-project records', () => { + const root = '/repo' + const toolsUrl = pathToFileURL('/repo/tools/check.ts') + toolsUrl.hash = 'source' + + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('src/main.ts'), + coverageRecord(toolsUrl.href), + coverageRecord('/repo/dist/index.ts'), + coverageRecord('/repo/tools/acceptance/index.ts'), + coverageRecord('/outside/vendor.ts'), + coverageRecord('/') + ], + root, + ['src/main.ts', 'tools/check.ts'] + ), + [] + ) +}) + +test('ignores traversals, source-directory lookalikes, and type-only records', () => { + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/../../outside.ts'), + coverageRecord('/repo/src-extra/main.ts'), + coverageRecord('/repo/tools-extra/check.ts'), + coverageRecord('/repo/src/types.ts'), + coverageRecord('/repo/src/vendor.d.ts'), + coverageRecord('/repo/tools/vendor.d.ts') + ], + '/repo', + [] + ), + [] + ) +}) + +test('normalizes encoded file URLs and detects equivalent duplicate records', () => { + assert.deepStrictEqual( + validateCoverage( + [coverageRecord('file:///repo/src/space%20name.ts?cache=1#source')], + '/repo', + ['src/space name.ts'] + ), + [] + ) + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('src/main.ts'), + coverageRecord('file:///repo/src/main.ts') + ], + '/repo', + ['src/main.ts'] + ), + ['src/main.ts: duplicate coverage records (2)'] + ) +}) + +test('validates acceptance coverage independently from unit coverage', () => { + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/main.ts'), + coverageRecord('/repo/tools/acceptance/index.ts'), + coverageRecord('/repo/tools/check.ts') + ], + '/repo', + ['tools/acceptance/index.ts', 'tools/acceptance/mock-github.ts'], + 'acceptance' + ), + ['tools/acceptance/mock-github.ts: missing coverage record'] + ) +}) + +test('uses the acceptance coverage scope from the environment', async context => { + const root = mkdtempSync(join(tmpdir(), 'branch-deploy-coverage-')) + context.after(() => rmSync(root, {recursive: true, force: true})) + + for (const path of [ + 'tools/acceptance/index.ts', + 'tools/acceptance/types.ts', + 'tools/check.ts' + ]) { + const absolutePath = resolve(root, path) + mkdirSync(dirname(absolutePath), {recursive: true}) + writeFileSync(absolutePath, '') + } + + const previousScope = process.env['BRANCH_DEPLOY_COVERAGE_SCOPE'] + process.env['BRANCH_DEPLOY_COVERAGE_SCOPE'] = 'acceptance' + context.after(() => { + if (previousScope === undefined) { + delete process.env['BRANCH_DEPLOY_COVERAGE_SCOPE'] + } else { + process.env['BRANCH_DEPLOY_COVERAGE_SCOPE'] = previousScope + } + }) + + assert.deepStrictEqual( + await collectReporterOutput( + events( + coverageEvent([ + coverageRecord(resolve(root, 'tools/acceptance/index.ts')) + ]), + summaryEvent() + ), + root + ), + [] + ) +}) + +test('reports missing and unexpected project sources', () => { + assert.deepStrictEqual( + validateCoverage([coverageRecord('/repo/src/unexpected.ts')], '/repo', [ + 'src/missing.ts' + ]), + [ + 'src/missing.ts: missing coverage record', + 'src/unexpected.ts: unexpected project source' + ] + ) +}) + +test('does not accept synthetic module-mock records as coverage proof', () => { + const encodedUrl = new URL('file:///repo/tools/check.ts') + encodedUrl.search = '?node-test%2Dmock=0' + + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/main.ts?node-test-mock=0'), + coverageRecord(encodedUrl.href) + ], + '/repo', + ['src/main.ts', 'tools/check.ts'] + ), + [ + 'src/main.ts: synthetic module-mock coverage cannot prove source coverage', + 'tools/check.ts: synthetic module-mock coverage cannot prove source coverage' + ] + ) +}) + +test('recognizes synthetic module mocks in every query position', () => { + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/first.ts?cache=1&node-test-mock=0'), + coverageRecord('file:///repo/src/second.ts?node-test-mock&cache=1') + ], + '/repo', + ['src/first.ts', 'src/second.ts'] + ), + [ + 'src/first.ts: synthetic module-mock coverage cannot prove source coverage', + 'src/second.ts: synthetic module-mock coverage cannot prove source coverage' + ] + ) +}) + +test('ignores a synthetic duplicate when real source coverage is present', () => { + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/main.ts'), + coverageRecord('file:///repo/src/main.ts?node-test-mock=0') + ], + '/repo', + ['src/main.ts'] + ), + [] + ) +}) + +test('rejects duplicate real source records', () => { + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/main.ts'), + coverageRecord('/repo/src/main.ts') + ], + '/repo', + ['src/main.ts'] + ), + ['src/main.ts: duplicate coverage records (2)'] + ) +}) + +test('reports every uncovered metric', () => { + assert.deepStrictEqual( + validateCoverage( + [ + coverageRecord('/repo/src/main.ts', { + coveredLineCount: 9, + coveredBranchCount: 3, + coveredFunctionCount: 1 + }) + ], + '/repo', + ['src/main.ts'] + ), + [ + 'src/main.ts: branch coverage is 3/4', + 'src/main.ts: function coverage is 1/2', + 'src/main.ts: line coverage is 9/10' + ] + ) +}) + +test('uses the source inventory by default', () => { + const root = process.cwd() + const expected = inventoryExecutableSources(root) + const records = expected.map(path => coverageRecord(resolve(root, path))) + + assert.deepStrictEqual(validateCoverage(records, root), []) +}) + +test('reports successful complete source coverage', async () => { + const root = process.cwd() + const expected = inventoryExecutableSources(root) + const records = expected.map(path => coverageRecord(resolve(root, path))) + + assert.deepStrictEqual( + await collectReporterOutput( + events(coverageEvent(records), summaryEvent()), + root + ), + [ + `coverage policy: ${expected.length} executable source files have 100% line, branch, and function coverage\n` + ] + ) +}) + +test('the default reporter combines spec output with the coverage policy', async () => { + const root = process.cwd() + const expected = inventoryExecutableSources(root) + const records = expected.map(path => coverageRecord(resolve(root, path))) + const output: string[] = [] + + for await (const value of coverageReporter( + events( + {type: 'test:watch:drained', data: undefined}, + summaryEvent({}, 'suite.test.ts'), + coverageEvent(records), + summaryEvent() + ) + )) { + output.push(value) + } + + const rendered = output.join('') + const policyResult = `coverage policy: ${expected.length} executable source files have 100% line, branch, and function coverage` + + assert.match(rendered, /start of coverage report/u) + assert.ok(rendered.includes(policyResult)) + assert.ok( + rendered.indexOf('end of coverage report') < rendered.indexOf(policyResult) + ) +}) + +test('the default reporter preserves coverage policy failures', async () => { + const root = process.cwd() + const expected = inventoryExecutableSources(root) + const records = expected + .slice(1) + .map(path => coverageRecord(resolve(root, path))) + const missingSource = expected[0] + assert.ok(missingSource !== undefined) + + await assert.rejects( + async () => { + for await (const output of coverageReporter( + events(coverageEvent(records), summaryEvent()) + )) { + assert.equal(typeof output, 'string') + } + }, + new RegExp( + `coverage policy failed:\\n${missingSource}: missing coverage record`, + 'u' + ) + ) +}) + +test('rejects a failed coverage policy', async () => { + const root = process.cwd() + const expected = inventoryExecutableSources(root) + const records = expected + .slice(1) + .map(path => coverageRecord(resolve(root, path))) + const missingSource = expected[0] + assert.ok(missingSource !== undefined) + + await assert.rejects( + collectReporterOutput(events(coverageEvent(records), summaryEvent()), root), + new RegExp( + `coverage policy failed:\\n${missingSource}: missing coverage record`, + 'u' + ) + ) +}) + +test('requires one coverage event', async () => { + await assert.rejects( + collectReporterOutput( + events({type: 'test:watch:drained', data: undefined}, summaryEvent()) + ), + /coverage policy: no test:coverage event received/u + ) +}) + +test('rejects multiple coverage events', async () => { + const event = coverageEvent([]) + await assert.rejects( + collectReporterOutput(events(event, event, summaryEvent())), + /coverage policy: multiple test:coverage events received/u + ) +}) + +test('requires an aggregate test summary', async () => { + await assert.rejects( + collectReporterOutput(events(coverageEvent([]))), + /test policy: no aggregate test:summary event received/u + ) +}) + +test('rejects skipped, todo, cancelled, or failed tests', async () => { + await assert.rejects( + collectReporterOutput( + events( + coverageEvent([]), + summaryEvent({ + cancelled: 1, + passed: 1, + skipped: 1, + tests: 4, + todo: 1 + }) + ) + ), + /test policy failed: passed=1\/4, skipped=1, todo=1, cancelled=1/u + ) +}) + +test('requires every test to pass without skips, todos, or cancellations', () => { + assert.strictEqual(validateTestSummary(summaryEvent().data), undefined) + assert.strictEqual( + validateTestSummary( + summaryEvent({cancelled: 1, passed: 1, skipped: 1, tests: 4, todo: 1}) + .data + ), + 'test policy failed: passed=1/4, skipped=1, todo=1, cancelled=1' + ) +}) diff --git a/__tests__/dependency-policy.test.ts b/__tests__/dependency-policy.test.ts new file mode 100644 index 00000000..3bf3f89a --- /dev/null +++ b/__tests__/dependency-policy.test.ts @@ -0,0 +1,171 @@ +import {readFileSync} from 'node:fs' +import assert from 'node:assert/strict' +import {test} from 'node:test' + +const EXPECTED_RUNTIME_DEPENDENCIES = { + '@actions/github': '9.0.0', + '@octokit/plugin-retry': '8.0.3', + 'yargs-parser': '22.0.0' +} as const satisfies Record + +const EXPECTED_DEVELOPMENT_DEPENDENCIES = { + '@types/node': '24.13.2', + '@vercel/ncc': '0.44.0', + 'js-yaml': '4.3.0', + prettier: '3.8.1', + typescript: '5.9.3' +} as const satisfies Record + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new TypeError(`${label} must be an object`) + } + + return value +} + +function readJson(path: string): Record { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + return requireRecord(parsed, path) +} + +test('direct dependencies are approved, exact, and locked', () => { + const packageJson = readJson('package.json') + const packageLock = readJson('package-lock.json') + const lockPackages = requireRecord( + packageLock['packages'], + 'package-lock.json packages' + ) + const lockRoot = requireRecord( + lockPackages[''], + 'package-lock.json root package' + ) + + assert.deepStrictEqual( + packageJson['dependencies'], + EXPECTED_RUNTIME_DEPENDENCIES + ) + assert.deepStrictEqual( + packageJson['devDependencies'], + EXPECTED_DEVELOPMENT_DEPENDENCIES + ) + assert.strictEqual(packageJson['overrides'], undefined) + assert.deepStrictEqual( + lockRoot['dependencies'], + EXPECTED_RUNTIME_DEPENDENCIES + ) + assert.deepStrictEqual( + lockRoot['devDependencies'], + EXPECTED_DEVELOPMENT_DEPENDENCIES + ) +}) + +test('resolved packages preserve public integrity and install-script policy', () => { + const packageLock = readJson('package-lock.json') + const lockPackages = requireRecord( + packageLock['packages'], + 'package-lock.json packages' + ) + const violations: string[] = [] + const installScripts: { + readonly path: string + readonly dev: unknown + readonly optional: unknown + }[] = [] + + for (const [path, value] of Object.entries(lockPackages)) { + if (path === '') continue + const entry = requireRecord(value, path) + const resolved = entry['resolved'] + const integrity = entry['integrity'] + const version = entry['version'] + const license = entry['license'] + + if ( + !/^node_modules\/(?:@[^/]+\/)?[^/]+(?:\/node_modules\/(?:@[^/]+\/)?[^/]+)*$/u.test( + path + ) + ) { + violations.push(`${path} has an invalid package path`) + } + + if ( + typeof resolved !== 'string' || + !resolved.startsWith('https://registry.npmjs.org/') + ) { + violations.push(`${path} has a non-public resolution`) + } + if ( + typeof integrity !== 'string' || + !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(integrity) + ) { + violations.push(`${path} has no valid sha512 integrity digest`) + } + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/u.test(version)) { + violations.push(`${path} has no exact resolved version`) + } + if (typeof license !== 'string' || license.trim() === '') { + violations.push(`${path} has no license`) + } + for (const field of [ + 'link', + 'devOptional', + 'optionalDependencies', + 'peerDependenciesMeta', + 'bundleDependencies', + 'bundledDependencies', + 'inBundle' + ] as const) { + if (entry[field] !== undefined) { + violations.push(`${path} has an unexpected ${field} field`) + } + } + for (const field of ['dependencies', 'peerDependencies'] as const) { + const dependencies = entry[field] + if (dependencies === undefined) continue + const ranges = requireRecord(dependencies, `${path} ${field}`) + for (const [name, range] of Object.entries(ranges)) { + if ( + typeof range !== 'string' || + /^(?:(?:git(?:\+[^:]+)?|github|gitlab|bitbucket|gist|file|link|workspace|https?|ssh):|git@|[^@\s/]+\/[^@\s/]+(?:#|$))/iu.test( + range + ) + ) { + violations.push(`${path} ${field}.${name} has a non-registry range`) + } + } + } + if (entry['hasInstallScript'] === true) { + installScripts.push({ + path, + dev: entry['dev'], + optional: entry['optional'] + }) + } + } + + const resolved = Object.entries(lockPackages).filter(([path]) => path !== '') + const runtime = resolved.filter(([, value]) => { + const entry = requireRecord(value, 'lockfile package') + return entry['dev'] !== true + }) + const development = resolved.filter(([, value]) => { + const entry = requireRecord(value, 'lockfile package') + return entry['dev'] === true + }) + const optional = resolved.filter(([, value]) => { + const entry = requireRecord(value, 'lockfile package') + return entry['optional'] === true + }) + + assert.deepStrictEqual(violations, []) + assert.deepStrictEqual(installScripts, []) + assert.strictEqual(resolved.length, 28) + assert.strictEqual(runtime.length, 21) + assert.strictEqual(development.length, 7) + assert.strictEqual(optional.length, 0) +}) diff --git a/__tests__/docs-security.test.ts b/__tests__/docs-security.test.ts new file mode 100644 index 00000000..088ce7a8 --- /dev/null +++ b/__tests__/docs-security.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict' +import {readFileSync, readdirSync} from 'node:fs' +import {join} from 'node:path' +import {test} from 'node:test' + +const markdownFiles = [ + 'README.md', + ...readdirSync('docs', {withFileTypes: true}) + .filter(entry => entry.isFile() && entry.name.endsWith('.md')) + .map(entry => join('docs', entry.name)) +] + +const documentedWorkflowFiles = [ + ...markdownFiles, + '.github/workflows/old/sample-workflow.yml' +] + +function checkoutSteps(path: string) { + const lines = readFileSync(path, 'utf8') + .split('\n') + .map(line => (path.endsWith('.yml') ? line.replace(/^# ?/, '') : line)) + const steps = [] + + for (const [index, line] of lines.entries()) { + if (!line.includes('uses: actions/checkout@')) continue + + const indentation = /^\s*/.exec(line)?.[0].length ?? 0 + const stepIndentation = line.trimStart().startsWith('- uses:') + ? indentation + : Math.max(0, indentation - 2) + let end = index + 1 + + while (end < lines.length) { + const nextLine = lines[end] + if (nextLine === undefined) break + const nextStep = /^(\s*)-\s/.exec(nextLine) + if (nextLine.trim() === '```') break + if ( + (nextStep?.[1]?.length ?? Number.POSITIVE_INFINITY) <= stepIndentation + ) + break + end += 1 + } + + steps.push({ + path, + line: index + 1, + body: lines.slice(index, end).join('\n') + }) + } + + return steps +} + +function documentedLines(path: string): readonly string[] { + return readFileSync(path, 'utf8') + .split('\n') + .map(line => (path.endsWith('.yml') ? line.replace(/^# ?/, '') : line)) +} + +function unsafeInlineScriptLines(lines: readonly string[]): number[] { + const unsafe: number[] = [] + + for (const [index, line] of lines.entries()) { + const script = /^(\s*)(?:run|script):\s*(.*)$/.exec(line) + if (script === null) continue + + const indentation = script[1]?.length ?? 0 + let end = index + 1 + if (/^[|>][+-]?(?:\s+#.*)?$/u.test(script[2] ?? '')) { + while (end < lines.length) { + const nextLine = lines[end] + if (nextLine === undefined) break + const nextIndentation = /^\s*/.exec(nextLine)?.[0].length ?? 0 + if (nextLine.trim() !== '' && nextIndentation <= indentation) break + end += 1 + } + } + + const body = lines.slice(index, end).join('\n') + if ( + /\$\{\{[^}]*\b(?:(?:steps|needs)\.[^}]*\.outputs\.|env\.|github\.event\.|inputs\.|matrix\.|secrets\.)[^}]*\}\}/u.test( + body + ) + ) { + unsafe.push(index + 1) + } + } + + return unsafe +} + +function fixedDeploymentDelimiters(source: string): string[] { + return source.match(/DEPLOY_MESSAGE<<[A-Za-z_][A-Za-z0-9_.-]*/gu) ?? [] +} + +test('documented checkout steps do not persist credentials', () => { + const checkouts = documentedWorkflowFiles.flatMap(checkoutSteps) + const unsafeCheckouts = checkouts + .filter( + ({body}) => !/^\s*persist-credentials:\s*false\s*(?:#.*)?$/m.test(body) + ) + .map(({path, line}) => `${path}:${line}`) + + assert.ok(checkouts.length > 0) + assert.deepStrictEqual(unsafeCheckouts, []) +}) + +test('documented inline scripts do not interpolate untrusted expressions', () => { + const unsafeScripts: string[] = [] + + for (const path of documentedWorkflowFiles) { + for (const line of unsafeInlineScriptLines(documentedLines(path))) { + unsafeScripts.push(`${path}:${line}`) + } + } + + assert.deepStrictEqual(unsafeScripts, []) +}) + +test('the inline-script scanner rejects literal, folded, and inline expressions', () => { + for (const source of [ + 'run: echo "${{ github.event.comment.body }}"', + 'run: |\n echo "${{ steps.branch-deploy.outputs.params }}"', + 'run: |-\n echo "${{ env.VALUE }}"', + 'run: >\n echo "${{ needs.deploy.outputs.result }}"', + 'run: >-\n echo "${{ matrix.value }}"', + 'script: >+\n console.log("${{ inputs.value }}")', + 'script: |\n console.log("${{ secrets.VALUE }}")' + ]) { + assert.deepStrictEqual(unsafeInlineScriptLines(source.split('\n')), [1]) + } + + assert.deepStrictEqual( + unsafeInlineScriptLines([ + 'env:', + ' PARAMS: ${{ steps.branch-deploy.outputs.params }}', + 'run: |', + ' printf \'%s\\n\' "$PARAMS"' + ]), + [] + ) +}) + +test('documented deployment messages do not use fixed delimiters', () => { + const unsafeDelimiters = documentedWorkflowFiles.flatMap(path => + fixedDeploymentDelimiters(readFileSync(path, 'utf8')).map( + delimiter => `${path}:${delimiter}` + ) + ) + + assert.deepStrictEqual(unsafeDelimiters, []) +}) + +test('the deployment-message scanner rejects every fixed delimiter', () => { + assert.deepStrictEqual( + fixedDeploymentDelimiters( + [ + 'echo \'DEPLOY_MESSAGE<> "$GITHUB_ENV"', + 'echo \'DEPLOY_MESSAGE<> "$GITHUB_ENV"', + 'printf \'%s\\n\' "DEPLOY_MESSAGE< JSON.parse('{}')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-any-concise-return.ts:1:15: no-unsafe-any: unsafe any flow" + ] + }, + { + "name": "unsafe-any-contained-return", + "source": "const value = (): unknown => JSON.parse('{}')\n", + "expected": [] + }, + { + "name": "exported-indirect-function", + "source": "function value() { return true }\nexport {value}\n", + "expected": [ + "__tests__/fixtures/typescript-policy/exported-indirect-function.ts:1:1: export-return-type: exported function needs a return type" + ] + }, + { + "name": "exported-default-arrow", + "source": "export default () => true\n", + "expected": [ + "__tests__/fixtures/typescript-policy/exported-default-arrow.ts:1:16: export-return-type: exported function needs a return type" + ] + }, + { + "name": "exported-object-method", + "source": "export const api = {value() { return true }}\n", + "expected": [ + "__tests__/fixtures/typescript-policy/exported-object-method.ts:1:21: export-return-type: exported function needs a return type" + ] + }, + { + "name": "exported-class-method", + "source": "export class Api { value() { return true } }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/exported-class-method.ts:1:20: export-return-type: exported function needs a return type" + ] + }, + { + "name": "exported-typed-arrow", + "source": "type Factory = () => boolean\nexport const value: Factory = () => true\n", + "expected": [] + }, + { + "name": "object-stringification", + "source": "const value: object = {}\nString(value)\n", + "expected": [ + "__tests__/fixtures/typescript-policy/object-stringification.ts:2:1: safe-string-operations: object needs explicit serialization" + ] + }, + { + "name": "array-stringification", + "source": "const value: string[] = []\nString(value)\n", + "expected": [ + "__tests__/fixtures/typescript-policy/array-stringification.ts:2:1: safe-string-operations: object needs explicit serialization" + ] + }, + { + "name": "custom-stringification", + "source": "const value: {toString(): string} = {toString: () => 'ok'}\nString(value)\n", + "expected": [] + }, + { + "name": "shadowed-string", + "source": "const String = (_value: object): string => 'ok'\nString({})\n", + "expected": [] + }, + { + "name": "nested-condition-assignment", + "source": "let value = false\nif (() => { value = true; return value }) {}\n", + "expected": [] + }, + { + "name": "direct-condition-assignment", + "source": "let value = false\nif ((value = true)) {}\n", + "expected": [ + "__tests__/fixtures/typescript-policy/direct-condition-assignment.ts:2:6: control-flow: assignment in condition" + ] + }, + { + "name": "local-finally-break", + "source": "try {} finally { for (;;) { break } }\n", + "expected": [] + }, + { + "name": "caught-finally-throw", + "source": "try {} finally { try { throw new Error() } catch {} }\n", + "expected": [] + }, + { + "name": "escaping-finally-break", + "source": "outer: for (;;) { try {} finally { break outer } }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/escaping-finally-break.ts:1:36: control-flow: abrupt completion in finally" + ] + }, + { + "name": "escaping-finally-return", + "source": "function value(): void { try {} finally { return } }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/escaping-finally-return.ts:1:43: control-flow: abrupt completion in finally" + ] + }, + { + "name": "deprecated-declaration", + "source": "class Api {\n/** @deprecated */\nvalue(): void {}\n}\n", + "expected": [] + }, + { + "name": "shadowed-globals", + "source": "const eval = (_value: string): void => undefined\nconst Function = (_value: string): void => undefined\nconst require = (_value: string): void => undefined\nconst NaN = 1\nclass Promise { constructor(_value: () => unknown) {} }\neval('x')\nFunction('x')\nrequire('x')\nNaN === 1\nnew Promise(async () => { await globalThis.Promise.resolve() })\n", + "expected": [] + }, + { + "name": "global-require", + "source": "require('value')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-require.ts:1:1: module-syntax: CommonJS syntax is prohibited" + ] + }, + { + "name": "global-indirect-eval", + "source": "(0, eval)('value')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-indirect-eval.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "global-function-construction", + "source": "Function('return 1')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-function-construction.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "global-nan-comparison", + "source": "NaN === 1\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-nan-comparison.ts:1:1: control-flow: use Number.isNaN" + ] + }, + { + "name": "global-promise-executor", + "source": "new Promise(async resolve => { await Promise.resolve(); resolve() })\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-promise-executor.ts:1:1: promise-safety: floating Promise", + "__tests__/fixtures/typescript-policy/global-promise-executor.ts:1:13: promise-safety: async Promise executor", + "__tests__/fixtures/typescript-policy/global-promise-executor.ts:1:13: promise-safety: Promise used as void callback" + ] + }, + { + "name": "unsafe-any-block-return", + "source": "function value(): string { return JSON.parse('{}') }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-any-block-return.ts:1:28: no-unsafe-any: unsafe any flow" + ] + }, + { + "name": "unsafe-any-inferred-return", + "source": "function value() { return JSON.parse('{}') }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-any-inferred-return.ts:1:20: no-unsafe-any: unsafe any flow" + ] + }, + { + "name": "unsafe-any-contextual-arrow", + "source": "const value: () => string = () => JSON.parse('{}')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-any-contextual-arrow.ts:1:29: no-unsafe-any: unsafe any flow" + ] + }, + { + "name": "tuple-stringification", + "source": "const value: [string] = ['x']\nString(value)\n", + "expected": [ + "__tests__/fixtures/typescript-policy/tuple-stringification.ts:2:1: safe-string-operations: object needs explicit serialization" + ] + }, + { + "name": "global-this-string", + "source": "globalThis.String({})\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-this-string.ts:1:1: safe-string-operations: object needs explicit serialization" + ] + }, + { + "name": "global-this-eval", + "source": "globalThis.eval('value')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-this-eval.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "conditional-global-callee", + "source": "const local = (_value: string): void => undefined\n;(true ? local : eval)('one')\n;(true ? eval : local)('two')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/conditional-global-callee.ts:2:2: dangerous-eval: dynamic evaluation is prohibited", + "__tests__/fixtures/typescript-policy/conditional-global-callee.ts:3:2: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "escaping-finally-throw", + "source": "try {} finally { throw new Error() }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/escaping-finally-throw.ts:1:18: control-flow: abrupt completion in finally" + ] + }, + { + "name": "unresolved-export", + "source": "export {foo} from './missing.js'\n", + "expected": [] + }, + { + "name": "invalid-finally-break", + "source": "try {} finally { break missing }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/invalid-finally-break.ts:1:18: control-flow: abrupt completion in finally" + ] + }, + { + "name": "unsafe-any-callee", + "source": "JSON.parse('{}')()\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-any-callee.ts:1:1: no-unsafe-any: unsafe any flow" + ] + }, + { + "name": "suppression-block-expect-error", + "source": "/* @ts-expect-error */\nconst value = 1\n", + "expected": [ + "__tests__/fixtures/typescript-policy/suppression-block-expect-error.ts:1:4: no-suppressions: TypeScript suppression directive" + ] + }, + { + "name": "suppression-line-nocheck", + "source": "// @ts-nocheck\nconst value = 1\n", + "expected": [ + "__tests__/fixtures/typescript-policy/suppression-line-nocheck.ts:1:4: no-suppressions: TypeScript suppression directive" + ] + }, + { + "name": "suppression-text-in-template", + "source": "const value = `@ts-ignore`\n", + "expected": [] + }, + { + "name": "global-optional-eval", + "source": "eval?.('value')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-optional-eval.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "global-asserted-eval", + "source": "(eval as typeof eval)('value')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-asserted-eval.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "global-new-function", + "source": "new Function('return 1')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-new-function.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "global-this-function", + "source": "globalThis.Function('return 1')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-this-function.ts:1:1: dangerous-eval: dynamic evaluation is prohibited" + ] + }, + { + "name": "promise-async-foreach", + "source": "[1].forEach(async value => { await Promise.resolve(value) })\n", + "expected": [ + "__tests__/fixtures/typescript-policy/promise-async-foreach.ts:1:13: promise-safety: Promise used as void callback" + ] + }, + { + "name": "promise-nested-await", + "source": "async function value(): Promise { const nested = async (): Promise => { await Promise.resolve() }; void nested }\n", + "expected": [ + "__tests__/fixtures/typescript-policy/promise-nested-await.ts:1:1: promise-safety: async function requires await" + ] + }, + { + "name": "promise-void-expression", + "source": "void Promise.resolve()\n", + "expected": [] + }, + { + "name": "promise-for-await", + "source": "async function value(): Promise { for await (const item of []) { void item } }\n", + "expected": [] + }, + { + "name": "nullable-string-condition", + "source": "declare const value: '' | 'production' | undefined\nif (value) {}\n", + "expected": [ + "__tests__/fixtures/typescript-policy/nullable-string-condition.ts:2:5: strict-boolean: condition must have a boolean type" + ] + }, + { + "name": "nullable-object-condition", + "source": "declare const value: {active: true} | undefined\nif (value) {}\n", + "expected": [] + }, + { + "name": "unsafe-any-element-access", + "source": "JSON.parse('{}')['value']\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-any-element-access.ts:1:1: no-unsafe-any: unsafe any flow" + ] + }, + { + "name": "global-nan-reversed-comparison", + "source": "1 < NaN\n", + "expected": [ + "__tests__/fixtures/typescript-policy/global-nan-reversed-comparison.ts:1:1: control-flow: use Number.isNaN" + ] + }, + { + "name": "unsafe-prototype-enumerable", + "source": "({}).propertyIsEnumerable('value')\n", + "expected": [ + "__tests__/fixtures/typescript-policy/unsafe-prototype-enumerable.ts:1:1: control-flow: unsafe prototype method call" + ] + } +] diff --git a/__tests__/functions/actions-status.test.js b/__tests__/functions/actions-status.test.js deleted file mode 100644 index b33d97b9..00000000 --- a/__tests__/functions/actions-status.test.js +++ /dev/null @@ -1,205 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import {actionStatus} from '../../src/functions/action-status.js' -import {truncateCommentBody} from '../../src/functions/truncate-comment-body.js' -import {API_HEADERS} from '../../src/functions/api-headers.js' - -var context -var octokit -beforeEach(() => { - vi.clearAllMocks() - - process.env.GITHUB_SERVER_URL = 'https://github.com' - process.env.GITHUB_RUN_ID = '12345' - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - id: '1' - } - } - } - - octokit = { - rest: { - reactions: { - createForIssueComment: vi.fn().mockReturnValueOnce({ - data: {} - }), - deleteForIssueComment: vi.fn().mockReturnValueOnce({ - data: {} - }) - }, - issues: { - createComment: vi.fn().mockReturnValueOnce({ - data: {} - }) - } - } - } -}) - -test('adds a successful status message for a deployment', async () => { - expect( - await actionStatus(context, octokit, 123, 'Everything worked!', true) - ).toBe(undefined) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: 'Everything worked!', - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - content: 'rocket', - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - owner: 'corp', - reaction_id: 123, - repo: 'test', - headers: API_HEADERS - }) -}) - -test('adds a successful status message for a deployment (with alt message)', async () => { - expect( - await actionStatus(context, octokit, 123, 'Everything worked!', true, true) - ).toBe(undefined) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: 'Everything worked!', - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - content: '+1', - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - owner: 'corp', - reaction_id: 123, - repo: 'test', - headers: API_HEADERS - }) -}) - -test('adds a failure status message for a deployment', async () => { - expect( - await actionStatus(context, octokit, 123, 'Everything failed!', false) - ).toBe(undefined) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: 'Everything failed!', - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - content: '-1', - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - owner: 'corp', - reaction_id: 123, - repo: 'test', - headers: API_HEADERS - }) -}) - -test('uses default log url when the "message" variable is empty for failures', async () => { - expect(await actionStatus(context, octokit, 123, '', false)).toBe(undefined) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: 'Unknown error, [check logs](https://github.com/corp/test/actions/runs/12345) for more details.', - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - content: '-1', - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - owner: 'corp', - reaction_id: 123, - repo: 'test', - headers: API_HEADERS - }) -}) - -test('uses default log url when the "message" variable is empty for a success', async () => { - expect(await actionStatus(context, octokit, 123, '', true)).toBe(undefined) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: 'Unknown error, [check logs](https://github.com/corp/test/actions/runs/12345) for more details.', - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - content: 'rocket', - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - owner: 'corp', - reaction_id: 123, - repo: 'test', - headers: API_HEADERS - }) -}) - -test('truncates the message when it is too large for an issue comment', async () => { - const message = 'a'.repeat(65538) - expect(await actionStatus(context, octokit, 123, message, true)).toBe( - undefined - ) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: truncateCommentBody(message), - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - content: 'rocket', - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({ - comment_id: '1', - owner: 'corp', - reaction_id: 123, - repo: 'test', - headers: API_HEADERS - }) -}) diff --git a/__tests__/functions/actions-status.test.ts b/__tests__/functions/actions-status.test.ts new file mode 100644 index 00000000..d9995f62 --- /dev/null +++ b/__tests__/functions/actions-status.test.ts @@ -0,0 +1,325 @@ +import {beforeEach, mock, test, type Mock} from 'node:test' +import type { + ActionStatusOctokit, + ActionStatusRequest +} from '../../src/functions/action-status.ts' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import { + assertNotCalled, + assertCalledWith, + createMock, + stubEnv, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const debugMock = createMock() +const warningMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + warning: warningMock +}) + +const {actionStatus} = await import('../../src/functions/action-status.ts') +const {truncateCommentBody} = + await import('../../src/functions/truncate-comment-body.ts') + +let context: ActionStatusRequest['context'] +let octokit: ActionStatusRequest['octokit'] +let createForIssueCommentMock: Mock< + ActionStatusOctokit['rest']['reactions']['createForIssueComment'] +> +let deleteForIssueCommentMock: Mock< + ActionStatusOctokit['rest']['reactions']['deleteForIssueComment'] +> +let createCommentMock: Mock< + ActionStatusOctokit['rest']['issues']['createComment'] +> + +beforeEach(testContext => { + if (!('after' in testContext)) { + throw new TypeError('expected a test context') + } + debugMock.mock.resetCalls() + warningMock.mock.resetCalls() + createForIssueCommentMock = createMock() + deleteForIssueCommentMock = createMock() + createCommentMock = createMock() + + stubEnv(testContext, 'GITHUB_SERVER_URL', 'https://github.com') + stubEnv(testContext, 'GITHUB_RUN_ID', '12345') + + context = createIssueCommentContext({ + repo: {owner: 'corp', repo: 'test'}, + issue: {number: 1}, + payload: {comment: {id: 1}} + }) + + octokit = { + rest: { + reactions: { + createForIssueComment: createForIssueCommentMock, + deleteForIssueComment: deleteForIssueCommentMock + }, + issues: { + createComment: createCommentMock + } + } + } satisfies ActionStatusOctokit +}) + +test('adds a successful status message for a deployment', async () => { + await actionStatus({ + context, + octokit, + reactionId: 123, + message: 'Everything worked!', + result: 'success' + }) + assertCalledWith(createCommentMock, { + body: 'Everything worked!', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: 'rocket', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(deleteForIssueCommentMock, { + comment_id: 1, + owner: 'corp', + reaction_id: 123, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('adds a successful status message for a deployment (with alt message)', async () => { + await actionStatus({ + context, + octokit, + reactionId: 123, + message: 'Everything worked!', + result: 'alternate-success' + }) + assertCalledWith(createCommentMock, { + body: 'Everything worked!', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: '+1', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(deleteForIssueCommentMock, { + comment_id: 1, + owner: 'corp', + reaction_id: 123, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('adds a failure status message for a deployment', async () => { + await actionStatus({ + context, + octokit, + reactionId: 123, + message: 'Everything failed!', + result: 'failure' + }) + assertCalledWith(createCommentMock, { + body: 'Everything failed!', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: '-1', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(deleteForIssueCommentMock, { + comment_id: 1, + owner: 'corp', + reaction_id: 123, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('uses default log url when the "message" variable is empty for failures', async () => { + await actionStatus({context, octokit, reactionId: 123, message: ''}) + assertCalledWith(createCommentMock, { + body: 'Unknown error, [check logs](https://github.com/corp/test/actions/runs/12345) for more details.', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: '-1', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(deleteForIssueCommentMock, { + comment_id: 1, + owner: 'corp', + reaction_id: 123, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('uses default log url when the "message" variable is empty for a success', async () => { + await actionStatus({ + context, + octokit, + reactionId: 123, + message: '', + result: 'success' + }) + assertCalledWith(createCommentMock, { + body: 'Unknown error, [check logs](https://github.com/corp/test/actions/runs/12345) for more details.', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: 'rocket', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(deleteForIssueCommentMock, { + comment_id: 1, + owner: 'corp', + reaction_id: 123, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('truncates the message when it is too large for an issue comment', async () => { + const message = 'a'.repeat(65538) + await actionStatus({ + context, + octokit, + reactionId: 123, + message, + result: 'success' + }) + assertCalledWith(createCommentMock, { + body: truncateCommentBody(message), + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: 'rocket', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(deleteForIssueCommentMock, { + comment_id: 1, + owner: 'corp', + reaction_id: 123, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('skips decorative reaction changes when no initial reaction exists', async () => { + await actionStatus({ + context, + octokit, + reactionId: null, + message: 'Everything worked!', + result: 'success' + }) + + assertCalledWith(createCommentMock, { + body: 'Everything worked!', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertNotCalled(deleteForIssueCommentMock) + assertNotCalled(createForIssueCommentMock) +}) + +test('continues to add the final reaction when initial reaction deletion fails', async () => { + deleteForIssueCommentMock.mock.mockImplementation(() => + Promise.reject(new Error('delete unavailable')) + ) + + await actionStatus({ + context, + octokit, + reactionId: 123, + message: 'Everything worked!', + result: 'success' + }) + + assertCalledWith( + warningMock, + 'failed to remove the initial decorative reaction: delete unavailable' + ) + assertCalledWith(createForIssueCommentMock, { + comment_id: 1, + content: 'rocket', + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) +}) + +test('keeps the required status comment when the final reaction fails', async () => { + createForIssueCommentMock.mock.mockImplementation(() => + Promise.reject(new Error('create unavailable')) + ) + + await actionStatus({ + context, + octokit, + reactionId: 123, + message: 'Everything worked!', + result: 'success' + }) + + assertCalledWith(createCommentMock, { + body: 'Everything worked!', + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith( + warningMock, + 'failed to add the final decorative reaction: create unavailable' + ) +}) diff --git a/__tests__/functions/admin.test.js b/__tests__/functions/admin.test.js deleted file mode 100644 index 00e50dcd..00000000 --- a/__tests__/functions/admin.test.js +++ /dev/null @@ -1,250 +0,0 @@ -import {isAdmin} from '../../src/functions/admin.js' -import {vi, expect, test, beforeEach} from 'vitest' -import {COLORS} from '../../src/functions/colors.js' -import * as github from '@actions/github' -import * as core from '@actions/core' - -vi.mock('@actions/github', {spy: true}) - -const debugMock = vi.spyOn(core, 'debug') -const warningMock = vi.spyOn(core, 'warning') - -class NotFoundError extends Error { - constructor(message) { - super(message) - this.status = 404 - } -} - -class WildError extends Error { - constructor(message) { - super(message) - this.status = 500 - } -} - -var context -var octokit -beforeEach(() => { - vi.clearAllMocks() - process.env.INPUT_ADMINS_PAT = 'faketoken' - process.env.INPUT_ADMINS = - 'MoNaLiSa,@lisamona,octoawesome/octo-awEsome-team,bad$user' - - context = { - actor: 'monalisa' - } - - octokit = { - request: vi.fn().mockReturnValueOnce({ - status: 204 - }), - rest: { - orgs: { - get: vi.fn().mockReturnValueOnce({ - data: {id: '12345'} - }) - }, - teams: { - getByName: vi.fn().mockReturnValueOnce({ - data: {id: '567890'} - }) - } - } - } - - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return octokit - }) -}) - -test('runs isAdmin checks and finds a valid admin via handle reference', async () => { - expect(await isAdmin(context)).toStrictEqual(true) - expect(debugMock).toHaveBeenCalledWith( - 'monalisa is an admin via handle reference' - ) -}) - -test('runs isAdmin checks and finds a valid handle that is a GitHub EMU', async () => { - process.env.INPUT_ADMINS = 'username_company' - const contextNoAdmin = { - actor: 'username_company' - } - expect(await isAdmin(contextNoAdmin)).toStrictEqual(true) - expect(debugMock).toHaveBeenCalledWith( - 'username_company is an admin via handle reference' - ) -}) - -test('runs isAdmin checks and does not find a valid admin due to a bad GitHub handle', async () => { - process.env.INPUT_ADMINS = 'mona%lisa-' - const contextNoAdmin = { - actor: 'mona%lisa-' - } - expect(await isAdmin(contextNoAdmin)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith( - 'mona%lisa- is not a valid GitHub username... skipping admin check' - ) -}) - -test('runs isAdmin checks and does not find a valid admin', async () => { - process.env.INPUT_ADMINS = 'monalisa' - const contextNoAdmin = { - actor: 'eviluser' - } - expect(await isAdmin(contextNoAdmin)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith('eviluser is not an admin') -}) - -test('runs isAdmin checks for an org team and fails due to no admins_pat', async () => { - process.env.INPUT_ADMINS_PAT = 'false' - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome' - expect(await isAdmin(context)).toStrictEqual(false) - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿšจ no ${COLORS.highlight}admins_pat${COLORS.reset} provided, skipping admin check for org team membership` - ) -}) - -test('runs isAdmin checks for an org team and finds a valid user', async () => { - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team' - expect(await isAdmin(context)).toStrictEqual(true) - expect(debugMock).toHaveBeenCalledWith( - 'monalisa is in octoawesome/octo-awesome-team' - ) - expect(debugMock).toHaveBeenCalledWith( - 'monalisa is an admin via org team reference' - ) -}) - -// This only handles the global failure case of any 404 in the admin.js file -test('runs isAdmin checks for an org team and does not find the org', async () => { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - rest: { - orgs: { - get: vi - .fn() - .mockRejectedValueOnce( - new NotFoundError('Reference does not exist') - ) - } - } - } - }) - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team' - expect(await isAdmin(context)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith( - 'monalisa is not a member of the octoawesome/octo-awesome-team team' - ) -}) - -// This only handles the global failure case of any 404 in the admin.js file -test('runs isAdmin checks for an org team and does not find the team', async () => { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - rest: { - orgs: { - get: vi.fn().mockReturnValueOnce({ - data: {id: '12345'} - }) - }, - teams: { - getByName: vi - .fn() - .mockRejectedValueOnce( - new NotFoundError('Reference does not exist') - ) - } - } - } - }) - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team' - expect(await isAdmin(context)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith( - 'monalisa is not a member of the octoawesome/octo-awesome-team team' - ) -}) - -// This test correctly tests if a user is a member of a team or not. If they are in a team a 204 is returned. If they are not a 404 is returned like in this test example -test('runs isAdmin checks for an org team and does not find the user in the team', async () => { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - request: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('Reference does not exist')), - rest: { - orgs: { - get: vi.fn().mockReturnValueOnce({ - data: {id: '12345'} - }) - }, - teams: { - getByName: vi.fn().mockReturnValueOnce({ - data: {id: '567890'} - }) - } - } - } - }) - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team' - expect(await isAdmin(context)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith( - 'monalisa is not a member of the octoawesome/octo-awesome-team team' - ) -}) - -test('runs isAdmin checks for an org team and an unexpected status code is received from the request method with octokit', async () => { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - request: vi.fn().mockReturnValueOnce({ - status: 500 - }), - rest: { - orgs: { - get: vi.fn().mockReturnValueOnce({ - data: {id: '12345'} - }) - }, - teams: { - getByName: vi.fn().mockReturnValueOnce({ - data: {id: '567890'} - }) - } - } - } - }) - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team' - expect(await isAdmin(context)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith('monalisa is not an admin') - expect(warningMock).toHaveBeenCalledWith( - 'non 204 response from org team check: 500' - ) -}) - -test('runs isAdmin checks for an org team and an unexpected error is thrown from any API call', async () => { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - request: vi - .fn() - .mockRejectedValueOnce(new WildError('something went boom')), - rest: { - orgs: { - get: vi.fn().mockReturnValueOnce({ - data: {id: '12345'} - }) - }, - teams: { - getByName: vi.fn().mockReturnValueOnce({ - data: {id: '567890'} - }) - } - } - } - }) - process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team' - expect(await isAdmin(context)).toStrictEqual(false) - expect(debugMock).toHaveBeenCalledWith('monalisa is not an admin') - expect(warningMock).toHaveBeenCalledWith( - 'error checking org team membership: Error: something went boom' - ) -}) diff --git a/__tests__/functions/admin.test.ts b/__tests__/functions/admin.test.ts new file mode 100644 index 00000000..e4c3726e --- /dev/null +++ b/__tests__/functions/admin.test.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import type { + AdminOctokit, + AdminOctokitFactory +} from '../../src/functions/admin.ts' +import {COLORS} from '../../src/functions/colors.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + queueMockImplementation, + stubEnv, + installModuleMock +} from '../node-test-helpers.ts' + +const actionsCore = await import('../../src/actions-core.ts') +const debugMock = createMock() +const infoMock = createMock() +const warningMock = createMock() +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + ...actionsCore, + debug: debugMock, + info: infoMock, + warning: warningMock +}) + +const {defaultAdminOctokitFactory, isAdmin} = + await import('../../src/functions/admin.ts') + +const requestMock = createMock() +const getOrganizationMock = createMock() +const getTeamMock = createMock() +const createClientMock = createMock() + +const adminClient: AdminOctokit = { + request: requestMock, + rest: { + orgs: {get: getOrganizationMock}, + teams: {getByName: getTeamMock} + } +} + +class NotFoundError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 404 + } +} + +class WildError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 500 + } +} + +let context: Parameters[0] + +beforeEach(context_ => { + if (!('after' in context_)) { + throw new TypeError('expected a test context') + } + for (const mockFunction of [ + debugMock, + infoMock, + warningMock, + requestMock, + getOrganizationMock, + getTeamMock, + createClientMock + ]) { + mockFunction.mock.resetCalls() + } + stubEnv(context_, 'INPUT_ADMINS_PAT', 'faketoken') + stubEnv( + context_, + 'INPUT_ADMINS', + 'MoNaLiSa,@lisamona,octoawesome/octo-awEsome-team,bad$user' + ) + + context = createContext({actor: 'monalisa'}) + requestMock.mock.mockImplementation(() => Promise.resolve({status: 204})) + getOrganizationMock.mock.mockImplementation(() => + Promise.resolve({ + data: {id: 12345} + }) + ) + getTeamMock.mock.mockImplementation(() => + Promise.resolve({data: {id: 567890}}) + ) + createClientMock.mock.mockImplementation(() => adminClient) +}) + +test('creates the default narrow admin client without making a request', () => { + const client = defaultAdminOctokitFactory('faketoken') + assert.strictEqual(typeof client.request, 'function') + assert.strictEqual(typeof client.rest.orgs.get, 'function') + assert.strictEqual(typeof client.rest.teams.getByName, 'function') +}) + +test('runs isAdmin checks and finds a valid admin via handle reference', async () => { + assert.strictEqual(await isAdmin(context), true) + assertCalledWith(debugMock, 'monalisa is an admin via handle reference') +}) + +test('runs isAdmin checks and finds a valid handle that is a GitHub EMU', async () => { + process.env['INPUT_ADMINS'] = 'username_company' + const contextNoAdmin = createContext({actor: 'username_company'}) + assert.strictEqual(await isAdmin(contextNoAdmin), true) + assertCalledWith( + debugMock, + 'username_company is an admin via handle reference' + ) +}) + +for (const {admin, valid} of [ + {admin: '', valid: false}, + {admin: 'a', valid: true}, + {admin: '0', valid: true}, + {admin: 'a-b', valid: true}, + {admin: 'a'.repeat(39), valid: true}, + {admin: 'a'.repeat(40), valid: false}, + {admin: '-monalisa', valid: false}, + {admin: 'mona--lisa', valid: false}, + {admin: 'monalisa-', valid: false}, + {admin: '@monalisa', valid: false}, + {admin: 'mona_lisa', valid: true}, + {admin: 'name-with-hyphens_company', valid: true}, + {admin: `${'a'.repeat(40)}_company`, valid: true}, + {admin: 'mona_lisa-smith', valid: false}, + {admin: 'mona__lisa', valid: false} +]) { + test(`preserves legacy username validation for ${admin}`, async () => { + process.env['INPUT_ADMINS'] = admin + const usernameContext = createContext({actor: admin}) + + assert.strictEqual(await isAdmin(usernameContext), valid) + + if (!valid) { + assertCalledWith( + debugMock, + `${admin} is not a valid GitHub username... skipping admin check` + ) + } + }) +} + +test('runs isAdmin checks and does not find a valid admin due to a bad GitHub handle', async () => { + process.env['INPUT_ADMINS'] = 'mona%lisa-' + const contextNoAdmin = createContext({actor: 'mona%lisa-'}) + assert.strictEqual(await isAdmin(contextNoAdmin), false) + assertCalledWith( + debugMock, + 'mona%lisa- is not a valid GitHub username... skipping admin check' + ) +}) + +test('runs isAdmin checks and does not find a valid admin', async () => { + process.env['INPUT_ADMINS'] = 'monalisa' + const contextNoAdmin = createContext({actor: 'eviluser'}) + assert.strictEqual(await isAdmin(contextNoAdmin), false) + assertCalledWith(debugMock, 'eviluser is not an admin') +}) + +test('runs isAdmin checks for an org team and fails due to no admins_pat', async () => { + process.env['INPUT_ADMINS_PAT'] = 'false' + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome' + assert.strictEqual(await isAdmin(context, createClientMock), false) + assertCalledWith( + warningMock, + `๐Ÿšจ no ${COLORS.highlight}admins_pat${COLORS.reset} provided, skipping admin check for org team membership` + ) +}) + +test('runs isAdmin checks for an org team and finds a valid user', async () => { + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome-team' + assert.strictEqual(await isAdmin(context, createClientMock), true) + assertCalledWith(debugMock, 'monalisa is in octoawesome/octo-awesome-team') + assertCalledWith(debugMock, 'monalisa is an admin via org team reference') +}) + +test('runs isAdmin checks for an org team and does not find the org', async () => { + queueMockImplementation(getOrganizationMock, () => + Promise.reject(new NotFoundError('Reference does not exist')) + ) + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome-team' + assert.strictEqual(await isAdmin(context, createClientMock), false) + assertCalledWith( + debugMock, + 'monalisa is not a member of the octoawesome/octo-awesome-team team' + ) +}) + +test('runs isAdmin checks for an org team and does not find the team', async () => { + queueMockImplementation(getTeamMock, () => + Promise.reject(new NotFoundError('Reference does not exist')) + ) + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome-team' + assert.strictEqual(await isAdmin(context, createClientMock), false) + assertCalledWith( + debugMock, + 'monalisa is not a member of the octoawesome/octo-awesome-team team' + ) +}) + +test('runs isAdmin checks for an org team and does not find the user in the team', async () => { + queueMockImplementation(requestMock, () => + Promise.reject(new NotFoundError('Reference does not exist')) + ) + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome-team' + assert.strictEqual(await isAdmin(context, createClientMock), false) + assertCalledWith( + debugMock, + 'monalisa is not a member of the octoawesome/octo-awesome-team team' + ) +}) + +test('runs isAdmin checks for an org team and an unexpected status code is received from the request method with octokit', async () => { + queueMockImplementation(requestMock, () => Promise.resolve({status: 500})) + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome-team' + assert.strictEqual(await isAdmin(context, createClientMock), false) + assertCalledWith(debugMock, 'monalisa is not an admin') + assertCalledWith(warningMock, 'non 204 response from org team check: 500') +}) + +test('runs isAdmin checks for an org team and an unexpected error is thrown from any API call', async () => { + queueMockImplementation(requestMock, () => + Promise.reject(new WildError('something went boom')) + ) + process.env['INPUT_ADMINS'] = 'octoawesome/octo-awesome-team' + assert.strictEqual(await isAdmin(context, createClientMock), false) + assertCalledWith(debugMock, 'monalisa is not an admin') + assertCalledWith( + warningMock, + 'error checking org team membership: Error: something went boom' + ) +}) diff --git a/__tests__/functions/branch-ruleset-checks.test.js b/__tests__/functions/branch-ruleset-checks.test.js deleted file mode 100644 index d63eecdb..00000000 --- a/__tests__/functions/branch-ruleset-checks.test.js +++ /dev/null @@ -1,303 +0,0 @@ -import {branchRulesetChecks} from '../../src/functions/branch-ruleset-checks.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' -import {COLORS} from '../../src/functions/colors.js' -import {SUGGESTED_RULESETS} from '../../src/functions/suggested-rulesets.js' -import {ERROR} from '../../src/functions/templates/error.js' - -const debugMock = vi.spyOn(core, 'debug') -const infoMock = vi.spyOn(core, 'info') -const warningMock = vi.spyOn(core, 'warning') - -var context -var octokit -var data -var rulesets - -class ForbiddenError extends Error { - constructor(message) { - super(message) - this.status = 403 - } -} - -beforeEach(() => { - vi.clearAllMocks() - - data = { - branch: 'main' - } - - rulesets = [ - { - type: 'deletion' - }, - { - type: 'non_fast_forward' - }, - { - type: 'pull_request', - parameters: { - required_approving_review_count: 1, - dismiss_stale_reviews_on_push: true, - required_reviewers: [], - require_code_owner_review: true, - require_last_push_approval: false, - required_review_thread_resolution: false, - allowed_merge_methods: ['merge', 'squash', 'rebase'] - } - }, - { - type: 'required_status_checks', - parameters: { - strict_required_status_checks_policy: true, - do_not_enforce_on_create: false, - required_status_checks: [] - } - }, - { - type: 'required_deployments', - parameters: { - required_deployment_environments: [] - } - } - ] - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - } - } - - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: rulesets}) - } - } - } -}) - -test('finds that no branch protections or rulesets are defined', async () => { - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: []}) - } - } - } - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: false, - failed_checks: ['missing_branch_rulesets'] - }) - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} are not defined for branch ${COLORS.highlight}${data.branch}${COLORS.reset}` - ) -}) - -test('exits early if the user has disabled security warnings', async () => { - data.use_security_warnings = false - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: true - }) - expect(warningMock).not.toHaveBeenCalled() - expect(infoMock).not.toHaveBeenCalledWith( - `๐Ÿ” branch ruleset checks ${COLORS.success}passed${COLORS.reset}` - ) -}) - -test('finds that the branch ruleset is missing the deletion rule', async () => { - rulesets = rulesets.filter(rule => rule.type !== 'deletion') - - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: rulesets}) - } - } - } - - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: false, - failed_checks: ['missing_deletion'] - }) - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${data.branch}${COLORS.reset} is missing a rule of type ${COLORS.highlight}deletion${COLORS.reset}` - ) -}) - -test('finds that the branch ruleset is missing the dismiss_stale_reviews_on_push parameter on the pull_request rule', async () => { - rulesets = rulesets.map(rule => { - if (rule.type === 'pull_request') { - return { - type: 'pull_request', - parameters: { - ...rule.parameters, - dismiss_stale_reviews_on_push: false - } - } - } - return rule - }) - - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: rulesets}) - } - } - } - - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: false, - failed_checks: ['mismatch_pull_request_dismiss_stale_reviews_on_push'] - }) - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${data.branch}${COLORS.reset} contains a rule of type ${COLORS.highlight}pull_request${COLORS.reset} with a parameter ${COLORS.highlight}dismiss_stale_reviews_on_push${COLORS.reset} which does not match the suggested parameter` - ) -}) - -test('finds that all suggested branch rulesets are defined', async () => { - rulesets = SUGGESTED_RULESETS.map(suggested_rule => { - return { - type: suggested_rule.type, - parameters: suggested_rule.parameters - } - }) - - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: rulesets}) - } - } - } - - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: true, - failed_checks: [] - }) - expect(warningMock).not.toHaveBeenCalled() - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ” branch ruleset checks ${COLORS.success}passed${COLORS.reset}` - ) -}) - -test('finds that all suggested branch rulesets are defined but required reviews is set to 0', async () => { - rulesets = SUGGESTED_RULESETS.map(suggested_rule => { - return { - type: suggested_rule.type, - parameters: suggested_rule.parameters - } - }) - - rulesets = rulesets.map(rule => { - if (rule.type === 'pull_request') { - return { - type: 'pull_request', - parameters: { - ...rule.parameters, - required_approving_review_count: 0 - } - } - } - return rule - }) - - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: rulesets}) - } - } - } - - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: false, - failed_checks: ['mismatch_pull_request_required_approving_review_count'] - }) - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${data.branch}${COLORS.reset} contains the required_approving_review_count parameter but it is set to 0` - ) -}) - -test('should still pass even with many required reviewers', async () => { - rulesets = SUGGESTED_RULESETS.map(suggested_rule => { - return { - type: suggested_rule.type, - parameters: suggested_rule.parameters - } - }) - - rulesets = rulesets.map(rule => { - if (rule.type === 'pull_request') { - return { - type: 'pull_request', - parameters: { - ...rule.parameters, - required_approving_review_count: 4 - } - } - } - return rule - }) - - octokit = { - rest: { - repos: { - getBranchRules: vi.fn().mockReturnValueOnce({data: rulesets}) - } - } - } - - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: true, - failed_checks: [] - }) - expect(warningMock).not.toHaveBeenCalled() - expect(debugMock).toHaveBeenCalledWith( - `required_approving_review_count is 4 - OK` - ) -}) - -test('fails due to a 403 from the GitHub API due to a repository being private on the free tier without access to repo rulesets', async () => { - octokit = { - rest: { - repos: { - getBranchRules: vi - .fn() - .mockRejectedValueOnce( - new ForbiddenError(ERROR.messages.upgrade_or_public.message) - ) - } - } - } - expect(await branchRulesetChecks(context, octokit, data)).toStrictEqual({ - success: false, - failed_checks: ['upgrade_or_public_required'] - }) - expect(debugMock).toHaveBeenCalledWith( - ERROR.messages.upgrade_or_public.help_text - ) -}) - -test('fails due to an unknown 403 from the GitHub API', async () => { - const errorMessage = 'oh no, something went wrong - forbidden' - octokit = { - rest: { - repos: { - getBranchRules: vi - .fn() - .mockRejectedValueOnce(new ForbiddenError(errorMessage)) - } - } - } - - await expect(branchRulesetChecks(context, octokit, data)).rejects.toThrow( - errorMessage - ) -}) diff --git a/__tests__/functions/branch-ruleset-checks.test.ts b/__tests__/functions/branch-ruleset-checks.test.ts new file mode 100644 index 00000000..ce24daa5 --- /dev/null +++ b/__tests__/functions/branch-ruleset-checks.test.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import {ERROR} from '../../src/functions/templates/error.ts' +import type {BranchRule} from '../../src/types.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + assertNotCalled, + createMock, + queueMockImplementation, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type BranchRulesetModule = + typeof import('../../src/functions/branch-ruleset-checks.ts') + +const debugMock = createMock() +const infoMock = createMock() +const warningMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + info: infoMock, + warning: warningMock +}) + +const {branchRulesetChecks} = + await import('../../src/functions/branch-ruleset-checks.ts') + +let context: Parameters[0] +let octokit: Parameters[1] +let data: Parameters[2] +let rulesets: BranchRule[] +const getBranchRulesMock = + createMock< + Parameters< + BranchRulesetModule['branchRulesetChecks'] + >[1]['rest']['repos']['getBranchRules'] + >() +type PullRequestRule = Extract + +class ForbiddenError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 403 + } +} + +function pullRequestRule( + overrides: Partial = {} +): PullRequestRule { + return { + type: 'pull_request', + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: true, + require_code_owner_review: true, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ['merge', 'squash', 'rebase'], + ...overrides + } + } +} + +function validRulesets(): BranchRule[] { + return [ + {type: 'deletion'}, + {type: 'non_fast_forward'}, + pullRequestRule(), + { + type: 'required_status_checks', + parameters: { + strict_required_status_checks_policy: true, + do_not_enforce_on_create: false, + required_status_checks: [] + } + }, + { + type: 'required_deployments', + parameters: {required_deployment_environments: []} + } + ] +} + +beforeEach(() => { + debugMock.mock.resetCalls() + infoMock.mock.resetCalls() + warningMock.mock.resetCalls() + getBranchRulesMock.mock.resetCalls() + + data = { + branch: 'main' + } + + rulesets = validRulesets() + + context = createContext({ + repo: { + owner: 'corp', + repo: 'test' + }, + issue: { + number: 1 + } + }) + + getBranchRulesMock.mock.mockImplementation(() => + Promise.resolve({data: rulesets}) + ) + octokit = { + rest: { + repos: { + getBranchRules: getBranchRulesMock + } + } + } +}) + +test('finds that no branch protections or rulesets are defined', async () => { + getBranchRulesMock.mock.mockImplementation(() => Promise.resolve({data: []})) + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: false, + failed_checks: ['missing_branch_rulesets'] + }) + assertCalledWith( + warningMock, + `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} are not defined for branch ${COLORS.highlight}${data.branch}${COLORS.reset}` + ) +}) + +test('exits early if the user has disabled security warnings', async () => { + data = {...data, use_security_warnings: false} + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: true + }) + assertNotCalled(warningMock) + assertNotCalled(infoMock) +}) + +test('finds that the branch ruleset is missing the deletion rule', async () => { + rulesets = rulesets.filter(rule => rule.type !== 'deletion') + + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: false, + failed_checks: ['missing_deletion'] + }) + assertCalledWith( + warningMock, + `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${data.branch}${COLORS.reset} is missing a rule of type ${COLORS.highlight}deletion${COLORS.reset}` + ) +}) + +test('finds that the branch ruleset is missing the dismiss_stale_reviews_on_push parameter on the pull_request rule', async () => { + rulesets = rulesets.map((rule): BranchRule => { + if (rule.type === 'pull_request') { + return pullRequestRule({dismiss_stale_reviews_on_push: false}) + } + return rule + }) + + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: false, + failed_checks: ['mismatch_pull_request_dismiss_stale_reviews_on_push'] + }) + assertCalledWith( + warningMock, + `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${data.branch}${COLORS.reset} contains a rule of type ${COLORS.highlight}pull_request${COLORS.reset} with a parameter ${COLORS.highlight}dismiss_stale_reviews_on_push${COLORS.reset} which does not match the suggested parameter` + ) +}) + +test('finds that all suggested branch rulesets are defined', async () => { + rulesets = validRulesets() + + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: true, + failed_checks: [] + }) + assertNotCalled(warningMock) + assertCalledWith( + infoMock, + `๐Ÿ” branch ruleset checks ${COLORS.success}passed${COLORS.reset}` + ) +}) + +test('finds that all suggested branch rulesets are defined but required reviews is set to 0', async () => { + rulesets = validRulesets() + + rulesets = rulesets.map((rule): BranchRule => { + if (rule.type === 'pull_request') { + return pullRequestRule({required_approving_review_count: 0}) + } + return rule + }) + + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: false, + failed_checks: ['mismatch_pull_request_required_approving_review_count'] + }) + assertCalledWith( + warningMock, + `๐Ÿ” branch ${COLORS.highlight}rulesets${COLORS.reset} for branch ${COLORS.highlight}${data.branch}${COLORS.reset} contains the required_approving_review_count parameter but it is set to 0` + ) +}) + +test('should still pass even with many required reviewers', async () => { + rulesets = validRulesets() + + rulesets = rulesets.map((rule): BranchRule => { + if (rule.type === 'pull_request') { + return pullRequestRule({required_approving_review_count: 4}) + } + return rule + }) + + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: true, + failed_checks: [] + }) + assertNotCalled(warningMock) + assertCalledWith(debugMock, 'required_approving_review_count is 4 - OK') +}) + +test('fails due to a 403 from the GitHub API due to a repository being private on the free tier without access to repo rulesets', async () => { + queueMockImplementation(getBranchRulesMock, () => + Promise.reject(new ForbiddenError(ERROR.messages.upgrade_or_public.message)) + ) + assert.deepStrictEqual(await branchRulesetChecks(context, octokit, data), { + success: false, + failed_checks: ['upgrade_or_public_required'] + }) + assertCalledWith(debugMock, ERROR.messages.upgrade_or_public.help_text) +}) + +test('fails due to an unknown 403 from the GitHub API', async () => { + const errorMessage = 'oh no, something went wrong - forbidden' + queueMockImplementation(getBranchRulesMock, () => + Promise.reject(new ForbiddenError(errorMessage)) + ) + + await assert.rejects(branchRulesetChecks(context, octokit, data), { + message: errorMessage + }) +}) diff --git a/__tests__/functions/check-input.test.js b/__tests__/functions/check-input.test.js deleted file mode 100644 index bb6192c1..00000000 --- a/__tests__/functions/check-input.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import {checkInput} from '../../src/functions/check-input.js' -import {expect, test} from 'vitest' - -test('checks an input an finds that it is valid', async () => { - expect(checkInput('production')).toStrictEqual('production') -}) - -test('checks an input an finds that it is valid with true/false strings', async () => { - expect(checkInput('true')).toStrictEqual('true') - - expect(checkInput('false')).toStrictEqual('false') -}) - -test('checks an empty string input an finds that it is invalid', async () => { - expect(checkInput('')).toStrictEqual(null) -}) - -test('checks a null object input an finds that it is invalid', async () => { - expect(checkInput(null)).toStrictEqual(null) -}) - -test('checks a string of null input an finds that it is invalid', async () => { - expect(checkInput('null')).toStrictEqual(null) -}) diff --git a/__tests__/functions/check-input.test.ts b/__tests__/functions/check-input.test.ts new file mode 100644 index 00000000..61b55834 --- /dev/null +++ b/__tests__/functions/check-input.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {checkInput} from '../../src/functions/check-input.ts' + +test('checks an input an finds that it is valid', () => { + assert.strictEqual(checkInput('production'), 'production') +}) + +test('checks an input an finds that it is valid with true/false strings', () => { + assert.strictEqual(checkInput('true'), 'true') + + assert.strictEqual(checkInput('false'), 'false') +}) + +test('checks an empty string input an finds that it is invalid', () => { + assert.strictEqual(checkInput(''), null) +}) + +test('checks a null object input an finds that it is invalid', () => { + assert.strictEqual(checkInput(null), null) +}) + +test('checks a string of null input an finds that it is invalid', () => { + assert.strictEqual(checkInput('null'), null) +}) diff --git a/__tests__/functions/commit-safety-checks.test.js b/__tests__/functions/commit-safety-checks.test.js deleted file mode 100644 index e68edead..00000000 --- a/__tests__/functions/commit-safety-checks.test.js +++ /dev/null @@ -1,242 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import {commitSafetyChecks} from '../../src/functions/commit-safety-checks.js' -import {COLORS} from '../../src/functions/colors.js' -import * as core from '@actions/core' - -vi.mock('../../src/functions/is-timestamp-older', () => ({ - isTimestampOlder: vi.fn() -})) -import {isTimestampOlder} from '../../src/functions/is-timestamp-older.js' - -const debugMock = vi.spyOn(core, 'debug') -const infoMock = vi.spyOn(core, 'info') -const warningMock = vi.spyOn(core, 'warning') -const saveStateMock = vi.spyOn(core, 'saveState') -const setOutputMock = vi.spyOn(core, 'setOutput') - -var data -var context - -const no_verification = { - verified: false, - reason: 'unsigned', - signature: null, - payload: null, - verified_at: null -} - -const sha = 'abc123' - -beforeEach(() => { - vi.clearAllMocks() - debugMock.mockClear() - infoMock.mockClear() - warningMock.mockClear() - saveStateMock.mockClear() - setOutputMock.mockClear() - - context = { - payload: { - comment: { - created_at: '2024-10-15T12:00:00Z' - } - } - } - - data = { - sha: sha, - commit: { - author: { - date: '2024-10-15T11:00:00Z' - }, - verification: no_verification - }, - inputs: { - commit_verification: false - } - } -}) - -test('checks a commit and finds that it is safe (date)', async () => { - isTimestampOlder.mockReturnValue(false) - expect(await commitSafetyChecks(context, data)).toStrictEqual({ - message: 'success', - status: true, - isVerified: false - }) - expect(debugMock).toHaveBeenCalledWith('isVerified: false') - expect(debugMock).toHaveBeenCalledWith( - `๐Ÿ”‘ commit does not contain a verified signature but ${COLORS.highlight}commit signing is not required${COLORS.reset} - ${COLORS.success}OK${COLORS.reset}` - ) - expect(saveStateMock).toHaveBeenCalledWith('commit_verified', false) - expect(setOutputMock).toHaveBeenCalledWith('commit_verified', false) -}) - -test('checks a commit and finds that it is safe (date + verification)', async () => { - isTimestampOlder.mockReturnValue(false) - data.inputs.commit_verification = true - data.commit.verification = { - verified: true, - reason: 'valid', - signature: 'SOME_SIGNATURE', - payload: 'SOME_PAYLOAD', - verified_at: '2024-10-15T12:00:00Z' - } - expect(await commitSafetyChecks(context, data)).toStrictEqual({ - message: 'success', - status: true, - isVerified: true - }) - expect(debugMock).toHaveBeenCalledWith('isVerified: true') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”‘ commit signature is ${COLORS.success}valid${COLORS.reset}` - ) -}) - -test('checks a commit and finds that it is not safe (date)', async () => { - isTimestampOlder.mockReturnValue(true) - data.commit.author.date = '2024-10-15T12:00:01Z' - - expect(await commitSafetyChecks(context, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\nThe latest commit is not safe for deployment. It was authored after the trigger comment was created.', - status: false, - isVerified: false - }) - expect(debugMock).toHaveBeenCalledWith('isVerified: false') -}) - -test('checks a commit and finds that it is not safe (verification)', async () => { - isTimestampOlder.mockReturnValue(false) - data.inputs.commit_verification = true - data.commit.verification = { - verified: false, - reason: 'unsigned', - signature: null, - payload: null, - verified_at: null - } - - expect(await commitSafetyChecks(context, data)).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`${data.commit.verification.reason}\`\n\n> The commit signature is not valid. Please ensure the commit has been properly signed and try again.`, - status: false, - isVerified: false - }) - expect(debugMock).toHaveBeenCalledWith('isVerified: false') - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿ”‘ commit signature is ${COLORS.error}invalid${COLORS.reset}` - ) - expect(saveStateMock).toHaveBeenCalledWith('commit_verified', false) - expect(setOutputMock).toHaveBeenCalledWith('commit_verified', false) -}) - -test('checks a commit and finds that it is not safe (verification time) even though it is verified - rejected due to timestamp', async () => { - // First call: commit_created_at check (should be false), second call: verified_at check (should be true) - isTimestampOlder - .mockImplementationOnce(() => false) - .mockImplementationOnce(() => true) - data.inputs.commit_verification = true - data.commit.verification = { - verified: true, - reason: 'valid', - signature: 'SOME_SIGNATURE', - payload: 'SOME_PAYLOAD', - verified_at: '2024-10-15T12:00:01Z' // occurred after the trigger comment was created - } - - expect(await commitSafetyChecks(context, data)).toStrictEqual({ - 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.`, - status: false, - isVerified: true - }) - expect(debugMock).toHaveBeenCalledWith('isVerified: true') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”‘ commit signature is ${COLORS.success}valid${COLORS.reset}` - ) - expect(saveStateMock).toHaveBeenCalledWith('commit_verified', true) - expect(setOutputMock).toHaveBeenCalledWith('commit_verified', true) -}) - -test('raises an error if the date format is invalid', async () => { - // Simulate isTimestampOlder throwing - isTimestampOlder.mockImplementation(() => { - throw new Error( - 'Invalid date format. Please ensure the dates are valid UTC timestamps.' - ) - }) - data.commit.author.date = '2024-10-15T12:00:uhoh' - await expect(commitSafetyChecks(context, data)).rejects.toThrow( - 'Invalid date format. Please ensure the dates are valid UTC timestamps.' - ) -}) - -test('throws if context.payload.comment.created_at is missing', async () => { - const brokenContext = {payload: {comment: {}}} - await expect(commitSafetyChecks(brokenContext, data)).rejects.toThrow( - 'Missing context.payload.comment.created_at' - ) -}) - -test('throws if commit.author.date is missing', async () => { - const brokenData = JSON.parse(JSON.stringify(data)) - delete brokenData.commit.author.date - await expect(commitSafetyChecks(context, brokenData)).rejects.toThrow( - 'Missing commit.author.date' - ) -}) - -test('rejects a deployment if commit.verification.verified_at is null and commit_verification is true', async () => { - isTimestampOlder.mockReturnValue(false) - data.inputs.commit_verification = true - data.commit.verification = { - verified: true, - reason: 'valid', - signature: 'SOME_SIGNATURE', - payload: 'SOME_PAYLOAD', - verified_at: null - } - - await expect(commitSafetyChecks(context, data)).resolves.toEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`valid\`\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.`, - status: false, - isVerified: true - }) -}) - -test('rejects a deployment if commit.verification.verified_at is missing and commit_verification is true', async () => { - isTimestampOlder.mockReturnValue(false) - data.inputs.commit_verification = true - data.commit.verification = { - verified: true, - reason: 'valid', - signature: 'SOME_SIGNATURE', - payload: 'SOME_PAYLOAD' - } - - await expect(commitSafetyChecks(context, data)).resolves.toEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`valid\`\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.`, - status: false, - isVerified: true - }) -}) - -test('isTimestampOlder covers else branch (not older)', async () => { - isTimestampOlder.mockReturnValue(false) - const context = {payload: {comment: {created_at: '2024-10-15T12:00:00Z'}}} - const data = { - sha: 'abc123', - commit: { - author: {date: '2024-10-15T11:00:00Z'}, - verification: { - verified: false, - reason: 'unsigned', - signature: null, - payload: null, - verified_at: null - } - }, - inputs: {commit_verification: false} - } - await commitSafetyChecks(context, data) - expect(debugMock).toHaveBeenCalledWith('isVerified: false') -}) diff --git a/__tests__/functions/commit-safety-checks.test.ts b/__tests__/functions/commit-safety-checks.test.ts new file mode 100644 index 00000000..4be42ebf --- /dev/null +++ b/__tests__/functions/commit-safety-checks.test.ts @@ -0,0 +1,353 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import { + createActionInputs, + createContext, + createIssueCommentContext +} from '../test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' +import { + assertCalledWith, + createMock, + queueMockImplementation, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type IsTimestampOlder = + typeof import('../../src/functions/is-timestamp-older.ts') + +const debugMock = createMock() +const infoMock = createMock() +const warningMock = createMock() +const saveStateMock = createMock() +const setOutputMock = createMock() +const isTimestampOlderMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + info: infoMock, + warning: warningMock, + saveState: saveStateMock, + setOutput: setOutputMock +}) +installModuleMock( + mock, + new URL('../../src/functions/is-timestamp-older.ts', import.meta.url), + {isTimestampOlder: isTimestampOlderMock} +) + +const {commitSafetyChecks} = + await import('../../src/functions/commit-safety-checks.ts') + +type CommitSafetyFixture = Parameters[1] & { + commit: { + author: {date: string} + verification: { + payload?: string | null + reason?: string | null + signature?: string | null + verified?: boolean + verified_at?: string | null + } + } +} + +let data: CommitSafetyFixture +let context: Parameters[0] + +const no_verification = { + verified: false, + reason: 'unsigned', + signature: null, + payload: null, + verified_at: null +} + +const sha = 'abc123' + +beforeEach(() => { + for (const mockFunction of [ + debugMock, + infoMock, + warningMock, + saveStateMock, + setOutputMock, + isTimestampOlderMock + ]) { + mockFunction.mock.resetCalls() + } + isTimestampOlderMock.mock.mockImplementation(() => false) + + context = createIssueCommentContext({ + payload: {comment: {created_at: '2024-10-15T12:00:00Z'}} + }) + + data = { + sha: sha, + commit: { + author: { + date: '2024-10-15T11:00:00Z' + }, + verification: no_verification + }, + inputs: createActionInputs({commit_verification: false}) + } +}) + +test('checks a commit and finds that it is safe (date)', () => { + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: 'success', + status: true, + isVerified: false + }) + assertCalledWith(debugMock, 'isVerified: false') + assertCalledWith( + debugMock, + `๐Ÿ”‘ commit does not contain a verified signature but ${COLORS.highlight}commit signing is not required${COLORS.reset} - ${COLORS.success}OK${COLORS.reset}` + ) + assertCalledWith(saveStateMock, 'commit_verified', false) + assertCalledWith(setOutputMock, 'commit_verified', false) +}) + +test('checks a commit and finds that it is safe (date + verification)', () => { + data = {...data, inputs: createActionInputs({commit_verification: true})} + data.commit.verification = { + verified: true, + reason: 'valid', + signature: 'SOME_SIGNATURE', + payload: 'SOME_PAYLOAD', + verified_at: '2024-10-15T12:00:00Z' + } + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: 'success', + status: true, + isVerified: true + }) + assertCalledWith(debugMock, 'isVerified: true') + assertCalledWith( + infoMock, + `๐Ÿ”‘ commit signature is ${COLORS.success}valid${COLORS.reset}` + ) +}) + +test('checks a commit and finds that it is not safe (date)', () => { + isTimestampOlderMock.mock.mockImplementation(() => true) + data.commit.author.date = '2024-10-15T12:00:01Z' + + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\nThe latest commit is not safe for deployment. It was authored after the trigger comment was created.', + status: false, + isVerified: false + }) + assertCalledWith(debugMock, 'isVerified: false') +}) + +test('checks a commit and finds that it is not safe (verification)', () => { + data = {...data, inputs: createActionInputs({commit_verification: true})} + data.commit.verification = { + verified: false, + reason: 'unsigned', + signature: null, + payload: null, + verified_at: null + } + + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`unsigned\`\n\n> The commit signature is not valid. Please ensure the commit has been properly signed and try again.`, + status: false, + isVerified: false + }) + assertCalledWith(debugMock, 'isVerified: false') + assertCalledWith( + warningMock, + `๐Ÿ”‘ commit signature is ${COLORS.error}invalid${COLORS.reset}` + ) + assertCalledWith(saveStateMock, 'commit_verified', false) + assertCalledWith(setOutputMock, 'commit_verified', false) +}) + +test('checks a commit and finds that it is not safe (verification time) even though it is verified - rejected due to timestamp', () => { + // First call: commit_created_at check (should be false), second call: verified_at check (should be true) + queueMockImplementation( + isTimestampOlderMock, + () => false, + () => true + ) + data = {...data, inputs: createActionInputs({commit_verification: true})} + data.commit.verification = { + verified: true, + reason: 'valid', + signature: 'SOME_SIGNATURE', + payload: 'SOME_PAYLOAD', + verified_at: '2024-10-15T12:00:01Z' // occurred after the trigger comment was created + } + + assert.deepStrictEqual(commitSafetyChecks(context, data), { + 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.`, + status: false, + isVerified: true + }) + assertCalledWith(debugMock, 'isVerified: true') + assertCalledWith( + infoMock, + `๐Ÿ”‘ commit signature is ${COLORS.success}valid${COLORS.reset}` + ) + assertCalledWith(saveStateMock, 'commit_verified', true) + assertCalledWith(setOutputMock, 'commit_verified', true) +}) + +test('raises an error if the date format is invalid', () => { + // Simulate isTimestampOlder throwing + isTimestampOlderMock.mock.mockImplementation(() => { + throw new Error( + 'Invalid date format. Please ensure the dates are valid UTC timestamps.' + ) + }) + data.commit.author.date = '2024-10-15T12:00:uhoh' + assert.throws(() => commitSafetyChecks(context, data), { + message: + 'Invalid date format. Please ensure the dates are valid UTC timestamps.' + }) +}) + +test('throws if context.payload.comment.created_at is missing', () => { + const brokenContext = createContext({payload: {comment: {}}}) + assert.throws(() => commitSafetyChecks(brokenContext, data), { + message: 'Missing context.payload.comment.created_at' + }) +}) + +for (const payload of [null, undefined]) { + test(`preserves the missing-comment error when the webhook payload is ${String(payload)}`, () => { + const brokenContext = unsafeInvalidValue< + Parameters[0] + >({...context, payload}) + assert.throws(() => commitSafetyChecks(brokenContext, data), { + message: 'Missing context.payload.comment.created_at' + }) + }) +} + +test('throws if commit.author.date is missing', () => { + const brokenData: Parameters[1] = { + ...data, + commit: { + ...data.commit, + author: {} + } + } + assert.throws(() => commitSafetyChecks(context, brokenData), { + message: 'Missing commit.author.date' + }) +}) + +for (const commit of [null, undefined]) { + test(`preserves the missing-author error when the external commit is ${String(commit)}`, () => { + assert.throws(() => commitSafetyChecks(context, {...data, commit}), { + message: 'Missing commit.author.date' + }) + }) +} + +for (const createdAt of [false, 0]) { + test(`preserves the missing-comment error for the falsy external timestamp ${String(createdAt)}`, () => { + const brokenContext = createIssueCommentContext({ + payload: { + comment: {created_at: unsafeInvalidValue(createdAt)} + } + }) + assert.throws(() => commitSafetyChecks(brokenContext, data), { + message: 'Missing context.payload.comment.created_at' + }) + }) +} + +for (const date of [false, 0]) { + test(`preserves the missing-author error for the falsy external timestamp ${String(date)}`, () => { + const brokenData: Parameters[1] = { + ...data, + commit: { + ...data.commit, + author: {date: unsafeInvalidValue(date)} + } + } + assert.throws(() => commitSafetyChecks(context, brokenData), { + message: 'Missing commit.author.date' + }) + }) +} + +test('rejects a deployment if commit.verification.verified_at is null and commit_verification is true', () => { + data = {...data, inputs: createActionInputs({commit_verification: true})} + data.commit.verification = { + verified: true, + reason: 'valid', + signature: 'SOME_SIGNATURE', + payload: 'SOME_PAYLOAD', + verified_at: null + } + + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`valid\`\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.`, + status: false, + isVerified: true + }) +}) + +for (const verifiedAt of [false, 0]) { + test(`preserves the invalid-verification result for the falsy external timestamp ${String(verifiedAt)}`, () => { + data = {...data, inputs: createActionInputs({commit_verification: true})} + data.commit.verification = { + verified: true, + reason: 'valid', + verified_at: unsafeInvalidValue(verifiedAt) + } + + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`valid\`\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.`, + status: false, + isVerified: true + }) + }) +} + +test('rejects a deployment if commit.verification.verified_at is missing and commit_verification is true', () => { + data = {...data, inputs: createActionInputs({commit_verification: true})} + data.commit.verification = unsafeInvalidValue< + NonNullable + >({ + verified: true, + reason: 'valid', + signature: 'SOME_SIGNATURE', + payload: 'SOME_PAYLOAD' + }) + + assert.deepStrictEqual(commitSafetyChecks(context, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- commit: \`${sha}\`\n- verification failed reason: \`valid\`\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.`, + status: false, + isVerified: true + }) +}) + +test('isTimestampOlder covers else branch (not older)', () => { + const context = createIssueCommentContext({ + payload: {comment: {created_at: '2024-10-15T12:00:00Z'}} + }) + const data: Parameters[1] = { + sha: 'abc123', + commit: { + author: {date: '2024-10-15T11:00:00Z'}, + verification: { + verified: false, + reason: 'unsigned', + verified_at: null + } + }, + inputs: createActionInputs({commit_verification: false}) + } + commitSafetyChecks(context, data) + assertCalledWith(debugMock, 'isVerified: false') +}) diff --git a/__tests__/functions/context-check.test.js b/__tests__/functions/context-check.test.js deleted file mode 100644 index 7dcb4647..00000000 --- a/__tests__/functions/context-check.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import {contextCheck} from '../../src/functions/context-check.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' - -const warningMock = vi.spyOn(core, 'warning') -const saveStateMock = vi.spyOn(core, 'saveState') - -var context -beforeEach(() => { - vi.clearAllMocks() - - context = { - eventName: 'issue_comment', - payload: { - issue: { - pull_request: {} - } - }, - pull_request: { - number: 1 - } - } -}) - -test('checks the event context and finds that it is valid', async () => { - expect(await contextCheck(context)).toBe(true) -}) - -test('checks the event context and finds that it is invalid', async () => { - context.eventName = 'push' - expect(await contextCheck(context)).toBe(false) - expect(warningMock).toHaveBeenCalledWith( - 'This Action can only be run in the context of a pull request comment' - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('checks the event context and throws an error', async () => { - try { - await contextCheck('evil') - } catch (e) { - expect(e.message).toBe( - "Could not get PR event context: TypeError: Cannot read properties of undefined (reading 'issue')" - ) - } -}) diff --git a/__tests__/functions/context-check.test.ts b/__tests__/functions/context-check.test.ts new file mode 100644 index 00000000..118b49e6 --- /dev/null +++ b/__tests__/functions/context-check.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {installModuleMock} from '../node-test-helpers.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const warningMock = mock.fn() +const saveStateMock = mock.fn() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + warning: warningMock, + saveState: saveStateMock +}) + +const {contextCheck} = await import('../../src/functions/context-check.ts') + +let context: Parameters[0] +beforeEach(() => { + warningMock.mock.resetCalls() + saveStateMock.mock.resetCalls() + + context = createIssueCommentContext({ + eventName: 'issue_comment', + payload: { + issue: { + number: 1, + pull_request: {} + } + } + }) +}) + +test('checks the event context and finds that it is valid', () => { + assert.strictEqual(contextCheck(context), true) +}) + +test('checks the event context and finds that it is invalid', () => { + context = createIssueCommentContext({ + eventName: 'push', + payload: {issue: {number: 1, pull_request: {}}} + }) + assert.strictEqual(contextCheck(context), false) + assert.deepStrictEqual( + warningMock.mock.calls.map(call => call.arguments), + [['This Action can only be run in the context of a pull request comment']] + ) + assert.deepStrictEqual( + saveStateMock.mock.calls.map(call => call.arguments), + [['bypass', 'true']] + ) +}) + +test('safely rejects a malformed event context', () => { + assert.strictEqual( + contextCheck( + unsafeInvalidValue[0]>('evil') + ), + false + ) +}) diff --git a/__tests__/functions/dedent.test.ts b/__tests__/functions/dedent.test.ts new file mode 100644 index 00000000..4a564666 --- /dev/null +++ b/__tests__/functions/dedent.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {dedent} from '../../src/functions/dedent.ts' + +const interpolatedLines = 'beta\n gamma' + +const cases = [ + { + name: 'removes a leading newline, common indentation, and trailing newline', + input: '\n alpha\n beta\n', + expected: 'alpha\nbeta' + }, + { + name: 'preserves CRLF line endings', + input: '\r\n\talpha\r\n\tbeta\r\n\t', + expected: 'alpha\r\nbeta' + }, + { + name: 'counts mixed tabs and spaces as indentation characters', + input: '\n \talpha\n beta\n', + expected: 'alpha\n beta' + }, + { + name: 'preserves indentation beyond the common minimum', + input: '\n alpha\n beta\n', + expected: 'alpha\n beta' + }, + { + name: 'preserves unindented blank lines', + input: '\n alpha\n\n beta\n', + expected: 'alpha\n\nbeta' + }, + { + name: 'removes only one outer newline', + input: '\n\n alpha\n beta\n\n', + expected: '\nalpha\nbeta\n' + }, + { + name: 'does not let an unindented line force the common indentation to zero', + input: '\n alpha\n beta\ngamma\n', + expected: 'alpha\nbeta\ngamma' + }, + { + name: 'lets a whitespace-only line participate in common indentation', + input: '\n alpha\n \n beta\n', + expected: 'alpha\n \nbeta' + }, + { + name: 'does not strip leading spaces without a leading newline', + input: ' alpha', + expected: ' alpha' + }, + { + name: 'does not strip indentation from the first line', + input: ' alpha\n beta\n', + expected: ' alpha\nbeta' + }, + { + name: 'includes already-interpolated lines when finding indentation', + input: `\n alpha\n ${interpolatedLines}\n`, + expected: 'alpha\nbeta\ngamma' + }, + { + name: 'leaves an inline string unchanged', + input: 'alpha beta', + expected: 'alpha beta' + } +] as const + +for (const {name, input, expected} of cases) { + test(name, () => { + assert.strictEqual(dedent(input), expected) + }) +} diff --git a/__tests__/functions/deployment-confirmation.test.js b/__tests__/functions/deployment-confirmation.test.js deleted file mode 100644 index 0935d420..00000000 --- a/__tests__/functions/deployment-confirmation.test.js +++ /dev/null @@ -1,384 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {COLORS} from '../../src/functions/colors.js' -import {deploymentConfirmation} from '../../src/functions/deployment-confirmation.js' -import {API_HEADERS} from '../../src/functions/api-headers.js' - -const warningMock = vi.spyOn(core, 'warning') - -var context -var octokit -var data - -beforeEach(() => { - vi.clearAllMocks() - - // Mock setTimeout to execute immediately - vi.spyOn(global, 'setTimeout').mockImplementation(fn => fn()) - - // Mock Date.now to control time progression - const mockDate = new Date('2024-10-21T19:11:18Z').getTime() - vi.spyOn(Date, 'now').mockReturnValue(mockDate) - - process.env.GITHUB_SERVER_URL = 'https://github.com' - process.env.GITHUB_RUN_ID = '12345' - - context = { - actor: 'monalisa', - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - body: '.deploy', - id: 123, - user: { - login: 'monalisa' - }, - created_at: '2024-10-21T19:11:18Z', - updated_at: '2024-10-21T19:11:18Z', - html_url: - 'https://github.com/corp/test/pull/123#issuecomment-1231231231' - } - } - } - - octokit = { - rest: { - reactions: { - createForIssueComment: vi.fn().mockResolvedValue({ - data: {} - }), - listForIssueComment: vi.fn().mockResolvedValue({ - data: [] - }) - }, - issues: { - createComment: vi.fn().mockResolvedValue({ - data: { - id: 124 - } - }), - updateComment: vi.fn().mockResolvedValue({ - data: {} - }) - } - } - } - - data = { - deployment_confirmation_timeout: 60, - deploymentType: 'branch', - environment: 'production', - environmentUrl: 'https://example.com', - log_url: 'https://github.com/corp/test/actions/runs/12345', - ref: 'cool-branch', - sha: 'abc123', - committer: 'monalisa', - commit_html_url: 'https://github.com/corp/test/commit/abc123', - isVerified: true, - noopMode: false, - isFork: false, - body: '.deploy', - params: 'param1=1,param2=2', - parsed_params: { - param1: '1', - param2: '2' - } - } -}) - -test('successfully prompts for deployment confirmation and gets confirmed by the original actor', async () => { - // Mock that the user adds a +1 reaction - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'monalisa'}, - content: '+1' - } - ] - }) - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(true) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: expect.stringContaining('Deployment Confirmation Required'), - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(core.debug).toHaveBeenCalledWith( - 'deployment confirmation comment id: 124' - ) - expect(core.info).toHaveBeenCalledWith( - `โฐ waiting ${COLORS.highlight}60${COLORS.reset} seconds for deployment confirmation` - ) - expect(core.info).toHaveBeenCalledWith( - `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` - ) - - expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledWith({ - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - - expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({ - body: expect.stringContaining('โœ… Deployment confirmed by __monalisa__'), - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) -}) - -test('successfully prompts for deployment confirmation and gets confirmed by the original actor with some null data params in the issue comment', async () => { - data.params = null - data.parsed_params = null - data.environmentUrl = null - data.isVerified = false - - // Mock that the user adds a +1 reaction - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'monalisa'}, - content: '+1' - } - ] - }) - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(true) - expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({ - body: expect.stringContaining('"url": null'), - issue_number: 1, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(core.debug).toHaveBeenCalledWith( - 'deployment confirmation comment id: 124' - ) - expect(core.info).toHaveBeenCalledWith( - `โฐ waiting ${COLORS.highlight}60${COLORS.reset} seconds for deployment confirmation` - ) - expect(core.info).toHaveBeenCalledWith( - `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` - ) - - expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledWith({ - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - - expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({ - body: expect.stringContaining('โœ… Deployment confirmed by __monalisa__'), - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) -}) - -test('user rejects the deployment with thumbs down', async () => { - // Mock that the user adds a -1 reaction - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'monalisa'}, - content: '-1' - } - ] - }) - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(false) - expect(octokit.rest.issues.createComment).toHaveBeenCalled() - expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalled() - - expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({ - body: expect.stringContaining('โŒ Deployment rejected by __monalisa__'), - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - - expect(core.setFailed).toHaveBeenCalledWith( - `โŒ deployment rejected by ${COLORS.highlight}monalisa${COLORS.reset}` - ) -}) - -test('deployment confirmation times out after no response', async () => { - // Mock empty reactions list (no user reaction) - octokit.rest.reactions.listForIssueComment.mockResolvedValue({ - data: [] - }) - - // Mock Date.now to first return start time, then timeout - Date.now - .mockReturnValueOnce(new Date('2024-10-21T19:11:18Z').getTime()) // Start time - .mockReturnValue(new Date('2024-10-21T19:12:30Z').getTime()) // After timeout - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(false) - expect(octokit.rest.issues.createComment).toHaveBeenCalled() - - expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({ - body: expect.stringContaining('โฑ๏ธ Deployment confirmation timed out'), - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - - expect(core.setFailed).toHaveBeenCalledWith( - `โฑ๏ธ deployment confirmation timed out after ${COLORS.highlight}60${COLORS.reset} seconds` - ) -}) - -test('ignores reactions from other users', async () => { - // First call returns reactions from other users - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'other-user'}, - content: '+1' - } - ] - }) - - // Second call includes the original actor's reaction - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'other-user'}, - content: '+1' - }, - { - user: {login: 'monalisa'}, - content: '+1' - } - ] - }) - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(true) - expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledTimes(2) - expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({ - body: expect.stringContaining('โœ… Deployment confirmed by __monalisa__'), - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) - expect(core.debug).toHaveBeenCalledWith( - 'ignoring reaction from other-user, expected monalisa' - ) - expect(core.info).toHaveBeenCalledWith( - `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` - ) -}) - -test('ignores non thumbsUp/thumbsDown reactions from the original actor', async () => { - // Mock reactions list with various reaction types from original actor - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'monalisa'}, - content: 'confused' - }, - { - user: {login: 'monalisa'}, - content: 'eyes' - }, - { - user: {login: 'monalisa'}, - content: 'rocket' - } - ] - }) - - // Add a thumbs up in the second poll - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'monalisa'}, - content: 'confused' - }, - { - user: {login: 'monalisa'}, - content: 'eyes' - }, - { - user: {login: 'monalisa'}, - content: 'rocket' - }, - { - user: {login: 'monalisa'}, - content: '+1' - } - ] - }) - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(true) - expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledTimes(2) - - // Verify that debug was called for each ignored reaction type - expect(core.debug).toHaveBeenCalledWith('ignoring reaction: confused') - expect(core.debug).toHaveBeenCalledWith('ignoring reaction: eyes') - expect(core.debug).toHaveBeenCalledWith('ignoring reaction: rocket') - - // Verify final confirmation happened - expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({ - body: expect.stringContaining('โœ… Deployment confirmed by __monalisa__'), - comment_id: 124, - owner: 'corp', - repo: 'test', - headers: API_HEADERS - }) -}) - -test('handles API errors gracefully', async () => { - // First call throws error - octokit.rest.reactions.listForIssueComment.mockRejectedValueOnce( - new Error('API error') - ) - - // Second call succeeds with valid reaction - octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({ - data: [ - { - user: {login: 'monalisa'}, - content: '+1' - } - ] - }) - - const result = await deploymentConfirmation(context, octokit, data) - - expect(result).toBe(true) - expect(warningMock).toHaveBeenCalledWith( - 'temporary failure when checking for reactions on the deployment confirmation comment: API error' - ) - expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledTimes(2) - expect(core.info).toHaveBeenCalledWith( - `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` - ) -}) diff --git a/__tests__/functions/deployment-confirmation.test.ts b/__tests__/functions/deployment-confirmation.test.ts new file mode 100644 index 00000000..f94faa50 --- /dev/null +++ b/__tests__/functions/deployment-confirmation.test.ts @@ -0,0 +1,681 @@ +import assert from 'node:assert/strict' +import {afterEach, beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {decodedJsonValue} from '../../src/trust-boundaries.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import { + assertCalledTimes, + assertCalledWith, + createMock, + stubEnv, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const debugMock = createMock() +const infoMock = createMock() +const warningMock = createMock() +const setFailedMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + info: infoMock, + setFailed: setFailedMock, + warning: warningMock +}) + +const {deploymentConfirmation} = + await import('../../src/functions/deployment-confirmation.ts') + +let context: Parameters[0] +let octokit: Parameters[1] +let data: Parameters[2] +const originalSetTimeout = globalThis.setTimeout +type ConfirmationOctokit = Parameters[1] +const createCommentMock = + createMock() +const updateCommentMock = + createMock() +const listReactionsMock = + createMock() +let advanceClock: ((delay: number) => void) | undefined +let timeoutDelays: number[] + +function immediateTimeout( + callback: (...args: TArgs) => void, + delay = 0, + ...args: TArgs +): NodeJS.Timeout { + timeoutDelays.push(delay) + advanceClock?.(delay) + callback(...args) + return originalSetTimeout(() => undefined, 0) +} + +function latestCreateCommentRequest(): NonNullable< + Parameters[0] +> { + const request = createCommentMock.mock.calls.at(-1)?.arguments[0] + if (!request) throw new Error('expected createComment to be called') + return request +} + +function latestUpdateCommentRequest(): NonNullable< + Parameters[0] +> { + const request = updateCommentMock.mock.calls.at(-1)?.arguments[0] + if (!request) throw new Error('expected updateComment to be called') + return request +} + +beforeEach(testContext => { + if (!('after' in testContext)) { + throw new Error('expected a test context') + } + + debugMock.mock.resetCalls() + infoMock.mock.resetCalls() + warningMock.mock.resetCalls() + setFailedMock.mock.resetCalls() + createCommentMock.mock.resetCalls() + updateCommentMock.mock.resetCalls() + listReactionsMock.mock.resetCalls() + advanceClock = undefined + timeoutDelays = [] + + // Mock setTimeout to execute immediately + mock.method(globalThis, 'setTimeout', immediateTimeout) + + stubEnv(testContext, 'GITHUB_SERVER_URL', 'https://github.com') + stubEnv(testContext, 'GITHUB_RUN_ID', '12345') + + context = createIssueCommentContext({ + actor: 'monalisa', + repo: { + owner: 'corp', + repo: 'test' + }, + issue: { + number: 1 + }, + payload: { + comment: { + body: '.deploy', + id: 123, + user: { + login: 'monalisa' + }, + created_at: '2024-10-21T19:11:18Z', + updated_at: '2024-10-21T19:11:18Z', + html_url: + 'https://github.com/corp/test/pull/123#issuecomment-1231231231' + } + } + }) + + createCommentMock.mock.mockImplementation(() => + Promise.resolve({data: {id: 124}}) + ) + updateCommentMock.mock.mockImplementation(() => Promise.resolve({data: {}})) + listReactionsMock.mock.mockImplementation(() => Promise.resolve({data: []})) + octokit = { + rest: { + reactions: { + listForIssueComment: listReactionsMock + }, + issues: { + createComment: createCommentMock, + updateComment: updateCommentMock + } + } + } + + data = { + deployment_confirmation_timeout: 60, + deploymentType: 'branch', + environment: 'production', + environmentUrl: 'https://example.com', + github_run_id: 12345, + log_url: 'https://github.com/corp/test/actions/runs/12345', + ref: 'cool-branch', + sha: 'abc123', + committer: 'monalisa', + commit_html_url: 'https://github.com/corp/test/commit/abc123', + isVerified: true, + noopMode: false, + isFork: false, + body: '.deploy', + params: 'param1=1,param2=2', + parsed_params: { + _: [], + param1: '1', + param2: '2' + } + } +}) + +afterEach(() => { + mock.restoreAll() +}) + +test('successfully prompts for deployment confirmation and gets confirmed by the original actor', async () => { + // Mock that the user adds a +1 reaction + listReactionsMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: [ + { + user: {login: 'monalisa'}, + content: '+1' + } + ] + }) + ) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'confirmed') + const createRequest = latestCreateCommentRequest() + assert.ok(createRequest.body.includes('Deployment Confirmation Required')) + assert.deepStrictEqual(createRequest, { + body: createRequest.body, + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(debugMock, 'deployment confirmation comment id: 124') + assertCalledWith( + infoMock, + `โฐ waiting ${COLORS.highlight}60${COLORS.reset} seconds for deployment confirmation` + ) + assertCalledWith( + infoMock, + `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` + ) + + assertCalledWith(listReactionsMock, { + comment_id: 124, + owner: 'corp', + page: 1, + per_page: 100, + repo: 'test', + headers: API_HEADERS + }) + + const updateRequest = latestUpdateCommentRequest() + assert.ok( + updateRequest.body.includes('โœ… Deployment confirmed by __monalisa__') + ) + assert.deepStrictEqual(updateRequest, { + body: updateRequest.body, + comment_id: 124, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) +}) + +test('successfully prompts for deployment confirmation and gets confirmed by the original actor with some null data params in the issue comment', async () => { + data = { + ...data, + params: null, + parsed_params: null, + environmentUrl: null, + isVerified: false + } + + // Mock that the user adds a +1 reaction + listReactionsMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: [ + { + user: {login: 'monalisa'}, + content: '+1' + } + ] + }) + ) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'confirmed') + const createRequest = latestCreateCommentRequest() + assert.ok(createRequest.body.includes('"url": null')) + assert.deepStrictEqual(createRequest, { + body: createRequest.body, + issue_number: 1, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith(debugMock, 'deployment confirmation comment id: 124') + assertCalledWith( + infoMock, + `โฐ waiting ${COLORS.highlight}60${COLORS.reset} seconds for deployment confirmation` + ) + assertCalledWith( + infoMock, + `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` + ) + + assertCalledWith(listReactionsMock, { + comment_id: 124, + owner: 'corp', + page: 1, + per_page: 100, + repo: 'test', + headers: API_HEADERS + }) + + const updateRequest = latestUpdateCommentRequest() + assert.ok( + updateRequest.body.includes('โœ… Deployment confirmed by __monalisa__') + ) + assert.deepStrictEqual(updateRequest, { + body: updateRequest.body, + comment_id: 124, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) +}) + +test('user rejects the deployment with thumbs down', async () => { + // Mock that the user adds a -1 reaction + listReactionsMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: [ + { + user: {login: 'monalisa'}, + content: '-1' + } + ] + }) + ) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'rejected') + assert.ok(createCommentMock.mock.callCount() > 0) + assert.ok(listReactionsMock.mock.callCount() > 0) + + const updateRequest = latestUpdateCommentRequest() + assert.ok( + updateRequest.body.includes('โŒ Deployment rejected by __monalisa__') + ) + assert.deepStrictEqual(updateRequest, { + body: updateRequest.body, + comment_id: 124, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + + assertCalledWith( + setFailedMock, + `โŒ deployment rejected by ${COLORS.highlight}monalisa${COLORS.reset}` + ) +}) + +test('deployment confirmation times out after no response', async testContext => { + testContext.mock.timers.enable({ + apis: ['Date'], + now: new Date('2024-10-21T19:11:18Z') + }) + advanceClock = () => + testContext.mock.timers.setTime(new Date('2024-10-21T19:12:30Z').getTime()) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'timed_out') + assert.ok(createCommentMock.mock.callCount() > 0) + + const updateRequest = latestUpdateCommentRequest() + assert.ok(updateRequest.body.includes('โฑ๏ธ Deployment confirmation timed out')) + assert.deepStrictEqual(updateRequest, { + body: updateRequest.body, + comment_id: 124, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + + assertCalledWith( + setFailedMock, + `โฑ๏ธ deployment confirmation timed out after ${COLORS.highlight}60${COLORS.reset} seconds` + ) +}) + +test('ignores reactions from other users', async () => { + // First call returns reactions from other users + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [{user: {login: 'other-user'}, content: '+1'}] + }), + 0 + ) + + // Second call includes the original actor's reaction + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [ + {user: {login: 'other-user'}, content: '+1'}, + {user: {login: 'monalisa'}, content: '+1'} + ] + }), + 1 + ) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'confirmed') + assertCalledTimes(listReactionsMock, 2) + const updateRequest = latestUpdateCommentRequest() + assert.ok( + updateRequest.body.includes('โœ… Deployment confirmed by __monalisa__') + ) + assert.deepStrictEqual(updateRequest, { + body: updateRequest.body, + comment_id: 124, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + assertCalledWith( + debugMock, + 'ignoring reaction from other-user, expected monalisa' + ) + assertCalledWith( + infoMock, + `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` + ) +}) + +test('ignores non thumbsUp/thumbsDown reactions from the original actor', async () => { + // Mock reactions list with various reaction types from original actor + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [ + {user: {login: 'monalisa'}, content: 'confused'}, + {user: {login: 'monalisa'}, content: 'eyes'}, + {user: {login: 'monalisa'}, content: 'rocket'} + ] + }), + 0 + ) + + // Add a thumbs up in the second poll + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [ + {user: {login: 'monalisa'}, content: 'confused'}, + {user: {login: 'monalisa'}, content: 'eyes'}, + {user: {login: 'monalisa'}, content: 'rocket'}, + {user: {login: 'monalisa'}, content: '+1'} + ] + }), + 1 + ) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'confirmed') + assertCalledTimes(listReactionsMock, 2) + + // Verify that debug was called for each ignored reaction type + assertCalledWith(debugMock, 'ignoring reaction: confused') + assertCalledWith(debugMock, 'ignoring reaction: eyes') + assertCalledWith(debugMock, 'ignoring reaction: rocket') + + // Verify final confirmation happened + const updateRequest = latestUpdateCommentRequest() + assert.ok( + updateRequest.body.includes('โœ… Deployment confirmed by __monalisa__') + ) + assert.deepStrictEqual(updateRequest, { + body: updateRequest.body, + comment_id: 124, + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) +}) + +test('handles API errors gracefully', async () => { + // First call throws error + listReactionsMock.mock.mockImplementationOnce( + () => Promise.reject(new Error('API error')), + 0 + ) + + // Second call succeeds with valid reaction + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [{user: {login: 'monalisa'}, content: '+1'}] + }), + 1 + ) + + const result = await deploymentConfirmation(context, octokit, data) + + assert.strictEqual(result, 'confirmed') + assertCalledWith( + warningMock, + 'temporary failure when checking for reactions on the deployment confirmation comment: API error' + ) + assertCalledTimes(listReactionsMock, 2) + assertCalledWith( + infoMock, + `โœ… deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}` + ) +}) + +for (const status of [408, 409, 429, 500, 599]) { + test(`retries a temporary HTTP ${status} confirmation failure`, async () => { + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.reject(Object.assign(new Error('temporary error'), {status})), + 0 + ) + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [{user: {login: 'monalisa'}, content: '+1'}] + }), + 1 + ) + + assert.strictEqual( + await deploymentConfirmation(context, octokit, data), + 'confirmed' + ) + assertCalledTimes(listReactionsMock, 2) + }) +} + +test('fails immediately on a permanent 4xx confirmation response', async () => { + listReactionsMock.mock.mockImplementation(() => + Promise.reject(Object.assign(new Error('not allowed'), {status: 403})) + ) + + await assert.rejects(deploymentConfirmation(context, octokit, data), { + message: 'not allowed' + }) + assertCalledTimes(listReactionsMock, 1) +}) + +test('fails immediately on an unsupported HTTP status', async () => { + listReactionsMock.mock.mockImplementation(() => + Promise.reject( + Object.assign(new Error('unexpected response'), {status: 600}) + ) + ) + + await assert.rejects(deploymentConfirmation(context, octokit, data), { + message: 'unexpected response' + }) +}) + +test('reads confirmation reactions across pages in API order', async () => { + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: Array.from({length: 100}, () => ({ + user: {login: 'other-user'}, + content: '+1' + })) + }), + 0 + ) + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [{user: {login: 'monalisa'}, content: '+1'}] + }), + 1 + ) + + assert.strictEqual( + await deploymentConfirmation(context, octokit, data), + 'confirmed' + ) + assertCalledWith(listReactionsMock, { + comment_id: 124, + owner: 'corp', + page: 2, + per_page: 100, + repo: 'test', + headers: API_HEADERS + }) +}) + +test('backs off confirmation polling at 2, 4, 8, and at most 10 seconds', async () => { + for (let index = 0; index < 4; index += 1) { + listReactionsMock.mock.mockImplementationOnce( + () => Promise.resolve({data: []}), + index + ) + } + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [{user: {login: 'monalisa'}, content: '+1'}] + }), + 4 + ) + + assert.strictEqual( + await deploymentConfirmation(context, octokit, data), + 'confirmed' + ) + assert.deepStrictEqual(timeoutDelays, [2000, 4000, 8000, 10_000]) +}) + +test('limits the final polling delay to the remaining deadline', async testContext => { + const initialTime = new Date('2024-10-21T19:11:18Z').getTime() + let currentTime = initialTime + testContext.mock.timers.enable({apis: ['Date'], now: initialTime}) + advanceClock = delay => { + currentTime += delay + testContext.mock.timers.setTime(currentTime) + } + data = {...data, deployment_confirmation_timeout: 5} + + assert.strictEqual( + await deploymentConfirmation(context, octokit, data), + 'timed_out' + ) + assert.deepStrictEqual(timeoutDelays, [2000, 3000]) +}) + +test('stops without sleeping when a reaction request reaches the deadline', async testContext => { + const initialTime = new Date('2024-10-21T19:11:18Z').getTime() + testContext.mock.timers.enable({apis: ['Date'], now: initialTime}) + listReactionsMock.mock.mockImplementation(() => { + testContext.mock.timers.setTime(initialTime + 60_000) + return Promise.resolve({data: []}) + }) + + assert.strictEqual( + await deploymentConfirmation(context, octokit, data), + 'timed_out' + ) + assert.deepStrictEqual(timeoutDelays, []) +}) + +test('renders arbitrary confirmation metadata as valid fenced JSON', async () => { + const body = 'quote " slash \\ newline\nUnicode ๐Ÿš€ and ````` backticks' + data = { + ...data, + body, + environment: 'prod"\\\n๐Ÿš€`````', + params: body + } + listReactionsMock.mock.mockImplementation(() => + Promise.resolve({data: [{user: {login: 'monalisa'}, content: '+1'}]}) + ) + + await deploymentConfirmation(context, octokit, data) + const rendered = String(latestCreateCommentRequest().body) + const match = rendered.match( + /\n\n(`{3,})json\n([\s\S]*?)\n\1\n\n/u + ) + if (match?.[1] === undefined || match[2] === undefined) { + throw new Error('expected confirmation metadata block') + } + assert.ok(match[1].length > 5) + const metadata = decodedJsonValue(match[2]) + assert.deepStrictEqual(metadata, { + type: 'branch', + environment: {name: data.environment, url: 'https://example.com'}, + deployment: {logs: data.log_url}, + git: { + branch: 'cool-branch', + commit: 'abc123', + verified: true, + committer: 'monalisa', + html_url: 'https://github.com/corp/test/commit/abc123' + }, + context: { + actor: 'monalisa', + noop: false, + fork: false, + comment: { + created_at: '2024-10-21T19:11:18Z', + updated_at: '2024-10-21T19:11:18Z', + body, + html_url: + 'https://github.com/corp/test/pull/123#issuecomment-1231231231' + } + }, + parameters: {raw: body, parsed: data.parsed_params} + }) +}) + +test('ignores a reaction whose user is null', async () => { + listReactionsMock.mock.mockImplementationOnce( + () => Promise.resolve({data: [{user: null, content: '+1'}]}), + 0 + ) + listReactionsMock.mock.mockImplementationOnce( + () => + Promise.resolve({ + data: [{user: {login: 'monalisa'}, content: '+1'}] + }), + 1 + ) + + assert.strictEqual( + await deploymentConfirmation(context, octokit, data), + 'confirmed' + ) + assertCalledWith(debugMock, 'ignoring reaction from an unknown user') + assertCalledTimes(listReactionsMock, 2) +}) diff --git a/__tests__/functions/deployment-template.test.ts b/__tests__/functions/deployment-template.test.ts new file mode 100644 index 00000000..ab27f661 --- /dev/null +++ b/__tests__/functions/deployment-template.test.ts @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {renderDeploymentTemplate} from '../../src/functions/deployment-template.ts' + +const variables = { + actor: 'monalisa', + approved_reviews_count: 2, + commit_verified: true, + deployment_end_time: '2026-07-11T12:00:00Z', + deployment_id: 123, + environment: 'production', + environment_url: null, + fork: false, + logs: 'https://github.com/corp/test/actions/runs/123', + noop: false, + params: '--target=production', + parsed_params: '{"target":"production"}', + ref: 'feature', + results: 'deployed successfully', + review_decision: 'APPROVED', + sha: '0123456789abcdef0123456789abcdef01234567', + status: 'success', + total_seconds: 8 +} as const + +test('renders allowlisted scalar variables and null as an empty string', () => { + assert.strictEqual( + renderDeploymentTemplate( + '{{ actor }}|{{ approved_reviews_count }}|{{ commit_verified }}|{{ environment_url }}|{{ fork }}', + variables + ), + 'monalisa|2|true||false' + ) +}) + +test('escapes HTML-significant characters in ordinary variables', () => { + assert.strictEqual( + renderDeploymentTemplate('{{ value }}', { + value: 'Tom & Jerry\'s' + }), + '<tag data-value="quoted">Tom & Jerry's</tag>' + ) +}) + +test('renders results raw and does not evaluate template syntax in its value', () => { + const results = + '
{{ actor }}{% if commit_verified %}owned{% endif %}{# comment #}
' + + assert.strictEqual( + renderDeploymentTemplate('before {{ results }} after {{ actor }}', { + ...variables, + results + }), + `before ${results} after monalisa` + ) +}) + +test('supports truthy, negated, else, and nested conditions', () => { + assert.strictEqual( + renderDeploymentTemplate( + '{% if commit_verified %}status:{% if noop %}noop{% else %}deploy{% endif %}{% endif %}|{% if not fork %}not-fork{% else %}fork{% endif %}', + variables + ), + 'status:deploy|not-fork' + ) + + assert.strictEqual( + renderDeploymentTemplate( + '{% if noop %}{% if commit_verified %}hidden{% endif %}{{ actor }}{% else %}visible{% endif %}', + variables + ), + 'visible' + ) +}) + +const comparisons = [ + ['status == "success"', 'yes'], + ['status === "success"', 'yes'], + ['status != "failure"', 'yes'], + ['status !== "failure"', 'yes'], + ['approved_reviews_count === 2', 'yes'], + ['approved_reviews_count === 2e0', 'yes'], + ['commit_verified === true', 'yes'], + ['fork === false', 'yes'], + ['environment_url === null', 'yes'], + ['approved_reviews_count == "2"', 'no'], + ['status === "failure"', 'no'] +] as const + +for (const [condition, expected] of comparisons) { + test(`evaluates the comparison ${condition}`, () => { + assert.strictEqual( + renderDeploymentTemplate( + `{% if ${condition} %}yes{% else %}no{% endif %}`, + variables + ), + expected + ) + }) +} + +test('supports literal-only ternaries with string, boolean, number, and null results', () => { + assert.strictEqual( + renderDeploymentTemplate( + '{{ "ok" if status === "success" else "bad" }}|{{ false if noop else true }}|{{ 1.5e1 if commit_verified else -2 }}|{{ "unused" if fork else null }}|{{ "line\\nnext" if status === "success" else "" }}', + variables + ), + 'ok|true|15||line\nnext' + ) + + assert.strictEqual( + renderDeploymentTemplate( + '{{ "unused" if status === "failure" else "fallback" }}', + variables + ), + 'fallback' + ) +}) + +test('supports negative and exponent numeric literals plus escaped string literals', () => { + assert.strictEqual( + renderDeploymentTemplate( + '{% if negative === -2.5e-1 %}negative{% else %}bad{% endif %}|{% if zero === -0 %}zero{% else %}bad{% endif %}|{% if text === "line\\nnext" %}text{% else %}bad{% endif %}|{{ "left\\tright" if enabled else "bad" }}', + {enabled: true, negative: -0.25, text: 'line\nnext', zero: 0} + ), + 'negative|zero|text|left\tright' + ) +}) + +test('rejects inherited template variables', () => { + const inheritedVariables = {visible: 'value'} + Reflect.setPrototypeOf(inheritedVariables, {secret: 'inherited'}) + + assert.strictEqual( + renderDeploymentTemplate('{{ visible }}', inheritedVariables), + 'value' + ) + assert.throws( + () => renderDeploymentTemplate('{{ secret }}', inheritedVariables), + new Error('Unknown deployment template variable: secret') + ) +}) + +test('rejects unknown variables', () => { + assert.throws( + () => renderDeploymentTemplate('{{ constructor }}', variables), + new Error('Unknown deployment template variable: constructor') + ) + assert.throws( + () => + renderDeploymentTemplate( + '{% if missing %}unexpected{% endif %}', + variables + ), + new Error('Unknown deployment template variable: missing') + ) +}) + +test('rejects unsupported expressions in inactive branches', () => { + assert.throws( + () => + renderDeploymentTemplate( + '{% if fork %}{{ actor.toString() }}{% endif %}', + variables + ), + new Error('Unsupported deployment template expression: actor.toString()') + ) +}) + +for (const condition of ['status', 'not environment_url']) { + test(`rejects non-boolean condition ${condition}`, () => { + assert.throws( + () => + renderDeploymentTemplate( + `{% if ${condition} %}unexpected{% endif %}`, + variables + ), + new Error(`Deployment template condition is not boolean: ${condition}`) + ) + }) +} + +const unsupportedTemplates = [ + [ + 'filters', + '{{ actor | safe }}', + 'Unsupported deployment template expression: actor | safe' + ], + [ + 'property access', + '{{ actor.constructor }}', + 'Unsupported deployment template expression: actor.constructor' + ], + [ + 'function calls', + '{{ actor() }}', + 'Unsupported deployment template expression: actor()' + ], + [ + 'variable ternary branches', + '{{ actor if status else ref }}', + 'Unsupported deployment template expression: actor if status else ref' + ], + [ + 'boolean operators', + '{% if status and commit_verified %}x{% endif %}', + 'Unsupported deployment template condition: status and commit_verified' + ], + [ + 'invalid negation', + '{% if not actor.name %}x{% endif %}', + 'Unsupported deployment template condition: not actor.name' + ], + [ + 'leading-zero numeric literal', + '{% if approved_reviews_count === 02 %}x{% endif %}', + 'Unsupported deployment template condition: approved_reviews_count === 02' + ], + [ + 'non-finite numeric literal', + '{% if approved_reviews_count === Infinity %}x{% endif %}', + 'Unsupported deployment template condition: approved_reviews_count === Infinity' + ], + [ + 'unsupported statement', + '{% for item in results %}x{% endfor %}', + 'Unsupported deployment template statement: for item in results' + ], + [ + 'orphan else', + '{% else %}', + 'Unexpected deployment template else statement' + ], + [ + 'duplicate else', + '{% if commit_verified %}a{% else %}b{% else %}c{% endif %}', + 'Unexpected deployment template else statement' + ], + [ + 'orphan endif', + '{% endif %}', + 'Unexpected deployment template endif statement' + ], + [ + 'unclosed if', + '{% if commit_verified %}x', + 'Unclosed deployment template if statement' + ], + ['template comments', '{# hidden #}', 'Malformed deployment template syntax'], + [ + 'malformed syntax before a valid token', + '{# hidden #}{{ actor }}', + 'Malformed deployment template syntax' + ], + ['unclosed expression', '{{ actor', 'Malformed deployment template syntax'], + [ + 'unclosed statement', + '{% if status', + 'Malformed deployment template syntax' + ], + [ + 'trailing malformed syntax', + '{{ actor }} {% broken', + 'Malformed deployment template syntax' + ] +] as const + +for (const [name, template, message] of unsupportedTemplates) { + test(`rejects ${name}`, () => { + assert.throws( + () => renderDeploymentTemplate(template, variables), + new Error(message) + ) + }) +} diff --git a/__tests__/functions/deployment.test.js b/__tests__/functions/deployment.test.js deleted file mode 100644 index fd6496e3..00000000 --- a/__tests__/functions/deployment.test.js +++ /dev/null @@ -1,315 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import { - createDeploymentStatus, - latestActiveDeployment, - activeDeployment -} from '../../src/functions/deployment.js' -import {API_HEADERS} from '../../src/functions/api-headers.js' - -var octokit -var context -var mockDeploymentData -var mockDeploymentResults -beforeEach(() => { - vi.clearAllMocks() - process.env.GITHUB_SERVER_URL = 'https://github.com' - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - payload: { - comment: { - id: '1' - } - }, - runId: 12345 - } - - mockDeploymentData = { - repository: { - deployments: { - nodes: [ - { - createdAt: '2024-09-19T20:18:18Z', - environment: 'production', - updatedAt: '2024-09-19T20:18:21Z', - id: 'DE_kwDOID9x8M5sC6QZ', - payload: - '{"type":"branch-deploy", "sha": "315cec138fc9d7dac8a47c6bba4217d3965ede3b"}', - state: 'ACTIVE', - creator: { - login: 'github-actions' - }, - ref: { - name: 'main' - }, - commit: { - oid: '315cec138fc9d7dac8a47c6bba4217d3965ede3b' - } - } - ], - pageInfo: { - endCursor: null, - hasNextPage: false - } - } - } - } - - mockDeploymentResults = { - createdAt: '2024-09-19T20:18:18Z', - environment: 'production', - updatedAt: '2024-09-19T20:18:21Z', - id: 'DE_kwDOID9x8M5sC6QZ', - payload: - '{"type":"branch-deploy", "sha": "315cec138fc9d7dac8a47c6bba4217d3965ede3b"}', - state: 'ACTIVE', - creator: { - login: 'github-actions' - }, - ref: { - name: 'main' - }, - commit: { - oid: '315cec138fc9d7dac8a47c6bba4217d3965ede3b' - } - } - - octokit = { - rest: { - repos: { - createDeploymentStatus: vi.fn().mockReturnValueOnce({ - data: {} - }) - } - } - } -}) - -const environment = 'production' -const deploymentId = 123 -const ref = 'test-ref' -const logUrl = 'https://github.com/corp/test/actions/runs/12345' - -const createMockGraphQLOctokit = data => ({ - graphql: vi.fn().mockReturnValueOnce(data) -}) - -test('creates an in_progress deployment status', async () => { - expect( - await createDeploymentStatus( - octokit, - context, - ref, - 'in_progress', - deploymentId, - environment - ) - ).toStrictEqual({}) - - expect(octokit.rest.repos.createDeploymentStatus).toHaveBeenCalledWith({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: ref, - deployment_id: deploymentId, - state: 'in_progress', - environment: environment, - environment_url: null, - log_url: logUrl, - headers: API_HEADERS - }) -}) - -test('successfully fetches the latest deployment', async () => { - octokit = createMockGraphQLOctokit(mockDeploymentData) - - expect( - await latestActiveDeployment(octokit, context, environment) - ).toStrictEqual(mockDeploymentResults) - - expect(octokit.graphql).toHaveBeenCalled() -}) - -test('returns null if no deployments are found', async () => { - octokit = createMockGraphQLOctokit({ - repository: { - deployments: { - nodes: [] - } - } - }) - - expect(await latestActiveDeployment(octokit, context, environment)).toBeNull() - - expect(octokit.graphql).toHaveBeenCalled() -}) - -test('returns null if no deployments are found in 3 pages of queries', async () => { - octokit.graphql = vi - .fn() - .mockReturnValueOnce({ - repository: { - deployments: { - nodes: [ - { - state: 'INACTIVE' - } - ], - pageInfo: { - endCursor: 'cursor', - hasNextPage: true - } - } - } - }) - .mockReturnValueOnce({ - repository: { - deployments: { - nodes: [ - { - state: 'INACTIVE' - } - ], - pageInfo: { - endCursor: 'cursor', - hasNextPage: true - } - } - } - }) - .mockReturnValueOnce({ - repository: { - deployments: { - nodes: [ - { - state: 'INACTIVE' - } - ], - pageInfo: { - endCursor: 'cursor', - hasNextPage: false - } - } - } - }) - - expect(await latestActiveDeployment(octokit, context, environment)).toBeNull() - - expect(octokit.graphql).toHaveBeenCalledTimes(3) -}) - -test('returns the deployment when it is found in the second page of queries', async () => { - octokit.graphql = vi - .fn() - .mockReturnValueOnce({ - repository: { - deployments: { - nodes: [ - { - state: 'INACTIVE' - }, - { - state: 'INACTIVE' - }, - { - state: 'INACTIVE' - }, - { - state: 'PENDING' - } - ], - pageInfo: { - endCursor: 'cursor', - hasNextPage: true - } - } - } - }) - .mockReturnValueOnce({ - repository: { - deployments: { - nodes: [ - { - state: 'INACTIVE' - }, - { - state: 'INACTIVE' - }, - { - state: 'ACTIVE' - }, - { - state: 'INACTIVE' - } - ], - pageInfo: { - endCursor: 'cursor', - hasNextPage: true - } - } - } - }) - - expect( - await latestActiveDeployment(octokit, context, environment) - ).toStrictEqual({ - state: 'ACTIVE' - }) - - expect(octokit.graphql).toHaveBeenCalledTimes(2) -}) - -test('returns false if the deployment is not active', async () => { - mockDeploymentData.repository.deployments.nodes[0].state = 'INACTIVE' - octokit = createMockGraphQLOctokit(mockDeploymentData) - - expect(await activeDeployment(octokit, context, environment, 'sha')).toBe( - false - ) - - expect(octokit.graphql).toHaveBeenCalled() -}) - -test('returns false if the deployment does not match the sha', async () => { - mockDeploymentData.repository.deployments.nodes[0].commit.oid = 'badsha' - octokit = createMockGraphQLOctokit(mockDeploymentData) - - expect(await activeDeployment(octokit, context, environment, 'sha')).toBe( - false - ) - - expect(octokit.graphql).toHaveBeenCalled() -}) - -test('returns true if the deployment is active and matches the sha', async () => { - octokit = createMockGraphQLOctokit(mockDeploymentData) - - expect( - await activeDeployment( - octokit, - context, - environment, - '315cec138fc9d7dac8a47c6bba4217d3965ede3b' - ) - ).toBe(true) - - expect(octokit.graphql).toHaveBeenCalled() -}) - -test('returns false if the deployment is not found', async () => { - octokit = createMockGraphQLOctokit({ - repository: { - deployments: { - nodes: [] - } - } - }) - - expect(await activeDeployment(octokit, context, environment, 'sha')).toBe( - false - ) - - expect(octokit.graphql).toHaveBeenCalled() -}) diff --git a/__tests__/functions/deployment.test.ts b/__tests__/functions/deployment.test.ts new file mode 100644 index 00000000..d73d54b0 --- /dev/null +++ b/__tests__/functions/deployment.test.ts @@ -0,0 +1,510 @@ +import assert from 'node:assert/strict' +import {afterEach, beforeEach, mock, test} from 'node:test' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledTimes, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type DeploymentModule = typeof import('../../src/functions/deployment.ts') +type StatusOctokit = Parameters[0] +type GraphqlOctokit = Parameters[0] +type DeploymentPage = Awaited> +type DeploymentNode = NonNullable< + DeploymentPage['repository'] +>['deployments']['nodes'][number] + +const debugMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock +}) + +const { + activeDeployment, + createDeploymentStatus, + latestActiveDeployment, + latestBranchDeployDeployment +} = await import('../../src/functions/deployment.ts') + +const createDeploymentStatusMock = + createMock() +const graphqlMock = createMock() + +const statusOctokit: StatusOctokit = { + rest: {repos: {createDeploymentStatus: createDeploymentStatusMock}} +} +const graphqlOctokit: GraphqlOctokit = {graphql: graphqlMock} + +const activeDeploymentNode = { + createdAt: '2024-09-19T20:18:18Z', + environment: 'production', + updatedAt: '2024-09-19T20:18:21Z', + id: 'DE_kwDOID9x8M5sC6QZ', + payload: + '{"type":"branch-deploy", "sha": "315cec138fc9d7dac8a47c6bba4217d3965ede3b"}', + state: 'ACTIVE', + creator: {login: 'github-actions'}, + ref: {name: 'main'}, + commit: {oid: '315cec138fc9d7dac8a47c6bba4217d3965ede3b'} +} + +function deploymentPage( + nodes: readonly DeploymentNode[], + hasNextPage = false, + endCursor: string | null = null, + repositoryId = 'R_test', + nameWithOwner = 'corp/test' +): DeploymentPage { + return { + repository: { + id: repositoryId, + nameWithOwner, + deployments: { + nodes, + pageInfo: {endCursor, hasNextPage} + } + } + } +} + +let context: Parameters[1] +const originalServerUrl = process.env['GITHUB_SERVER_URL'] + +beforeEach(() => { + debugMock.mock.resetCalls() + createDeploymentStatusMock.mock.resetCalls() + graphqlMock.mock.resetCalls() + process.env['GITHUB_SERVER_URL'] = 'https://github.com' + context = createContext({ + repo: {owner: 'corp', repo: 'test'}, + runId: 12345 + }) + createDeploymentStatusMock.mock.mockImplementation(() => + Promise.resolve({ + data: {id: 456, url: 'https://api.github.com/deployments/456'} + }) + ) +}) + +afterEach(() => { + if (originalServerUrl === undefined) { + delete process.env['GITHUB_SERVER_URL'] + } else { + process.env['GITHUB_SERVER_URL'] = originalServerUrl + } +}) + +const environment = 'production' +const deploymentId = 123 +const ref = 'test-ref' +const logUrl = 'https://github.com/corp/test/actions/runs/12345' + +test('creates an in_progress deployment status', async () => { + assert.deepStrictEqual( + await createDeploymentStatus( + statusOctokit, + context, + ref, + 'in_progress', + deploymentId, + environment + ), + { + id: 456, + url: 'https://api.github.com/deployments/456' + } + ) + + assert.deepStrictEqual( + createDeploymentStatusMock.mock.calls.map(call => call.arguments), + [ + [ + { + owner: context.repo.owner, + repo: context.repo.repo, + ref, + deployment_id: deploymentId, + state: 'in_progress', + environment, + environment_url: null, + log_url: logUrl, + headers: API_HEADERS + } + ] + ] + ) +}) + +for (const state of [ + 'error', + 'failure', + 'inactive', + 'in_progress', + 'pending', + 'queued', + 'success' +] as const) { + test(`preserves the ${state} deployment status request contract`, async () => { + const environmentUrl = 'https://example.com/staging?mode=preview#ready' + + assert.deepStrictEqual( + await createDeploymentStatus( + statusOctokit, + context, + ref, + state, + '123', + environment, + environmentUrl + ), + {id: 456, url: 'https://api.github.com/deployments/456'} + ) + assert.deepStrictEqual( + createDeploymentStatusMock.mock.calls[0]?.arguments[0], + { + owner: context.repo.owner, + repo: context.repo.repo, + ref, + deployment_id: '123', + state, + environment, + environment_url: environmentUrl, + log_url: logUrl, + headers: API_HEADERS + } + ) + }) +} + +test('successfully fetches the latest deployment', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([activeDeploymentNode])) + ) + + assert.deepStrictEqual( + await latestActiveDeployment(graphqlOctokit, context, environment), + activeDeploymentNode + ) + assertCalledTimes(graphqlMock, 1) + const call = graphqlMock.mock.calls[0] + assert.ok(call) + assert.match(call.arguments[0], /after: \$cursor/) + assert.deepStrictEqual(call.arguments[1], { + repo_owner: 'corp', + repo_name: 'test', + environment, + first: 1, + cursor: null + }) +}) + +test('returns null if no deployments are found', async () => { + graphqlMock.mock.mockImplementation(() => Promise.resolve(deploymentPage([]))) + + assert.strictEqual( + await latestActiveDeployment(graphqlOctokit, context, environment), + null + ) + assertCalledTimes(graphqlMock, 1) +}) + +test('returns null when the newest deployment is inactive without reading older pages', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage( + [{...activeDeploymentNode, id: 'inactive', state: 'INACTIVE'}], + true, + 'cursor' + ) + ) + ) + + assert.strictEqual( + await latestActiveDeployment(graphqlOctokit, context, environment), + null + ) + assertCalledTimes(graphqlMock, 1) +}) + +test('returns false if the deployment is not active', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage([{...activeDeploymentNode, state: 'INACTIVE'}]) + ) + ) + + assert.strictEqual( + await activeDeployment(graphqlOctokit, context, environment, 'sha'), + false + ) + assertCalledTimes(graphqlMock, 1) +}) + +test('returns false if the deployment does not match the sha', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage([{...activeDeploymentNode, commit: {oid: 'badsha'}}]) + ) + ) + + assert.strictEqual( + await activeDeployment(graphqlOctokit, context, environment, 'sha'), + false + ) + assertCalledTimes(graphqlMock, 1) +}) + +test('returns true if the deployment is active and matches the sha', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([activeDeploymentNode])) + ) + + assert.strictEqual( + await activeDeployment( + graphqlOctokit, + context, + environment, + '315cec138fc9d7dac8a47c6bba4217d3965ede3b' + ), + true + ) + assertCalledTimes(graphqlMock, 1) +}) + +test('returns false if the deployment is not found', async () => { + graphqlMock.mock.mockImplementation(() => Promise.resolve(deploymentPage([]))) + + assert.strictEqual( + await activeDeployment(graphqlOctokit, context, environment, 'sha'), + false + ) + assertCalledTimes(graphqlMock, 1) +}) + +test('paginates with cursor variables to find the newest branch-deploy deployment', async () => { + graphqlMock.mock.mockImplementationOnce( + () => + Promise.resolve( + deploymentPage( + [ + { + ...activeDeploymentNode, + id: 'other-object', + payload: {type: 'other'} + }, + { + ...activeDeploymentNode, + id: 'other-json', + payload: '{"type":"other"}' + }, + {...activeDeploymentNode, id: 'null-payload', payload: null} + ], + true, + 'cursor-1' + ) + ), + 0 + ) + graphqlMock.mock.mockImplementationOnce( + () => Promise.resolve(deploymentPage([activeDeploymentNode])), + 1 + ) + + assert.deepStrictEqual( + await latestBranchDeployDeployment(graphqlOctokit, context, environment), + activeDeploymentNode + ) + assert.deepStrictEqual( + graphqlMock.mock.calls.map(call => call.arguments[1]), + [ + { + repo_owner: 'corp', + repo_name: 'test', + environment, + first: 100, + cursor: null + }, + { + repo_owner: 'corp', + repo_name: 'test', + environment, + first: 100, + cursor: 'cursor-1' + } + ] + ) +}) + +test('accepts an object branch-deploy payload', async () => { + const deployment = { + ...activeDeploymentNode, + payload: {type: 'branch-deploy'} + } + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([deployment])) + ) + + assert.deepStrictEqual( + await latestBranchDeployDeployment(graphqlOctokit, context, environment), + deployment + ) +}) + +test('accepts the double-encoded payload returned by GitHub GraphQL', async () => { + const deployment = { + ...activeDeploymentNode, + payload: JSON.stringify(JSON.stringify({type: 'branch-deploy'})) + } + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([deployment])) + ) + + assert.deepStrictEqual( + await latestBranchDeployDeployment(graphqlOctokit, context, environment), + deployment + ) +}) + +test('returns null when deployment history has no branch-deploy deployment', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage([ + {...activeDeploymentNode, payload: {type: 'another-deployer'}} + ]) + ) + ) + + assert.strictEqual( + await latestBranchDeployDeployment(graphqlOctokit, context, environment), + null + ) +}) + +for (const payload of [ + '{', + JSON.stringify('{'), + JSON.stringify(JSON.stringify(JSON.stringify({type: 'branch-deploy'}))), + 1, + {type: null} +] as const) { + test(`stops at malformed deployment payload ${JSON.stringify(payload)}`, async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage([ + {...activeDeploymentNode, payload}, + activeDeploymentNode + ]) + ) + ) + + assert.strictEqual( + await latestBranchDeployDeployment(graphqlOctokit, context, environment), + null + ) + }) +} + +test('treats an object without a deployment type as unrelated history', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage([ + {...activeDeploymentNode, payload: {}}, + activeDeploymentNode + ]) + ) + ) + + assert.deepStrictEqual( + await latestBranchDeployDeployment(graphqlOctokit, context, environment), + activeDeploymentNode + ) +}) + +for (const [description, page] of [ + ['missing repository', {repository: null}], + ['empty repository identity', deploymentPage([], false, null, '')] +] as const) { + test(`rejects deployment history with ${description}`, async () => { + graphqlMock.mock.mockImplementation(() => Promise.resolve(page)) + + await assert.rejects( + latestBranchDeployDeployment(graphqlOctokit, context, environment), + /deployment history has no repository identity/ + ) + }) +} + +test('rejects deployment history for another repository', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([], false, null, 'R_other', 'corp/other')) + ) + + await assert.rejects( + latestBranchDeployDeployment(graphqlOctokit, context, environment), + /deployment history belongs to another repository/ + ) +}) + +test('rejects deployment history for another environment', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve( + deploymentPage([{...activeDeploymentNode, environment: 'staging'}]) + ) + ) + + await assert.rejects( + latestBranchDeployDeployment(graphqlOctokit, context, environment), + /deployment history belongs to another environment/ + ) +}) + +test('rejects a repository identity change while paging deployment history', async () => { + graphqlMock.mock.mockImplementationOnce( + () => Promise.resolve(deploymentPage([], true, 'cursor-1')), + 0 + ) + graphqlMock.mock.mockImplementationOnce( + () => + Promise.resolve( + deploymentPage([], false, null, 'R_changed', 'CORP/TEST') + ), + 1 + ) + + await assert.rejects( + latestBranchDeployDeployment(graphqlOctokit, context, environment), + /deployment history repository changed while paging/ + ) +}) + +for (const [description, cursor] of [ + ['missing', null], + ['empty', ''] +] as const) { + test(`rejects a ${description} deployment page cursor`, async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([], true, cursor)) + ) + + await assert.rejects( + latestBranchDeployDeployment(graphqlOctokit, context, environment), + /deployment page has no end cursor/ + ) + }) +} + +test('rejects a repeated deployment page cursor', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve(deploymentPage([], true, 'cursor-1')) + ) + + await assert.rejects( + latestBranchDeployDeployment(graphqlOctokit, context, environment), + /deployment page cursor did not advance/ + ) + assertCalledTimes(graphqlMock, 2) +}) diff --git a/__tests__/functions/deprecated-checks.test.js b/__tests__/functions/deprecated-checks.test.js deleted file mode 100644 index bd4e3d2c..00000000 --- a/__tests__/functions/deprecated-checks.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import {isDeprecated} from '../../src/functions/deprecated-checks.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' - -const docsLink = - 'https://github.com/github/branch-deploy/blob/main/docs/deprecated.md' -const warningMock = vi.spyOn(core, 'warning') - -var context -var octokit - -beforeEach(() => { - vi.clearAllMocks() - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - id: '1' - } - } - } - - octokit = { - rest: { - reactions: { - createForIssueComment: vi.fn().mockReturnValueOnce({ - data: {} - }) - }, - issues: { - createComment: vi.fn().mockReturnValueOnce({ - data: {} - }) - } - } - } -}) - -test('checks a deployment message and does not find anything that is deprecated', async () => { - const body = '.deploy to production' - expect(await isDeprecated(body, octokit, context)).toBe(false) -}) - -test('checks a deployment message and finds the old "noop" style command which is now deprecated', async () => { - const body = '.deploy noop' - expect(await isDeprecated(body, octokit, context)).toBe(true) - expect(warningMock).toHaveBeenCalledWith( - `'.deploy noop' is deprecated. Please view the docs for more information: ${docsLink}#deploy-noop` - ) -}) diff --git a/__tests__/functions/deprecated-checks.test.ts b/__tests__/functions/deprecated-checks.test.ts new file mode 100644 index 00000000..7749e504 --- /dev/null +++ b/__tests__/functions/deprecated-checks.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import type {DeprecatedChecksOctokit} from '../../src/functions/deprecated-checks.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const warningMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + warning: warningMock +}) + +const {isDeprecated} = await import('../../src/functions/deprecated-checks.ts') + +const docsLink = + 'https://github.com/github/branch-deploy/blob/main/docs/deprecated.md' + +let context: Parameters[2] +let octokit: Parameters[1] + +beforeEach(() => { + warningMock.mock.resetCalls() + + context = createIssueCommentContext({ + repo: {owner: 'corp', repo: 'test'}, + issue: {number: 1}, + payload: {comment: {id: 1}} + }) + + octokit = { + rest: { + reactions: { + createForIssueComment: + createMock< + DeprecatedChecksOctokit['rest']['reactions']['createForIssueComment'] + >() + }, + issues: { + createComment: + createMock< + DeprecatedChecksOctokit['rest']['issues']['createComment'] + >() + } + } + } satisfies DeprecatedChecksOctokit +}) + +test('checks a deployment message and does not find anything that is deprecated', async () => { + const body = '.deploy to production' + assert.strictEqual(await isDeprecated(body, octokit, context), false) +}) + +test('checks a deployment message and finds the old "noop" style command which is now deprecated', async () => { + const body = '.deploy noop' + assert.strictEqual(await isDeprecated(body, octokit, context), true) + assertCalledWith( + warningMock, + `'.deploy noop' is deprecated. Please view the docs for more information: ${docsLink}#deploy-noop` + ) +}) diff --git a/__tests__/functions/environment-targets.test.js b/__tests__/functions/environment-targets.test.js deleted file mode 100644 index 40591ed7..00000000 --- a/__tests__/functions/environment-targets.test.js +++ /dev/null @@ -1,1179 +0,0 @@ -import {environmentTargets} from '../../src/functions/environment-targets.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' -import dedent from 'dedent-js' -import {COLORS} from '../../src/functions/colors.js' - -const debugMock = vi.spyOn(core, 'debug') -const infoMock = vi.spyOn(core, 'info') -const setOutputMock = vi.spyOn(core, 'setOutput') -const saveStateMock = vi.spyOn(core, 'saveState') -const warningMock = vi.spyOn(core, 'warning') - -beforeEach(() => { - vi.clearAllMocks() - - process.env.INPUT_ENVIRONMENT_TARGETS = 'production,development,staging' - process.env.INPUT_GLOBAL_LOCK_FLAG = '--global' - process.env.INPUT_LOCK_INFO_ALIAS = '.wcid' -}) - -const environment = 'production' -const body = '.deploy' -const trigger = '.deploy' -const noop_trigger = '.noop' -const stable_branch = 'main' -const environmentUrls = - 'production|https://example.com,development|https://dev.example.com,staging|http://staging.example.com' - -test('checks the comment body and does not find an explicit environment target', async () => { - expect( - await environmentTargets( - environment, - body, - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - noop: false, - stable_branch_used: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for branch deployment' - ) -}) - -test('checks the comment body and finds an explicit environment target for development', async () => { - expect( - await environmentTargets( - environment, - '.deploy development', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: false, - stable_branch_used: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for branch deploy: development' - ) -}) - -test('checks the comment body and finds an explicit environment target for development with params', async () => { - expect( - await environmentTargets( - environment, - '.deploy development | something1 something2 something3', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: false, - stable_branch_used: false, - params: 'something1 something2 something3', - parsed_params: {_: ['something1', 'something2', 'something3']}, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for branch deploy: development' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}something1 something2 something3` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - 'something1 something2 something3' - ) -}) - -test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with params', async () => { - expect( - await environmentTargets( - environment, - '.deploy 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: false, - stable_branch_used: false, - params: 'something1 something2 something3', - parsed_params: {_: ['something1', 'something2', 'something3']}, - sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for branch deploy: development' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}something1 something2 something3` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - 'something1 something2 something3' - ) -}) - -test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with params on a noop command', async () => { - expect( - await environmentTargets( - environment, - '.noop 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: true, - stable_branch_used: false, - params: 'something1 something2 something3', - parsed_params: {_: ['something1', 'something2', 'something3']}, - sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for noop trigger: development' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}something1 something2 something3` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - 'something1 something2 something3' - ) -}) - -test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with parsed params style params on a noop command', async () => { - expect( - await environmentTargets( - environment, - '.noop 82c238c277ca3df56fe9418a5913d9188eafe3bc development | --cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: true, - stable_branch_used: false, - params: - '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue', - parsed_params: { - _: [], - cpu: 2, - memory: '4G', - env: 'development', - port: 8080, - name: 'my-app', - q: 'my-queue' - }, - sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for noop trigger: development' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' - ) -}) - -test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with params on a noop command and the sha is a sha256 hash (64 characters)', async () => { - expect( - await environmentTargets( - environment, - '.noop f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b development | something1 something2 something3', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: true, - stable_branch_used: false, - params: 'something1 something2 something3', - parsed_params: {_: ['something1', 'something2', 'something3']}, - sha: 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b' - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for noop trigger: development' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}something1 something2 something3` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - 'something1 something2 something3' - ) -}) - -test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) on a noop command with trailing whitespace', async () => { - expect( - await environmentTargets( - environment, - '.noop 82c238c277ca3df56fe9418a5913d9188eafe3bc ', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - noop: true, - stable_branch_used: false, - params: null, - parsed_params: null, - sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' - } - }) - - expect(debugMock).toHaveBeenCalledWith('no parameters detected in command') - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for noop trigger' - ) -}) - -test('checks the comment body and finds an explicit environment target for development to stable_branch with params and a custom separator', async () => { - expect( - await environmentTargets( - environment, - '.deploy main development + something1 | something2 something3', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - null, // environmentUrls - '+' // custom separator - ) - ).toStrictEqual({ - environment: 'development', - environmentUrl: null, - environmentObj: { - target: 'development', - noop: false, - stable_branch_used: true, - params: 'something1 | something2 something3', - parsed_params: {_: ['something1', '|', 'something2', 'something3']}, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for stable branch deploy: development' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}something1 | something2 something3` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - 'something1 | something2 something3' - ) -}) - -test('checks the comment body and finds an explicit environment target for staging on a noop deploy', async () => { - expect( - await environmentTargets( - environment, - '.noop staging', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'staging', - environmentUrl: null, - environmentObj: { - target: 'staging', - noop: true, - stable_branch_used: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for noop trigger: staging' - ) -}) - -test('checks the comment body and finds an explicit environment target for staging on a noop deploy with the stable branch', async () => { - expect( - await environmentTargets( - environment, - '.noop main staging', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'staging', - environmentUrl: null, - environmentObj: { - target: 'staging', - noop: true, - stable_branch_used: true, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for stable branch noop trigger: staging' - ) -}) - -test('checks the comment body and finds an explicit environment target for staging on a noop deploy with environment_urls set', async () => { - expect( - await environmentTargets( - environment, - '.noop staging', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - environmentUrls - ) - ).toStrictEqual({ - environment: 'staging', - environmentUrl: 'http://staging.example.com', - environmentObj: { - target: 'staging', - noop: true, - stable_branch_used: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”— environment url detected: ${COLORS.highlight}http://staging.example.com` - ) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for noop trigger: staging' - ) - expect(saveStateMock).toHaveBeenCalledWith( - 'environment_url', - 'http://staging.example.com' - ) - expect(saveStateMock).toHaveBeenCalledWith('params', '') - expect(saveStateMock).toHaveBeenCalledWith('parsed_params', '') - expect(setOutputMock).toHaveBeenCalledWith( - 'environment_url', - 'http://staging.example.com' - ) -}) - -test('checks the comment body and finds an explicit environment target for staging on a noop deploy with environment_urls set and using the stable branch with "to" - and params!', async () => { - expect( - await environmentTargets( - environment, - '.noop main to staging | something1 something2 something3', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - environmentUrls - ) - ).toStrictEqual({ - environment: 'staging', - environmentUrl: 'http://staging.example.com', - environmentObj: { - target: 'staging', - noop: true, - stable_branch_used: true, - params: 'something1 something2 something3', - parsed_params: {_: ['something1', 'something2', 'something3']}, - sha: null - } - }) - - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”— environment url detected: ${COLORS.highlight}http://staging.example.com` - ) - expect(debugMock).toHaveBeenCalledWith( - `found environment target for stable branch noop trigger (with 'to'): staging` - ) - expect(saveStateMock).toHaveBeenCalledWith( - 'environment_url', - 'http://staging.example.com' - ) - expect(saveStateMock).toHaveBeenCalledWith( - 'params', - 'something1 something2 something3' - ) - expect(saveStateMock).toHaveBeenCalledWith('parsed_params', { - _: ['something1', 'something2', 'something3'] - }) - - expect(setOutputMock).toHaveBeenCalledWith( - 'environment_url', - 'http://staging.example.com' - ) -}) - -test('checks the comment body and uses the default production environment target with environment_urls set', async () => { - expect( - await environmentTargets( - environment, - '.deploy', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - environmentUrls - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: 'https://example.com', - environmentObj: { - target: 'production', - noop: false, - stable_branch_used: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”— environment url detected: ${COLORS.highlight}https://example.com` - ) - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for branch deployment' - ) - expect(saveStateMock).toHaveBeenCalledWith( - 'environment_url', - 'https://example.com' - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'environment_url', - 'https://example.com' - ) -}) - -test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but no valid url', async () => { - expect( - await environmentTargets( - environment, - '.deploy production', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - 'evil-production|example.com,development|dev.example.com,staging|' - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - noop: false, - params: null, - parsed_params: null, - stable_branch_used: false, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for branch deploy: production' - ) - expect(warningMock).toHaveBeenCalledWith( - "no valid environment URL found for environment: production - setting environment URL to 'null' - please check your 'environment_urls' input" - ) - expect(saveStateMock).toHaveBeenCalledWith('environment_url', 'null') - expect(setOutputMock).toHaveBeenCalledWith('environment_url', 'null') -}) - -test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but a url with a non-http(s) schema is provided', async () => { - expect( - await environmentTargets( - environment, - '.deploy production', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - 'production|example.com,development|dev.example.com,staging|' - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: false, - noop: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for branch deploy: production' - ) - expect(warningMock).toHaveBeenCalledWith( - 'environment url does not match http(s) schema: example.com' - ) - expect(warningMock).toHaveBeenCalledWith( - "no valid environment URL found for environment: production - setting environment URL to 'null' - please check your 'environment_urls' input" - ) - expect(saveStateMock).toHaveBeenCalledWith('environment_url', 'null') - expect(setOutputMock).toHaveBeenCalledWith('environment_url', 'null') -}) - -test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but the environment url for the given environment is disabled', async () => { - expect( - await environmentTargets( - environment, - '.deploy production', - trigger, - noop_trigger, - stable_branch, - null, - null, - null, - false, // lockChecks disabled - 'production|disabled,development|dev.example.com,staging|' - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: false, - noop: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for branch deploy: production' - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ’ก environment url for ${COLORS.highlight}production${COLORS.reset} is explicitly disabled` - ) - expect(saveStateMock).toHaveBeenCalledWith('environment_url', 'null') - expect(setOutputMock).toHaveBeenCalledWith('environment_url', 'null') -}) - -test('checks the comment body and finds an explicit environment target for staging on a noop deploy with "to"', async () => { - expect( - await environmentTargets( - environment, - '.noop to staging', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'staging', - environmentUrl: null, - environmentObj: { - target: 'staging', - stable_branch_used: false, - noop: true, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - "found environment target for noop trigger (with 'to'): staging" - ) -}) - -test('checks the comment body and finds a noop deploy to the stable branch and default environment', async () => { - expect( - await environmentTargets( - environment, - '.noop main', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: true, - noop: true, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for stable branch noop trigger' - ) -}) - -test('checks the comment body and finds a noop deploy to the stable branch and default environment with params', async () => { - expect( - await environmentTargets( - environment, - '.noop main | foo=bar', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: true, - noop: true, - params: 'foo=bar', - parsed_params: {_: ['foo=bar']}, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for stable branch noop trigger' - ) -}) - -test('checks the comment body and finds an explicit environment target for production on a branch deploy with "to"', async () => { - expect( - await environmentTargets( - environment, - '.deploy to production', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: false, - noop: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - "found environment target for branch deploy (with 'to'): production" - ) -}) - -test('checks the comment body on a noop deploy and does not find an explicit environment target', async () => { - expect( - await environmentTargets( - environment, - '.noop', // comment body - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: false, - noop: true, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for noop trigger' - ) -}) - -test('checks the comment body on a deployment and does not find any matching environment target (fails)', async () => { - const mockContext = { - repo: {owner: 'test', repo: 'test'}, - issue: {number: 1}, - payload: {comment: {id: 1}} - } - const mockOctokit = { - rest: { - issues: {createComment: vi.fn()}, - reactions: { - createForIssueComment: vi.fn(), - deleteForIssueComment: vi.fn() - } - } - } - - expect( - await environmentTargets( - environment, - '.deploy to chaos', - trigger, - noop_trigger, - stable_branch, - mockContext, - mockOctokit, - 123 - ) - ).toStrictEqual({ - environment: false, - environmentUrl: null, - environmentObj: { - noop: null, - params: null, - parsed_params: null, - stable_branch_used: null, - target: false, - sha: null - } - }) - - const msg = dedent(` - No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action. - - > The following environment targets are available: \`production,development,staging\` - `) - - expect(warningMock).toHaveBeenCalledWith(msg) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('checks the comment body on a stable branch deployment and finds a matching environment (with to)', async () => { - expect( - await environmentTargets( - environment, - '.deploy main to production', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: true, - noop: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - "found environment target for stable branch deploy (with 'to'): production" - ) -}) - -test('checks the comment body on a stable branch deployment and finds a matching environment (without to)', async () => { - expect( - await environmentTargets( - environment, - '.deploy main production', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: true, - noop: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for stable branch deploy: production' - ) -}) - -test('checks the comment body on a stable branch deployment and uses the default environment', async () => { - expect( - await environmentTargets( - environment, - '.deploy main', - trigger, - noop_trigger, - stable_branch - ) - ).toStrictEqual({ - environment: 'production', - environmentUrl: null, - environmentObj: { - target: 'production', - stable_branch_used: true, - noop: false, - params: null, - parsed_params: null, - sha: null - } - }) - - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for stable branch deployment' - ) -}) - -test('checks the comment body on a stable branch deployment and does not find a matching environment', async () => { - const mockContext = { - repo: {owner: 'test', repo: 'test'}, - issue: {number: 1}, - payload: {comment: {id: 1}} - } - const mockOctokit = { - rest: { - issues: {createComment: vi.fn()}, - reactions: { - createForIssueComment: vi.fn(), - deleteForIssueComment: vi.fn() - } - } - } - - expect( - await environmentTargets( - environment, - '.deploy main chaos', - trigger, - noop_trigger, - stable_branch, - mockContext, - mockOctokit, - 123 - ) - ).toStrictEqual({ - environment: false, - environmentUrl: null, - environmentObj: { - noop: null, - params: null, - parsed_params: null, - stable_branch_used: null, - target: false, - sha: null - } - }) - - const msg = dedent(` - No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action. - - > The following environment targets are available: \`production,development,staging\` - `) - - expect(warningMock).toHaveBeenCalledWith(msg) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('checks the comment body on a lock request and uses the default environment', async () => { - expect( - await environmentTargets( - environment, - '.lock', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for lock request' - ) -}) - -test('checks the comment body on a lock request with a reason and uses the default environment', async () => { - expect( - await environmentTargets( - environment, - '.lock --reason making a small change to our api because reasons', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for lock request' - ) -}) - -test('checks the comment body on a lock request with a reason and uses the explict environment with a bunch of horrible formatting', async () => { - expect( - await environmentTargets( - environment, - '.lock production --reason small change to mappings for risk rating - - 92*91-2408| ', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for lock request: production' - ) -}) - -test('checks the comment body on an unlock request and uses the default environment', async () => { - expect( - await environmentTargets( - environment, - '.unlock', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for unlock request' - ) -}) - -test('checks the comment body on an unlock request and uses the default environment (and uses --reason) even though it does not need to', async () => { - expect( - await environmentTargets( - environment, - '.unlock --reason oh wait this command does not need a reason.. oops', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for unlock request' - ) -}) - -test('checks the comment body on an unlock request and uses the development environment (and uses --reason) even though it does not need to', async () => { - expect( - await environmentTargets( - environment, - '.unlock development --reason oh wait this command does not need a reason.. oops', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'development', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for unlock request: development' - ) -}) - -test('checks the comment body on a lock info alias request and uses the default environment', async () => { - expect( - await environmentTargets( - environment, - '.wcid', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'using default environment for lock info request' - ) -}) - -test('checks the comment body on a lock request and uses the production environment', async () => { - expect( - await environmentTargets( - environment, - '.lock production', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'production', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for lock request: production' - ) -}) - -test('checks the comment body on an unlock request and uses the development environment', async () => { - expect( - await environmentTargets( - environment, - '.unlock development', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'development', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for unlock request: development' - ) -}) - -test('checks the comment body on a lock info alias request and uses the development environment', async () => { - expect( - await environmentTargets( - environment, - '.wcid development', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'development', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for lock info request: development' - ) -}) - -test('checks the comment body on a lock info request and uses the development environment', async () => { - expect( - await environmentTargets( - environment, - '.lock --info development', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'development', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for lock request: development' - ) -}) - -test('checks the comment body on a lock info request and uses the development environment (using -d)', async () => { - expect( - await environmentTargets( - environment, - '.lock -d development', // comment body - '.lock', // lock trigger - '.unlock', // unlock trigger - null, // stable_branch not used for lock/unlock requests - null, // context - null, // octokit - null, // reaction_id - true // enable lockChecks - ) - ).toStrictEqual({environment: 'development', environmentUrl: null}) - expect(debugMock).toHaveBeenCalledWith( - 'found environment target for lock request: development' - ) -}) diff --git a/__tests__/functions/environment-targets.test.ts b/__tests__/functions/environment-targets.test.ts new file mode 100644 index 00000000..99610add --- /dev/null +++ b/__tests__/functions/environment-targets.test.ts @@ -0,0 +1,1052 @@ +import type { + DeploymentEnvironmentRequest, + LockEnvironmentRequest +} from '../../src/functions/environment-targets.ts' +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {dedent} from '../../src/functions/dedent.ts' +import {COLORS} from '../../src/functions/colors.ts' +import {createIssueCommentContext, createOctokit} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + stubEnv, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionStatus = typeof import('../../src/functions/action-status.ts') + +function readInput(name: string, trimWhitespace = true): string { + const value = + process.env[`INPUT_${name.replace(/ /gu, '_').toUpperCase()}`] ?? '' + return trimWhitespace ? value.trim() : value +} + +const debugMock = createMock() +const infoMock = createMock() +const setOutputMock = createMock() +const saveStateMock = createMock() +const warningMock = createMock() +const getInputMock = createMock((name, options) => + readInput(name, options?.trimWhitespace !== false) +) +const actionStatusMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + getInput: getInputMock, + info: infoMock, + saveState: saveStateMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../../src/functions/action-status.ts', import.meta.url), + {actionStatus: actionStatusMock} +) + +const {environmentTargets} = + await import('../../src/functions/environment-targets.ts') + +beforeEach(testContext => { + if (!('after' in testContext)) { + throw new Error('expected a test context') + } + + debugMock.mock.resetCalls() + infoMock.mock.resetCalls() + setOutputMock.mock.resetCalls() + saveStateMock.mock.resetCalls() + warningMock.mock.resetCalls() + getInputMock.mock.resetCalls() + actionStatusMock.mock.resetCalls() + actionStatusMock.mock.mockImplementation(() => Promise.resolve(undefined)) + + stubEnv( + testContext, + 'INPUT_ENVIRONMENT_TARGETS', + 'production,development,staging' + ) + stubEnv(testContext, 'INPUT_GLOBAL_LOCK_FLAG', '--global') + stubEnv(testContext, 'INPUT_LOCK_INFO_ALIAS', '.wcid') +}) + +const environment = 'production' +const body = '.deploy' +const trigger = '.deploy' +const noop_trigger = '.noop' +const stable_branch = 'main' +const environmentUrls = + 'production|https://example.com,development|https://dev.example.com,staging|http://staging.example.com' + +const context = createIssueCommentContext({ + actor: 'monalisa', + issue: {number: 1}, + payload: {comment: {body, id: 1}}, + repo: {owner: 'test', repo: 'test'} +}) +const octokit = createOctokit() + +const deploymentRequestDefaults = { + alternateTrigger: noop_trigger, + context, + environment, + environmentUrls: null, + mode: 'deployment', + octokit, + paramSeparator: '|', + reactionId: 123, + stableBranch: stable_branch, + trigger +} satisfies Omit + +const lockRequestDefaults = { + alternateTrigger: '.unlock', + context, + environment, + mode: 'lock', + octokit, + reactionId: 123, + trigger: '.lock' +} satisfies Omit + +function deploymentRequest( + requestBody: string, + overrides: Partial> = {} +): DeploymentEnvironmentRequest { + return {...deploymentRequestDefaults, body: requestBody, ...overrides} +} + +function lockRequest(requestBody: string): LockEnvironmentRequest { + return {...lockRequestDefaults, body: requestBody} +} + +test('checks the comment body and does not find an explicit environment target', async () => { + assert.deepStrictEqual(await environmentTargets(deploymentRequest(body)), { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + noop: false, + stable_branch_used: false, + params: null, + parsed_params: null, + sha: null + } + }) + + assertCalledWith(debugMock, 'using default environment for branch deployment') +}) + +test('checks the comment body and finds an explicit environment target for development', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy development')), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: false, + stable_branch_used: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for branch deploy: development' + ) +}) + +test('checks the comment body and finds an explicit environment target for development with params', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.deploy development | something1 something2 something3' + ) + ), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: false, + stable_branch_used: false, + params: 'something1 something2 something3', + parsed_params: {_: ['something1', 'something2', 'something3']}, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for branch deploy: development' + ) + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}"something1 something2 something3"` + ) + assertCalledWith(setOutputMock, 'params', 'something1 something2 something3') +}) + +test('escapes multiline parameters before informational logging', async () => { + const params = 'first\n::error::injected' + await environmentTargets(deploymentRequest(`.deploy | ${params}`)) + + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}${JSON.stringify(params)}` + ) + assert.ok( + !infoMock.mock.calls.some(call => String(call.arguments[0]).includes('\n')) + ) +}) + +test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with params', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.deploy 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3' + ) + ), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: false, + stable_branch_used: false, + params: 'something1 something2 something3', + parsed_params: {_: ['something1', 'something2', 'something3']}, + sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for branch deploy: development' + ) + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}"something1 something2 something3"` + ) + assertCalledWith(setOutputMock, 'params', 'something1 something2 something3') +}) + +test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with params on a noop command', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.noop 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3' + ) + ), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: true, + stable_branch_used: false, + params: 'something1 something2 something3', + parsed_params: {_: ['something1', 'something2', 'something3']}, + sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for noop trigger: development' + ) + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}"something1 something2 something3"` + ) + assertCalledWith(setOutputMock, 'params', 'something1 something2 something3') +}) + +test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with parsed params style params on a noop command', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.noop 82c238c277ca3df56fe9418a5913d9188eafe3bc development | --cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' + ) + ), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: true, + stable_branch_used: false, + params: + '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue', + parsed_params: { + _: [], + cpu: 2, + memory: '4G', + env: 'development', + port: 8080, + name: 'my-app', + q: 'my-queue' + }, + sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for noop trigger: development' + ) + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}"--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue"` + ) + assertCalledWith( + setOutputMock, + 'params', + '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' + ) +}) + +test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) for development with params on a noop command and the sha is a sha256 hash (64 characters)', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.noop f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b development | something1 something2 something3' + ) + ), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: true, + stable_branch_used: false, + params: 'something1 something2 something3', + parsed_params: {_: ['something1', 'something2', 'something3']}, + sha: 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b' + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for noop trigger: development' + ) + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}"something1 something2 something3"` + ) + assertCalledWith(setOutputMock, 'params', 'something1 something2 something3') +}) + +test('checks the comment body and finds an explicit environment target and an explicit sha (sha1) on a noop command with trailing whitespace', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest('.noop 82c238c277ca3df56fe9418a5913d9188eafe3bc ') + ), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + noop: true, + stable_branch_used: false, + params: null, + parsed_params: null, + sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc' + } + } + ) + + assertCalledWith(debugMock, 'no parameters detected in command') + assertCalledWith(debugMock, 'using default environment for noop trigger') +}) + +test('checks the comment body and finds an explicit environment target for development to stable_branch with params and a custom separator', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.deploy main development + something1 | something2 something3', + {paramSeparator: '+'} + ) + ), + { + environment: 'development', + environmentUrl: null, + environmentObj: { + target: 'development', + noop: false, + stable_branch_used: true, + params: 'something1 | something2 something3', + parsed_params: {_: ['something1', '|', 'something2', 'something3']}, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for stable branch deploy: development' + ) + assertCalledWith( + infoMock, + `๐Ÿงฎ detected parameters in command: ${COLORS.highlight}"something1 | something2 something3"` + ) + assertCalledWith( + setOutputMock, + 'params', + 'something1 | something2 something3' + ) +}) + +test('checks the comment body and finds an explicit environment target for staging on a noop deploy', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.noop staging')), + { + environment: 'staging', + environmentUrl: null, + environmentObj: { + target: 'staging', + noop: true, + stable_branch_used: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for noop trigger: staging' + ) +}) + +test('checks the comment body and finds an explicit environment target for staging on a noop deploy with the stable branch', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.noop main staging')), + { + environment: 'staging', + environmentUrl: null, + environmentObj: { + target: 'staging', + noop: true, + stable_branch_used: true, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for stable branch noop trigger: staging' + ) +}) + +test('checks the comment body and finds an explicit environment target for staging on a noop deploy with environment_urls set', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest('.noop staging', {environmentUrls}) + ), + { + environment: 'staging', + environmentUrl: 'http://staging.example.com', + environmentObj: { + target: 'staging', + noop: true, + stable_branch_used: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + infoMock, + `๐Ÿ”— environment url detected: ${COLORS.highlight}http://staging.example.com` + ) + assertCalledWith( + debugMock, + 'found environment target for noop trigger: staging' + ) + assertCalledWith( + saveStateMock, + 'environment_url', + 'http://staging.example.com' + ) + assertCalledWith(saveStateMock, 'params', '') + assertCalledWith(saveStateMock, 'parsed_params', '') + assertCalledWith( + setOutputMock, + 'environment_url', + 'http://staging.example.com' + ) +}) + +test('checks the comment body and finds an explicit environment target for staging on a noop deploy with environment_urls set and using the stable branch with "to" - and params!', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest( + '.noop main to staging | something1 something2 something3', + {environmentUrls} + ) + ), + { + environment: 'staging', + environmentUrl: 'http://staging.example.com', + environmentObj: { + target: 'staging', + noop: true, + stable_branch_used: true, + params: 'something1 something2 something3', + parsed_params: {_: ['something1', 'something2', 'something3']}, + sha: null + } + } + ) + + assertCalledWith( + infoMock, + `๐Ÿ”— environment url detected: ${COLORS.highlight}http://staging.example.com` + ) + assertCalledWith( + debugMock, + `found environment target for stable branch noop trigger (with 'to'): staging` + ) + assertCalledWith( + saveStateMock, + 'environment_url', + 'http://staging.example.com' + ) + assertCalledWith(saveStateMock, 'params', 'something1 something2 something3') + assertCalledWith(saveStateMock, 'parsed_params', { + _: ['something1', 'something2', 'something3'] + }) + + assertCalledWith( + setOutputMock, + 'environment_url', + 'http://staging.example.com' + ) +}) + +test('checks the comment body and uses the default production environment target with environment_urls set', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy', {environmentUrls})), + { + environment: 'production', + environmentUrl: 'https://example.com', + environmentObj: { + target: 'production', + noop: false, + stable_branch_used: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + infoMock, + `๐Ÿ”— environment url detected: ${COLORS.highlight}https://example.com` + ) + assertCalledWith(debugMock, 'using default environment for branch deployment') + assertCalledWith(saveStateMock, 'environment_url', 'https://example.com') + assertCalledWith(setOutputMock, 'environment_url', 'https://example.com') +}) + +test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but no valid url', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest('.deploy production', { + environmentUrls: + 'evil-production|example.com,development|dev.example.com,staging|' + }) + ), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + noop: false, + params: null, + parsed_params: null, + stable_branch_used: false, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for branch deploy: production' + ) + assertCalledWith( + warningMock, + "no valid environment URL found for environment: production - setting environment URL to 'null' - please check your 'environment_urls' input" + ) + assertCalledWith(saveStateMock, 'environment_url', 'null') + assertCalledWith(setOutputMock, 'environment_url', 'null') +}) + +test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but a url with a non-http(s) schema is provided', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest('.deploy production', { + environmentUrls: + 'production|example.com,development|dev.example.com,staging|' + }) + ), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: false, + noop: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for branch deploy: production' + ) + assertCalledWith( + warningMock, + 'environment url does not match http(s) schema: example.com' + ) + assertCalledWith( + warningMock, + "no valid environment URL found for environment: production - setting environment URL to 'null' - please check your 'environment_urls' input" + ) + assertCalledWith(saveStateMock, 'environment_url', 'null') + assertCalledWith(setOutputMock, 'environment_url', 'null') +}) + +test('preserves the legacy error for an environment target without a URL separator', async () => { + await assert.rejects( + environmentTargets( + deploymentRequest('.deploy production', { + environmentUrls: 'production' + }) + ), + TypeError + ) +}) + +test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but the environment url for the given environment is disabled', async () => { + assert.deepStrictEqual( + await environmentTargets( + deploymentRequest('.deploy production', { + environmentUrls: + 'production|disabled,development|dev.example.com,staging|' + }) + ), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: false, + noop: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for branch deploy: production' + ) + assertCalledWith( + infoMock, + `๐Ÿ’ก environment url for ${COLORS.highlight}production${COLORS.reset} is explicitly disabled` + ) + assertCalledWith(saveStateMock, 'environment_url', 'null') + assertCalledWith(setOutputMock, 'environment_url', 'null') +}) + +test('checks the comment body and finds an explicit environment target for staging on a noop deploy with "to"', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.noop to staging')), + { + environment: 'staging', + environmentUrl: null, + environmentObj: { + target: 'staging', + stable_branch_used: false, + noop: true, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + "found environment target for noop trigger (with 'to'): staging" + ) +}) + +test('checks the comment body and finds a noop deploy to the stable branch and default environment', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.noop main')), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: true, + noop: true, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'using default environment for stable branch noop trigger' + ) +}) + +test('checks the comment body and finds a noop deploy to the stable branch and default environment with params', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.noop main | foo=bar')), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: true, + noop: true, + params: 'foo=bar', + parsed_params: {_: ['foo=bar']}, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'using default environment for stable branch noop trigger' + ) +}) + +test('checks the comment body and finds an explicit environment target for production on a branch deploy with "to"', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy to production')), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: false, + noop: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + "found environment target for branch deploy (with 'to'): production" + ) +}) + +test('checks the comment body on a noop deploy and does not find an explicit environment target', async () => { + assert.deepStrictEqual(await environmentTargets(deploymentRequest('.noop')), { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: false, + noop: true, + params: null, + parsed_params: null, + sha: null + } + }) + + assertCalledWith(debugMock, 'using default environment for noop trigger') +}) + +test('checks the comment body on a deployment and does not find any matching environment target (fails)', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy to chaos')), + { + environment: false, + environmentUrl: null, + environmentObj: { + noop: null, + params: null, + parsed_params: null, + stable_branch_used: null, + target: false, + sha: null + } + } + ) + + const msg = dedent(` + No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action. + + > The following environment targets are available: \`production,development,staging\` + `) + + assertCalledWith(warningMock, msg) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('checks the comment body on a stable branch deployment and finds a matching environment (with to)', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy main to production')), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: true, + noop: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + "found environment target for stable branch deploy (with 'to'): production" + ) +}) + +test('checks the comment body on a stable branch deployment and finds a matching environment (without to)', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy main production')), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: true, + noop: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'found environment target for stable branch deploy: production' + ) +}) + +test('checks the comment body on a stable branch deployment and uses the default environment', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy main')), + { + environment: 'production', + environmentUrl: null, + environmentObj: { + target: 'production', + stable_branch_used: true, + noop: false, + params: null, + parsed_params: null, + sha: null + } + } + ) + + assertCalledWith( + debugMock, + 'using default environment for stable branch deployment' + ) +}) + +test('checks the comment body on a stable branch deployment and does not find a matching environment', async () => { + assert.deepStrictEqual( + await environmentTargets(deploymentRequest('.deploy main chaos')), + { + environment: false, + environmentUrl: null, + environmentObj: { + noop: null, + params: null, + parsed_params: null, + stable_branch_used: null, + target: false, + sha: null + } + } + ) + + const msg = dedent(` + No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action. + + > The following environment targets are available: \`production,development,staging\` + `) + + assertCalledWith(warningMock, msg) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('checks the comment body on a lock request and uses the default environment', async () => { + assert.deepStrictEqual(await environmentTargets(lockRequest('.lock')), { + environment: 'production', + environmentUrl: null + }) + assertCalledWith(debugMock, 'using default environment for lock request') +}) + +test('checks the comment body on a lock request with a reason and uses the default environment', async () => { + assert.deepStrictEqual( + await environmentTargets( + lockRequest( + '.lock --reason making a small change to our api because reasons' + ) + ), + {environment: 'production', environmentUrl: null} + ) + assertCalledWith(debugMock, 'using default environment for lock request') +}) + +test('checks the comment body on a lock request with a reason and uses the explict environment with a bunch of horrible formatting', async () => { + assert.deepStrictEqual( + await environmentTargets( + lockRequest( + '.lock production --reason small change to mappings for risk rating - - 92*91-2408| ' + ) + ), + {environment: 'production', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for lock request: production' + ) +}) + +test('checks the comment body on an unlock request and uses the default environment', async () => { + assert.deepStrictEqual(await environmentTargets(lockRequest('.unlock')), { + environment: 'production', + environmentUrl: null + }) + assertCalledWith(debugMock, 'using default environment for unlock request') +}) + +test('checks the comment body on an unlock request and uses the default environment (and uses --reason) even though it does not need to', async () => { + assert.deepStrictEqual( + await environmentTargets( + lockRequest( + '.unlock --reason oh wait this command does not need a reason.. oops' + ) + ), + {environment: 'production', environmentUrl: null} + ) + assertCalledWith(debugMock, 'using default environment for unlock request') +}) + +test('checks the comment body on an unlock request and uses the development environment (and uses --reason) even though it does not need to', async () => { + assert.deepStrictEqual( + await environmentTargets( + lockRequest( + '.unlock development --reason oh wait this command does not need a reason.. oops' + ) + ), + {environment: 'development', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for unlock request: development' + ) +}) + +test('checks the comment body on a lock info alias request and uses the default environment', async () => { + assert.deepStrictEqual(await environmentTargets(lockRequest('.wcid')), { + environment: 'production', + environmentUrl: null + }) + assertCalledWith(debugMock, 'using default environment for lock info request') +}) + +test('checks the comment body on a lock request and uses the production environment', async () => { + assert.deepStrictEqual( + await environmentTargets(lockRequest('.lock production')), + {environment: 'production', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for lock request: production' + ) +}) + +test('checks the comment body on an unlock request and uses the development environment', async () => { + assert.deepStrictEqual( + await environmentTargets(lockRequest('.unlock development')), + {environment: 'development', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for unlock request: development' + ) +}) + +test('checks the comment body on a lock info alias request and uses the development environment', async () => { + assert.deepStrictEqual( + await environmentTargets(lockRequest('.wcid development')), + {environment: 'development', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for lock info request: development' + ) +}) + +test('checks the comment body on a lock info request and uses the development environment', async () => { + assert.deepStrictEqual( + await environmentTargets(lockRequest('.lock --info development')), + {environment: 'development', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for lock request: development' + ) +}) + +test('checks the comment body on a lock info request and uses the development environment (using -d)', async () => { + assert.deepStrictEqual( + await environmentTargets(lockRequest('.lock -d development')), + {environment: 'development', environmentUrl: null} + ) + assertCalledWith( + debugMock, + 'found environment target for lock request: development' + ) +}) diff --git a/__tests__/functions/format-lock-reason.test.ts b/__tests__/functions/format-lock-reason.test.ts new file mode 100644 index 00000000..bf218b53 --- /dev/null +++ b/__tests__/functions/format-lock-reason.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {formatLockReason} from '../../src/functions/format-lock-reason.ts' +import {truncateCommentBody} from '../../src/functions/truncate-comment-body.ts' + +test('formats an ordinary reason as nested Markdown code', () => { + assert.strictEqual( + formatLockReason('routine maintenance'), + '- __Reason__:\n\n routine maintenance' + ) +}) + +test('keeps every attacker-controlled line inside the nested code block', () => { + const formatted = formatLockReason( + 'routine ` and `` and ```\r\n## Deployment approved\r[continue](https://example.com)\n
' + ) + + assert.strictEqual( + formatted, + '- __Reason__:\n\n routine ` and `` and ```\n ## Deployment approved\n [continue](https://example.com)\n
' + ) + assert.strictEqual(formatted.includes('\n## Deployment approved'), false) + assert.strictEqual( + formatted.includes('\n[continue](https://example.com)'), + false + ) + assert.strictEqual(formatted.includes('\n
'), false) +}) + +test('keeps empty and whitespace-only reason lines indented', () => { + assert.strictEqual( + formatLockReason('\n \t\nlast line\n'), + '- __Reason__:\n\n \n \t\n last line\n ' + ) +}) + +for (const [description, value, expected] of [ + ['null', null, 'null'], + ['number', 42, '42'], + ['boolean', false, 'false'] +] as const) { + test(`formats a ${description} boundary value as text`, () => { + assert.strictEqual( + formatLockReason(value), + `- __Reason__:\n\n ${expected}` + ) + }) +} + +test('keeps attacker-controlled lines indented when the comment is truncated', () => { + const link = '[continue](https://example.com)' + const formatted = formatLockReason(`${link}\n${'a'.repeat(70000)}`) + const truncated = truncateCommentBody(`Lock details\n\n${formatted}`) + + assert.ok(truncated.includes(` ${link}`)) + assert.strictEqual(truncated.includes(`\n${link}`), false) + assert.ok(truncated.length <= 65536) +}) diff --git a/__tests__/functions/help.test.js b/__tests__/functions/help.test.js deleted file mode 100644 index 7bf36335..00000000 --- a/__tests__/functions/help.test.js +++ /dev/null @@ -1,229 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {help} from '../../src/functions/help.js' - -const debugMock = vi.spyOn(core, 'debug') - -beforeEach(() => { - vi.clearAllMocks() -}) - -const context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - id: 123 - } - } -} -const octokit = { - rest: { - issues: { - createComment: vi.fn().mockReturnValue({ - data: {} - }) - }, - reactions: { - deleteForIssueComment: vi.fn().mockReturnValue({ - data: {} - }), - createForIssueComment: vi.fn().mockReturnValue({ - data: {} - }) - } - } -} - -const defaultInputs = { - trigger: '.deploy', - reaction: 'eyes', - environment: 'production', - stable_branch: 'main', - noop_trigger: '.noop', - lock_trigger: '.lock', - production_environments: 'production', - environment_targets: 'production,staging,development', - unlock_trigger: '.unlock', - help_trigger: '.help', - lock_info_alias: '.wcid', - global_lock_flag: '--global', - update_branch: 'warn', - outdated_mode: 'strict', - required_contexts: 'false', - allowForks: 'true', - skipCi: '', - skipReviews: '', - draft_permitted_targets: '', - admins: 'false', - permissions: ['write', 'admin'], - allow_sha_deployments: false, - checks: 'all', - commit_verification: true, - ignored_checks: [], - enforced_deployment_order: [], - use_security_warnings: true, - allow_non_default_target_branch_deployments: false, - deployment_confirmation: false, - deployment_confirmation_timeout: 60 -} - -test('successfully calls help with defaults', async () => { - expect(await help(octokit, context, 123, defaultInputs)) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/## ๐Ÿ“š Branch Deployment Help/) - ) -}) - -test('successfully calls help with non-defaults', async () => { - const inputs = { - trigger: '.deploy', - reaction: 'eyes', - environment: 'production', - stable_branch: 'main', - noop_trigger: '.noop', - lock_trigger: '.lock', - production_environments: 'production', - environment_targets: 'production,staging,development', - unlock_trigger: '.unlock', - help_trigger: '.help', - lock_info_alias: '.wcid', - global_lock_flag: '--global', - update_branch: 'force', - outdated_mode: 'pr_base', - required_contexts: 'cat', - allowForks: 'false', - skipCi: 'development', - skipReviews: 'development', - draft_permitted_targets: 'development', - admins: 'monalisa', - permissions: ['write', 'admin'], - allow_sha_deployments: true, - checks: ['test,build,security'], - ignored_checks: ['lint', 'format'], - commit_verification: false, - enforced_deployment_order: [], - use_security_warnings: false, - allow_non_default_target_branch_deployments: false, - deployment_confirmation: true - } - - expect(await help(octokit, context, 123, inputs)) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/## ๐Ÿ“š Branch Deployment Help/) - ) -}) - -test('successfully calls help with non-defaults again', async () => { - const inputs = { - trigger: '.deploy', - reaction: 'eyes', - environment: 'production', - stable_branch: 'main', - noop_trigger: '.noop', - lock_trigger: '.lock', - production_environments: 'production,production-eu,production-ap', - environment_targets: 'production,staging,development', - unlock_trigger: '.unlock', - help_trigger: '.help', - lock_info_alias: '.wcid', - global_lock_flag: '--global', - update_branch: 'force', - outdated_mode: 'default_branch', - required_contexts: 'cat', - allowForks: 'false', - skipCi: 'development', - skipReviews: 'development', - draft_permitted_targets: 'development', - admins: 'monalisa', - permissions: ['write', 'admin'], - allow_sha_deployments: false, - checks: 'required', - ignored_checks: ['lint'], - commit_verification: false, - enforced_deployment_order: ['development', 'staging', 'production'], - use_security_warnings: false, - allow_non_default_target_branch_deployments: false - } - - expect(await help(octokit, context, 123, inputs)) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/## ๐Ÿ“š Branch Deployment Help/) - ) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/a specific deployment order by environment/) - ) - - var inputsSecond = inputs - inputsSecond.update_branch = 'disabled' - expect(await help(octokit, context, 123, inputsSecond)) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/## ๐Ÿ“š Branch Deployment Help/) - ) -}) - -test('successfully calls help with non-defaults and unknown update_branch setting', async () => { - const inputs = { - trigger: '.deploy', - reaction: 'eyes', - environment: 'production', - stable_branch: 'main', - noop_trigger: '.noop', - lock_trigger: '.lock', - production_environments: 'production,production-eu,production-ap', - environment_targets: 'production,staging,development', - unlock_trigger: '.unlock', - help_trigger: '.help', - lock_info_alias: '.wcid', - global_lock_flag: '--global', - update_branch: 'bugzzz', - outdated_mode: 'default_branch', - required_contexts: 'cat', - allowForks: 'false', - skipCi: 'development', - skipReviews: 'development', - draft_permitted_targets: 'development', - admins: 'monalisa', - permissions: ['write', 'admin'], - allow_sha_deployments: false, - checks: 'required', - ignored_checks: ['lint'], - enforced_deployment_order: [], - use_security_warnings: false, - allow_non_default_target_branch_deployments: true - } - - expect(await help(octokit, context, 123, inputs)) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/## ๐Ÿ“š Branch Deployment Help/) - ) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching( - /Deployments can be made to any environment in any order/ - ) - ) - - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/Unknown value for update_branch/) - ) - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching(/not use security warnings/) - ) - expect(debugMock).toHaveBeenCalledWith( - expect.stringMatching( - /will allow the deployments of pull requests that target a branch other than the default branch/ - ) - ) -}) diff --git a/__tests__/functions/help.test.ts b/__tests__/functions/help.test.ts new file mode 100644 index 00000000..80ac7944 --- /dev/null +++ b/__tests__/functions/help.test.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import { + createActionInputs, + createIssueCommentContext, + createOctokit +} from '../test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' +import {createMock, installModuleMock} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionStatus = typeof import('../../src/functions/action-status.ts') + +const debugMock = createMock() +const actionStatusMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock +}) +installModuleMock( + mock, + new URL('../../src/functions/action-status.ts', import.meta.url), + {actionStatus: actionStatusMock} +) + +const {help} = await import('../../src/functions/help.ts') + +beforeEach(() => { + debugMock.mock.resetCalls() + actionStatusMock.mock.resetCalls() + actionStatusMock.mock.mockImplementation(() => Promise.resolve(undefined)) +}) + +function assertDebugMatches(pattern: RegExp): void { + assert.ok( + debugMock.mock.calls.some(call => pattern.test(call.arguments[0])), + `expected a debug call matching ${pattern.source}` + ) +} + +function assertDebugIncludes(expected: string): void { + assert.ok( + debugMock.mock.calls.some(call => call.arguments[0].includes(expected)), + `expected a debug call containing ${expected}` + ) +} + +const context = createIssueCommentContext({ + repo: { + owner: 'corp', + repo: 'test' + }, + issue: { + number: 1 + }, + payload: { + comment: {id: 123} + } +}) +const octokit = createOctokit() + +const defaultInputs = createActionInputs({ + commit_verification: true, + outdated_mode: 'strict' +}) + +test('successfully calls help with defaults', async () => { + assert.strictEqual( + await help(octokit, context, 123, defaultInputs), + undefined + ) + + assertDebugMatches(/## ๐Ÿ“š Branch Deployment Help/) + assertDebugIncludes( + '`allowForks: false` - This Action will not run on forked repositories' + ) + assertDebugIncludes( + '`disable_lock: false` - This Action will use deployment locks' + ) +}) + +test('explains disabled locking in help output', async () => { + const inputs = createActionInputs({disable_lock: true}) + + assert.strictEqual(await help(octokit, context, 123, inputs), undefined) + assertDebugIncludes( + '> Deployment locking is disabled. Lock-related commands only report that no lock state is changed.' + ) + assertDebugIncludes( + '`disable_lock: true` - This Action will skip deployment lock acquisition and completion' + ) +}) + +test('successfully calls help with non-defaults', async () => { + const inputs = createActionInputs({ + trigger: '.deploy', + reaction: 'eyes', + environment: 'production', + stable_branch: 'main', + noop_trigger: '.noop', + lock_trigger: '.lock', + production_environments: ['production'], + environment_targets: 'production,staging,development', + unlock_trigger: '.unlock', + help_trigger: '.help', + lock_info_alias: '.wcid', + global_lock_flag: '--global', + update_branch: 'force', + outdated_mode: 'pr_base', + required_contexts: 'cat', + allowForks: false, + skipCi: 'development', + skipReviews: 'development', + draft_permitted_targets: 'development', + admins: 'monalisa', + permissions: ['write', 'admin'], + allow_sha_deployments: true, + checks: ['test,build,security'], + ignored_checks: ['lint', 'format'], + commit_verification: false, + enforced_deployment_order: [], + use_security_warnings: false, + allow_non_default_target_branch_deployments: false, + deployment_confirmation: true + }) + + assert.strictEqual(await help(octokit, context, 123, inputs), undefined) + + assertDebugMatches(/## ๐Ÿ“š Branch Deployment Help/) +}) + +test('successfully calls help with non-defaults again', async () => { + const inputs = createActionInputs({ + trigger: '.deploy', + reaction: 'eyes', + environment: 'production', + stable_branch: 'main', + noop_trigger: '.noop', + lock_trigger: '.lock', + production_environments: ['production', 'production-eu', 'production-ap'], + environment_targets: 'production,staging,development', + unlock_trigger: '.unlock', + help_trigger: '.help', + lock_info_alias: '.wcid', + global_lock_flag: '--global', + update_branch: 'force', + outdated_mode: 'default_branch', + required_contexts: 'cat', + allowForks: false, + skipCi: 'development', + skipReviews: 'development', + draft_permitted_targets: 'development', + admins: 'monalisa', + permissions: ['write', 'admin'], + allow_sha_deployments: false, + checks: 'required', + ignored_checks: ['lint'], + commit_verification: false, + enforced_deployment_order: ['development', 'staging', 'production'], + use_security_warnings: false, + allow_non_default_target_branch_deployments: false + }) + + assert.strictEqual(await help(octokit, context, 123, inputs), undefined) + + assertDebugMatches(/## ๐Ÿ“š Branch Deployment Help/) + + assertDebugMatches(/a specific deployment order by environment/) + + const inputsSecond = {...inputs, update_branch: 'disabled'} as const + assert.strictEqual(await help(octokit, context, 123, inputsSecond), undefined) + + assertDebugMatches(/## ๐Ÿ“š Branch Deployment Help/) +}) + +test('successfully calls help with non-defaults and unknown update_branch setting', async () => { + const inputs = createActionInputs({ + trigger: '.deploy', + reaction: 'eyes', + environment: 'production', + stable_branch: 'main', + noop_trigger: '.noop', + lock_trigger: '.lock', + production_environments: ['production', 'production-eu', 'production-ap'], + environment_targets: 'production,staging,development', + unlock_trigger: '.unlock', + help_trigger: '.help', + lock_info_alias: '.wcid', + global_lock_flag: '--global', + update_branch: + unsafeInvalidValue[3]['update_branch']>('bugzzz'), + outdated_mode: 'default_branch', + required_contexts: 'cat', + allowForks: false, + skipCi: 'development', + skipReviews: 'development', + draft_permitted_targets: 'development', + admins: 'monalisa', + permissions: ['write', 'admin'], + allow_sha_deployments: false, + checks: 'required', + ignored_checks: ['lint'], + enforced_deployment_order: [], + use_security_warnings: false, + allow_non_default_target_branch_deployments: true + }) + + assert.strictEqual(await help(octokit, context, 123, inputs), undefined) + + assertDebugMatches(/## ๐Ÿ“š Branch Deployment Help/) + + assertDebugMatches(/Deployments can be made to any environment in any order/) + + assertDebugMatches(/Unknown value for update_branch/) + assertDebugMatches(/not use security warnings/) + assertDebugMatches( + /will allow the deployments of pull requests that target a branch other than the default branch/ + ) +}) + +test('renders a custom string-valued checks input without mutating it', async () => { + const inputs = createActionInputs({ + checks: + unsafeInvalidValue[3]['checks']>('custom-check') + }) + + assert.strictEqual(await help(octokit, context, 123, inputs), undefined) + assertDebugIncludes( + 'The following CI checks must pass before a deployment can be requested: `custom-check`' + ) +}) + +test('preserves legacy rendering for the string-valued allowForks input', async () => { + const inputs = createActionInputs({ + allowForks: unsafeInvalidValue('true') + }) + + assert.strictEqual(await help(octokit, context, 123, inputs), undefined) + assertDebugIncludes( + '`allowForks: true` - This Action will run on forked repositories' + ) +}) diff --git a/__tests__/functions/identical-commit-check.test.js b/__tests__/functions/identical-commit-check.test.js deleted file mode 100644 index 526481b1..00000000 --- a/__tests__/functions/identical-commit-check.test.js +++ /dev/null @@ -1,124 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {identicalCommitCheck} from '../../src/functions/identical-commit-check.js' -import {COLORS} from '../../src/functions/colors.js' - -const saveStateMock = vi.spyOn(core, 'saveState') -const setOutputMock = vi.spyOn(core, 'setOutput') -const infoMock = vi.spyOn(core, 'info') - -var context -var octokit -beforeEach(() => { - vi.clearAllMocks() - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - payload: { - comment: { - id: '1' - } - } - } - - octokit = { - rest: { - repos: { - get: vi.fn().mockReturnValue({ - data: { - default_branch: 'main' - } - }), - getBranch: vi.fn().mockReturnValue({ - data: { - commit: { - sha: 'abcdef', - commit: { - tree: { - sha: 'deadbeef' - } - } - } - } - }), - getCommit: vi.fn().mockReturnValue({ - data: { - commit: { - tree: { - sha: 'deadbeef' - } - } - } - }), - listDeployments: vi.fn().mockReturnValue({ - data: [ - { - sha: 'deadbeef', - id: 123395608, - created_at: '2023-02-01T21:30:40Z', - payload: { - type: 'some-other-type' - } - }, - { - sha: 'beefdead', - id: 785395609, - created_at: '2023-02-01T20:26:33Z', - payload: { - type: 'branch-deploy' - } - } - ] - }) - } - } - } -}) - -test('checks if the default branch sha and deployment sha are identical, and they are', async () => { - expect( - await identicalCommitCheck(octokit, context, 'production') - ).toStrictEqual(true) - expect(infoMock).toHaveBeenCalledWith( - `๐ŸŸฐ the latest deployment tree sha is ${COLORS.highlight}equal${COLORS.reset} to the default branch tree sha` - ) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'false') - expect(setOutputMock).toHaveBeenCalledWith('environment', 'production') - expect(setOutputMock).not.toHaveBeenCalledWith('sha', 'abcdef') - expect(saveStateMock).not.toHaveBeenCalledWith('sha', 'abcdef') -}) - -test('checks if the default branch sha and deployment sha are identical, and they are not', async () => { - octokit.rest.repos.getCommit = vi.fn().mockReturnValue({ - data: { - commit: { - tree: { - sha: 'beefdead' - } - } - } - }) - - expect( - await identicalCommitCheck(octokit, context, 'production') - ).toStrictEqual(false) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ“ latest commit sha on ${COLORS.highlight}main${COLORS.reset}: ${COLORS.info}abcdef${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐ŸŒฒ latest default ${COLORS.info}branch${COLORS.reset} tree sha: ${COLORS.info}deadbeef${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐ŸŒฒ latest ${COLORS.info}deployment${COLORS.reset} tree sha: ${COLORS.info}beefdead${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿš€ a ${COLORS.success}new deployment${COLORS.reset} will be created based on your configuration` - ) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(setOutputMock).toHaveBeenCalledWith('environment', 'production') - expect(setOutputMock).toHaveBeenCalledWith('sha', 'abcdef') - expect(saveStateMock).toHaveBeenCalledWith('sha', 'abcdef') -}) diff --git a/__tests__/functions/identical-commit-check.test.ts b/__tests__/functions/identical-commit-check.test.ts new file mode 100644 index 00000000..051de9a1 --- /dev/null +++ b/__tests__/functions/identical-commit-check.test.ts @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + assertNotCalled, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionIo = typeof import('../../src/action-io.ts') +type IdenticalCommitModule = + typeof import('../../src/functions/identical-commit-check.ts') + +const debugMock = createMock() +const infoMock = createMock() +const saveActionStateMock = createMock() +const setActionOutputMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + info: infoMock +}) +installModuleMock(mock, new URL('../../src/action-io.ts', import.meta.url), { + saveActionState: saveActionStateMock, + setActionOutput: setActionOutputMock +}) + +const {identicalCommitCheck} = + await import('../../src/functions/identical-commit-check.ts') + +let context: Parameters[1] +let octokit: Parameters[0] +type IdenticalOctokit = Parameters< + IdenticalCommitModule['identicalCommitCheck'] +>[0] +const getRepositoryMock = createMock() +const getBranchMock = + createMock() +const getCommitMock = + createMock() +const graphqlMock = createMock() + +beforeEach(() => { + debugMock.mock.resetCalls() + infoMock.mock.resetCalls() + saveActionStateMock.mock.resetCalls() + setActionOutputMock.mock.resetCalls() + getRepositoryMock.mock.resetCalls() + getBranchMock.mock.resetCalls() + getCommitMock.mock.resetCalls() + graphqlMock.mock.resetCalls() + + context = createContext({repo: {owner: 'corp', repo: 'test'}}) + + getRepositoryMock.mock.mockImplementation(() => + Promise.resolve({ + data: {default_branch: 'main'} + }) + ) + getBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + commit: { + sha: 'abcdef', + commit: {tree: {sha: 'deadbeef'}} + } + } + }) + ) + getCommitMock.mock.mockImplementation(() => + Promise.resolve({ + data: {commit: {tree: {sha: 'deadbeef'}}} + }) + ) + graphqlMock.mock.mockImplementation(() => + Promise.resolve({ + repository: { + id: 'R_test', + nameWithOwner: 'corp/test', + deployments: { + nodes: [ + { + commit: {oid: 'other'}, + createdAt: '2023-02-01T21:30:40Z', + environment: 'production', + id: 'D_other', + payload: {type: 'some-other-type'}, + state: 'ACTIVE' + }, + { + commit: {oid: 'beefdead'}, + createdAt: '2023-02-01T20:26:33Z', + environment: 'production', + id: 'D_branch_deploy', + payload: {type: 'branch-deploy'}, + state: 'ACTIVE' + } + ], + pageInfo: {endCursor: null, hasNextPage: false} + } + } + }) + ) + + octokit = { + graphql: graphqlMock, + rest: { + repos: { + get: getRepositoryMock, + getBranch: getBranchMock, + getCommit: getCommitMock + } + } + } +}) + +test('checks if the default branch sha and deployment sha are identical, and they are', async () => { + assert.strictEqual( + await identicalCommitCheck(octokit, context, 'production'), + true + ) + assertCalledWith( + infoMock, + `๐ŸŸฐ the latest deployment tree sha is ${COLORS.highlight}equal${COLORS.reset} to the default branch tree sha` + ) + assertCalledWith(setActionOutputMock, 'continue', 'false') + assertCalledWith(setActionOutputMock, 'environment', 'production') + assertNotCalled(saveActionStateMock) + assert.strictEqual( + setActionOutputMock.mock.calls.some( + call => call.arguments[0] === 'sha' && call.arguments[1] === 'abcdef' + ), + false + ) +}) + +test('checks if the default branch sha and deployment sha are identical, and they are not', async () => { + getCommitMock.mock.mockImplementation(() => + Promise.resolve({ + data: {commit: {tree: {sha: 'beefdead'}}} + }) + ) + + assert.strictEqual( + await identicalCommitCheck(octokit, context, 'production'), + false + ) + assertCalledWith( + infoMock, + `๐Ÿ“ latest commit sha on ${COLORS.highlight}main${COLORS.reset}: ${COLORS.info}abcdef${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐ŸŒฒ latest default ${COLORS.info}branch${COLORS.reset} tree sha: ${COLORS.info}deadbeef${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐ŸŒฒ latest ${COLORS.info}deployment${COLORS.reset} tree sha: ${COLORS.info}beefdead${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿš€ a ${COLORS.success}new deployment${COLORS.reset} will be created based on your configuration` + ) + assertCalledWith(setActionOutputMock, 'continue', 'true') + assertCalledWith(setActionOutputMock, 'environment', 'production') + assertCalledWith(setActionOutputMock, 'sha', 'abcdef') + assertCalledWith(saveActionStateMock, 'sha', 'abcdef') +}) + +test('does not skip when the newest branch-deploy deployment is inactive', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve({ + repository: { + id: 'R_test', + nameWithOwner: 'corp/test', + deployments: { + nodes: [ + { + commit: {oid: 'beefdead'}, + createdAt: '2023-02-01T20:26:33Z', + environment: 'production', + id: 'D_inactive', + payload: {type: 'branch-deploy'}, + state: 'INACTIVE' + }, + { + commit: {oid: 'older-active'}, + createdAt: '2023-01-31T20:26:33Z', + environment: 'production', + id: 'D_active', + payload: {type: 'branch-deploy'}, + state: 'ACTIVE' + } + ], + pageInfo: {endCursor: null, hasNextPage: false} + } + } + }) + ) + + assert.strictEqual( + await identicalCommitCheck(octokit, context, 'production'), + false + ) + assertNotCalled(getCommitMock) + assertCalledWith(setActionOutputMock, 'continue', 'true') + assertCalledWith(setActionOutputMock, 'environment', 'production') + assertCalledWith(setActionOutputMock, 'sha', 'abcdef') +}) + +test('does not skip when no branch-deploy deployment exists', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.resolve({ + repository: { + id: 'R_test', + nameWithOwner: 'corp/test', + deployments: { + nodes: [], + pageInfo: {endCursor: null, hasNextPage: false} + } + } + }) + ) + + assert.strictEqual( + await identicalCommitCheck(octokit, context, 'production'), + false + ) + assertNotCalled(getCommitMock) + assertCalledWith(setActionOutputMock, 'continue', 'true') +}) diff --git a/__tests__/functions/is-timestamp-older.test.js b/__tests__/functions/is-timestamp-older.test.js deleted file mode 100644 index 76ae511f..00000000 --- a/__tests__/functions/is-timestamp-older.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, describe, test, beforeEach} from 'vitest' -import {isTimestampOlder} from '../../src/functions/is-timestamp-older.js' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('isTimestampOlder', () => { - test('throws if timestampA is missing', () => { - expect(() => isTimestampOlder(undefined, '2024-10-15T11:00:00Z')).toThrow( - 'One or both timestamps are missing or empty.' - ) - expect(() => isTimestampOlder(null, '2024-10-15T11:00:00Z')).toThrow( - 'One or both timestamps are missing or empty.' - ) - expect(() => isTimestampOlder('', '2024-10-15T11:00:00Z')).toThrow( - 'One or both timestamps are missing or empty.' - ) - }) - - test('throws if timestampB is missing', () => { - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', undefined)).toThrow( - 'One or both timestamps are missing or empty.' - ) - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', null)).toThrow( - 'One or both timestamps are missing or empty.' - ) - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', '')).toThrow( - 'One or both timestamps are missing or empty.' - ) - }) - - test('throws if both timestamps are invalid', () => { - expect(() => isTimestampOlder('bad', 'bad')).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder('notadate', '2024-10-15T11:00:00Z')).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', 'notadate')).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder({}, '2024-10-15T11:00:00Z')).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', {})).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder([], '2024-10-15T11:00:00Z')).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', [])).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder('2024-10-15T11:00:00Z', 1)).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - expect(() => isTimestampOlder(1, '2024-10-15T11:00:00Z')).toThrow( - /format YYYY-MM-DDTHH:MM:SSZ/ - ) - }) - - test('returns true if timestampA is older than timestampB', () => { - expect( - isTimestampOlder('2024-10-15T11:00:00Z', '2024-10-16T11:00:00Z') - ).toBe(true) - expect(core.debug).toHaveBeenCalledWith( - '2024-10-15T11:00:00Z is older than 2024-10-16T11:00:00Z' - ) - }) - - test('returns false if timestampA is newer than timestampB', () => { - expect( - isTimestampOlder('2024-10-17T11:00:00Z', '2024-10-16T11:00:00Z') - ).toBe(false) - expect(core.debug).toHaveBeenCalledWith( - '2024-10-17T11:00:00Z is not older than 2024-10-16T11:00:00Z' - ) - }) - - test('returns false if timestampA equals timestampB', () => { - expect( - isTimestampOlder('2024-10-16T11:00:00Z', '2024-10-16T11:00:00Z') - ).toBe(false) - expect(core.debug).toHaveBeenCalledWith( - '2024-10-16T11:00:00Z is not older than 2024-10-16T11:00:00Z' - ) - }) - - test('accepts valid leap year date', () => { - // Feb 29, 2024 is valid (leap year) - expect(() => - isTimestampOlder('2024-02-29T12:00:00Z', '2024-10-15T11:00:00Z') - ).not.toThrow() - expect( - isTimestampOlder('2024-02-29T12:00:00Z', '2024-10-15T11:00:00Z') - ).toBe(true) - expect( - isTimestampOlder('2024-10-15T11:00:00Z', '2024-02-29T12:00:00Z') - ).toBe(false) - }) - - test('throws an error on js silent date contructor corrections', () => { - // Invalid date: 2024-02-30T12:00:00Z actually becomes 2024-03-01T12:00:00Z (gross) - expect(() => - isTimestampOlder('2024-02-30T12:00:00Z', '2024-10-15T11:00:00Z') - ).toThrow(/Invalid date format/) - expect(() => - isTimestampOlder('2024-10-15T11:00:00Z', '2024-02-30T12:00:00Z') - ).toThrow(/Invalid date format/) - }) -}) diff --git a/__tests__/functions/is-timestamp-older.test.ts b/__tests__/functions/is-timestamp-older.test.ts new file mode 100644 index 00000000..81bae95e --- /dev/null +++ b/__tests__/functions/is-timestamp-older.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' +import {beforeEach, describe, mock, test} from 'node:test' +import {installModuleMock} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const debugMock = mock.fn() +const errorMock = mock.fn() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: errorMock +}) + +const {isTimestampOlder} = + await import('../../src/functions/is-timestamp-older.ts') + +beforeEach(() => { + debugMock.mock.resetCalls() + errorMock.mock.resetCalls() +}) + +describe('isTimestampOlder', () => { + test('throws if timestampA is missing', () => { + assert.throws(() => isTimestampOlder(undefined, '2024-10-15T11:00:00Z'), { + message: 'One or both timestamps are missing or empty.' + }) + assert.throws(() => isTimestampOlder(null, '2024-10-15T11:00:00Z'), { + message: 'One or both timestamps are missing or empty.' + }) + assert.throws(() => isTimestampOlder('', '2024-10-15T11:00:00Z'), { + message: 'One or both timestamps are missing or empty.' + }) + }) + + test('throws if timestampB is missing', () => { + assert.throws(() => isTimestampOlder('2024-10-15T11:00:00Z', undefined), { + message: 'One or both timestamps are missing or empty.' + }) + assert.throws(() => isTimestampOlder('2024-10-15T11:00:00Z', null), { + message: 'One or both timestamps are missing or empty.' + }) + assert.throws(() => isTimestampOlder('2024-10-15T11:00:00Z', ''), { + message: 'One or both timestamps are missing or empty.' + }) + }) + + test('throws if both timestamps are invalid', () => { + assert.throws( + () => isTimestampOlder('bad', 'bad'), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder('notadate', '2024-10-15T11:00:00Z'), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder('2024-10-15T11:00:00Z', 'notadate'), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder({}, '2024-10-15T11:00:00Z'), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder('2024-10-15T11:00:00Z', {}), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder([], '2024-10-15T11:00:00Z'), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder('2024-10-15T11:00:00Z', []), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder('2024-10-15T11:00:00Z', 1), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + assert.throws( + () => isTimestampOlder(1, '2024-10-15T11:00:00Z'), + /format YYYY-MM-DDTHH:MM:SSZ/ + ) + }) + + test('returns true if timestampA is older than timestampB', () => { + assert.strictEqual( + isTimestampOlder('2024-10-15T11:00:00Z', '2024-10-16T11:00:00Z'), + true + ) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [['2024-10-15T11:00:00Z is older than 2024-10-16T11:00:00Z']] + ) + }) + + test('returns false if timestampA is newer than timestampB', () => { + assert.strictEqual( + isTimestampOlder('2024-10-17T11:00:00Z', '2024-10-16T11:00:00Z'), + false + ) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [['2024-10-17T11:00:00Z is not older than 2024-10-16T11:00:00Z']] + ) + }) + + test('returns false if timestampA equals timestampB', () => { + assert.strictEqual( + isTimestampOlder('2024-10-16T11:00:00Z', '2024-10-16T11:00:00Z'), + false + ) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [['2024-10-16T11:00:00Z is not older than 2024-10-16T11:00:00Z']] + ) + }) + + test('accepts valid leap year date', () => { + // Feb 29, 2024 is valid (leap year) + isTimestampOlder('2024-02-29T12:00:00Z', '2024-10-15T11:00:00Z') + assert.strictEqual( + isTimestampOlder('2024-02-29T12:00:00Z', '2024-10-15T11:00:00Z'), + true + ) + assert.strictEqual( + isTimestampOlder('2024-10-15T11:00:00Z', '2024-02-29T12:00:00Z'), + false + ) + }) + + test('throws an error on js silent date contructor corrections', () => { + // Invalid date: 2024-02-30T12:00:00Z actually becomes 2024-03-01T12:00:00Z (gross) + assert.throws( + () => isTimestampOlder('2024-02-30T12:00:00Z', '2024-10-15T11:00:00Z'), + /Invalid date format/ + ) + assert.throws( + () => isTimestampOlder('2024-10-15T11:00:00Z', '2024-02-30T12:00:00Z'), + /Invalid date format/ + ) + }) +}) diff --git a/__tests__/functions/issue-command.test.ts b/__tests__/functions/issue-command.test.ts new file mode 100644 index 00000000..daf6a233 --- /dev/null +++ b/__tests__/functions/issue-command.test.ts @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import { + analyzeIssueCommand, + analyzeNakedCommand +} from '../../src/functions/issue-command.ts' + +const config = { + globalFlag: '--global', + helpTrigger: '.help', + lockInfoAlias: '.wcid', + lockTrigger: '.lock', + noopTrigger: '.noop', + paramSeparator: '|', + trigger: '.deploy', + unlockTrigger: '.unlock' +} as const + +test('classifies every supported IssueOps command', () => { + assert.deepStrictEqual( + [ + '.deploy', + '.noop', + '.lock', + '.lock --details', + '.unlock', + '.help', + '.wcid', + '.unknown' + ].map(body => { + const result = analyzeIssueCommand(body, config) + return [result.operation, result.outputType, result.dispatch] + }), + [ + ['deploy', 'deploy', 'deployment'], + ['noop', 'deploy', 'deployment'], + ['lock', 'lock', 'lock'], + ['lock_info', 'lock', 'lock_info'], + ['unlock', 'unlock', 'unlock'], + ['help', 'help', 'help'], + ['lock_info', 'lock-info-alias', 'lock_info'], + ['none', null, 'none'] + ] + ) +}) + +test('preserves overlapping trigger classification and dispatch precedence', () => { + const overlap = { + ...config, + helpTrigger: '.x', + lockInfoAlias: '.x', + lockTrigger: '.x', + noopTrigger: '.x', + trigger: '.x', + unlockTrigger: '.x' + } + const result = analyzeIssueCommand('.x', overlap) + + assert.deepStrictEqual(result.matches, { + deploy: true, + help: true, + lock: true, + lockInfoAlias: true, + noop: true, + unlock: true + }) + assert.strictEqual(result.operation, 'noop') + assert.strictEqual(result.outputType, 'deploy') + assert.strictEqual(result.dispatch, 'help') +}) + +test('preserves permissive lock-info substring matching', () => { + const result = analyzeIssueCommand('.lock --detailsXYZ', config) + assert.strictEqual(result.hasLockInfoFlag, true) + assert.strictEqual(result.operation, 'lock_info') + assert.strictEqual(result.dispatch, 'lock_info') +}) + +test('normalizes legacy naked-command modifiers and parameters', () => { + const result = analyzeIssueCommand( + '.lock --details --reason because | key=value', + config + ) + assert.deepStrictEqual(result.naked, { + body: '.lock', + globalBypass: false, + isNaked: true, + params: '' + }) +}) + +test('preserves global substring bypass and empty-global behavior', () => { + assert.strictEqual( + analyzeIssueCommand('.lock --globalish', config).naked.globalBypass, + true + ) + assert.strictEqual( + analyzeIssueCommand('.deploy', {...config, globalFlag: ''}).naked + .globalBypass, + true + ) +}) + +test('requires trigger boundaries while preserving prefix overlap', () => { + assert.strictEqual( + analyzeIssueCommand('.deploy-two', config).dispatch, + 'none' + ) + const overlap = analyzeIssueCommand('.ship now', { + ...config, + noopTrigger: '.ship now', + trigger: '.ship' + }) + assert.strictEqual(overlap.matches.deploy, true) + assert.strictEqual(overlap.matches.noop, true) + assert.strictEqual(overlap.operation, 'noop') +}) + +test('classifies custom commands and preserves repeated custom separators in parameters', () => { + const custom = { + ...config, + globalFlag: '--everywhere', + helpTrigger: '/assist', + lockInfoAlias: '/who', + lockTrigger: '/hold', + noopTrigger: '/plan', + paramSeparator: '::', + trigger: '/ship', + unlockTrigger: '/release' + } + + const deployment = analyzeIssueCommand('/ship :: --tag=a::b', custom) + assert.strictEqual(deployment.dispatch, 'deployment') + assert.strictEqual(deployment.operation, 'deploy') + assert.deepStrictEqual(deployment.naked, { + body: '/ship', + globalBypass: false, + isNaked: true, + params: ' --tag=a::b' + }) + + const lockInfo = analyzeIssueCommand('/hold --details :: reason', custom) + assert.strictEqual(lockInfo.dispatch, 'lock_info') + assert.strictEqual(lockInfo.operation, 'lock_info') + assert.strictEqual(lockInfo.naked.body, '/hold') +}) + +test('accepts whitespace trigger boundaries and rejects punctuation or leading whitespace', () => { + const custom = {...config, trigger: '/ship'} + + assert.strictEqual( + analyzeIssueCommand('/ship\tproduction', custom).dispatch, + 'deployment' + ) + assert.strictEqual( + analyzeIssueCommand('/ship\nproduction', custom).dispatch, + 'deployment' + ) + assert.strictEqual(analyzeIssueCommand('/ship-now', custom).dispatch, 'none') + assert.strictEqual( + analyzeIssueCommand(' /ship production', custom).dispatch, + 'none' + ) +}) + +test('fails closed when splitting a custom parameter suffix returns no command', context => { + const originalSplit: ( + this: string, + separator: string | RegExp, + limit?: number + ) => string[] = String.prototype.split + context.mock.method( + String.prototype, + 'split', + function (this: string, separator: string | RegExp, limit?: number) { + if (separator === ':: --flag=true') return [] + return originalSplit.call(this, separator, limit) + } + ) + + assert.deepStrictEqual( + analyzeNakedCommand('/ship :: --flag=true', '::', ['/ship'], '--global'), + {body: '', globalBypass: false, isNaked: false, params: ' --flag=true'} + ) +}) diff --git a/__tests__/functions/json-code-block.test.ts b/__tests__/functions/json-code-block.test.ts new file mode 100644 index 00000000..825170e8 --- /dev/null +++ b/__tests__/functions/json-code-block.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {jsonCodeBlock} from '../../src/functions/json-code-block.ts' + +test('uses a standard JSON fence when the value has no backticks', () => { + assert.strictEqual( + jsonCodeBlock({value: 'safe'}), + '```json\n{\n "value": "safe"\n}\n```' + ) +}) + +test('uses a fence longer than every backtick run in the JSON', () => { + assert.strictEqual( + jsonCodeBlock({value: 'before ````` after'}), + '``````json\n{\n "value": "before ````` after"\n}\n``````' + ) +}) + +test('keeps multiline and multiple backtick runs inside one JSON fence', () => { + const value = { + message: 'first line\n```json\n{"approved":true}\n```\n```````', + nested: {value: '````'} + } + const rendered = jsonCodeBlock(value) + + assert.strictEqual( + rendered, + `\`\`\`\`\`\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\`\`\`\`\`\`` + ) + assert.strictEqual(rendered.includes('\n```json\n{"approved":true}'), false) +}) + +test('serializes an undefined boundary value as JSON null', () => { + assert.strictEqual(jsonCodeBlock(undefined), '```json\nnull\n```') +}) diff --git a/__tests__/functions/label.test.js b/__tests__/functions/label.test.js deleted file mode 100644 index 93bc02b3..00000000 --- a/__tests__/functions/label.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import {label} from '../../src/functions/label.js' -import {vi, expect, test, beforeEach} from 'vitest' - -var context -var octokit -beforeEach(() => { - vi.clearAllMocks() - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - } - } - - octokit = { - rest: { - issues: { - addLabels: vi.fn().mockReturnValueOnce({ - data: {} - }), - removeLabel: vi.fn().mockReturnValueOnce({ - data: {} - }), - listLabelsOnIssue: vi.fn().mockReturnValueOnce({ - data: [ - { - name: 'deploy-failed' - }, - { - name: 'noop' - } - ] - }) - } - } - } -}) - -test('adds a single label to a pull request and removes none', async () => { - expect(await label(context, octokit, ['read-for-review'], [])).toStrictEqual({ - added: ['read-for-review'], - removed: [] - }) -}) - -test('adds two labels to a pull request and removes none', async () => { - expect( - await label(context, octokit, ['read-for-review', 'cool-label'], []) - ).toStrictEqual({ - added: ['read-for-review', 'cool-label'], - removed: [] - }) -}) - -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 () => { - expect( - await label(context, octokit, ['read-for-review'], ['unknown-label']) - ).toStrictEqual({ - added: ['read-for-review'], - removed: [] - }) -}) - -test('does not add or remove any labels', async () => { - expect(await label(context, octokit, [], [])).toStrictEqual({ - added: [], - removed: [] - }) -}) - -test('adds a single label to a pull request and removes a single label', async () => { - expect( - await label(context, octokit, ['deploy-success'], ['deploy-failed']) - ).toStrictEqual({ - added: ['deploy-success'], - removed: ['deploy-failed'] - }) -}) - -test('adds two labels to a pull request and removes two labels', async () => { - expect( - await label( - context, - octokit, - ['deploy-success', 'read-for-review'], - ['deploy-failed', 'noop'] - ) - ).toStrictEqual({ - added: ['deploy-success', 'read-for-review'], - removed: ['deploy-failed', 'noop'] - }) -}) - -test('does not add any labels and removes a single label', async () => { - expect(await label(context, octokit, [], ['noop'])).toStrictEqual({ - added: [], - removed: ['noop'] - }) -}) diff --git a/__tests__/functions/label.test.ts b/__tests__/functions/label.test.ts new file mode 100644 index 00000000..70e48975 --- /dev/null +++ b/__tests__/functions/label.test.ts @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test, type Mock} from 'node:test' +import type {LabelOctokit} from '../../src/functions/label.ts' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledTimes, + assertCalledWith, + assertNotCalled, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type LabelModule = typeof import('../../src/functions/label.ts') + +const debugMock = createMock() +const infoMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + info: infoMock +}) + +const {label} = await import('../../src/functions/label.ts') + +let context: Parameters[0] +let octokit: Parameters[1] +let addLabelsMock: Mock +let listLabelsMock: Mock +let removeLabelMock: Mock + +beforeEach(() => { + debugMock.mock.resetCalls() + infoMock.mock.resetCalls() + + context = createContext({ + repo: {owner: 'corp', repo: 'test'}, + issue: {number: 1} + }) + + addLabelsMock = createMock() + removeLabelMock = createMock() + listLabelsMock = createMock< + LabelOctokit['rest']['issues']['listLabelsOnIssue'] + >(() => + Promise.resolve({ + data: [{name: 'deploy-failed'}, {name: 'noop'}] + }) + ) + + octokit = { + rest: { + issues: { + addLabels: addLabelsMock, + removeLabel: removeLabelMock, + listLabelsOnIssue: listLabelsMock + } + } + } satisfies LabelOctokit +}) + +test('adds a single label to a pull request and removes none', async () => { + assert.deepStrictEqual( + await label(context, octokit, ['read-for-review'], []), + { + added: ['read-for-review'], + removed: [] + } + ) +}) + +test('adds two labels to a pull request and removes none', async () => { + assert.deepStrictEqual( + await label(context, octokit, ['read-for-review', 'cool-label'], []), + { + added: ['read-for-review', 'cool-label'], + removed: [] + } + ) +}) + +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 () => { + assert.deepStrictEqual( + await label(context, octokit, ['read-for-review'], ['unknown-label']), + { + added: ['read-for-review'], + removed: [] + } + ) +}) + +test('does not add or remove any labels', async () => { + assert.deepStrictEqual(await label(context, octokit, [], []), { + added: [], + removed: [] + }) +}) + +test('adds a single label to a pull request and removes a single label', async () => { + assert.deepStrictEqual( + await label(context, octokit, ['deploy-success'], ['deploy-failed']), + { + added: ['deploy-success'], + removed: ['deploy-failed'] + } + ) +}) + +test('adds two labels to a pull request and removes two labels', async () => { + assert.deepStrictEqual( + await label( + context, + octokit, + ['deploy-success', 'read-for-review'], + ['deploy-failed', 'noop'] + ), + { + added: ['deploy-success', 'read-for-review'], + removed: ['deploy-failed', 'noop'] + } + ) +}) + +test('does not add any labels and removes a single label', async () => { + assert.deepStrictEqual(await label(context, octokit, [], ['noop']), { + added: [], + removed: ['noop'] + }) +}) + +test('removes a label after the first page of issue labels', async () => { + listLabelsMock.mock.mockImplementation(parameters => + Promise.resolve({ + data: + parameters?.page === 1 + ? Array.from({length: 100}, (_, index) => ({ + name: `unrelated-${String(index)}` + })) + : [{name: 'deploy-failed'}] + }) + ) + + assert.deepStrictEqual( + await label(context, octokit, ['deploy-success'], ['deploy-failed']), + {added: ['deploy-success'], removed: ['deploy-failed']} + ) + assertCalledTimes(listLabelsMock, 2) + assertCalledWith(listLabelsMock, { + owner: 'corp', + repo: 'test', + issue_number: 1, + per_page: 100, + page: 1, + headers: API_HEADERS + }) + assertCalledWith(listLabelsMock, { + owner: 'corp', + repo: 'test', + issue_number: 1, + per_page: 100, + page: 2, + headers: API_HEADERS + }) + assertCalledWith(removeLabelMock, { + owner: 'corp', + repo: 'test', + issue_number: 1, + name: 'deploy-failed', + headers: API_HEADERS + }) +}) + +test('stops after an empty page when the first label page is full', async () => { + listLabelsMock.mock.mockImplementation(parameters => + Promise.resolve({ + data: + parameters?.page === 1 + ? Array.from({length: 100}, (_, index) => ({ + name: `unrelated-${String(index)}` + })) + : [] + }) + ) + + assert.deepStrictEqual(await label(context, octokit, [], ['missing']), { + added: [], + removed: [] + }) + assertCalledTimes(listLabelsMock, 2) + assertNotCalled(removeLabelMock) +}) + +test('does not mutate labels when a later page cannot be read', async () => { + const error = new Error('label page unavailable') + listLabelsMock.mock.mockImplementation(parameters => + parameters?.page === 1 + ? Promise.resolve({ + data: Array.from({length: 100}, (_, index) => ({ + name: `unrelated-${String(index)}` + })) + }) + : Promise.reject(error) + ) + + await assert.rejects( + label(context, octokit, ['deploy-success'], ['deploy-failed']), + candidate => candidate === error + ) + assertCalledTimes(listLabelsMock, 2) + assertNotCalled(addLabelsMock) + assertNotCalled(removeLabelMock) +}) diff --git a/__tests__/functions/lock.test.js b/__tests__/functions/lock.test.js deleted file mode 100644 index 7e60c799..00000000 --- a/__tests__/functions/lock.test.js +++ /dev/null @@ -1,1070 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {lock} from '../../src/functions/lock.js' -import {COLORS} from '../../src/functions/colors.js' -import * as actionStatus from '../../src/functions/action-status.js' - -class NotFoundError extends Error { - constructor(message) { - super(message) - this.status = 404 - } -} - -class BigBadError extends Error { - constructor(message) { - super(message) - this.status = 500 - } -} - -const environment = 'production' -const globalFlag = '--global' - -const lockBase64Monalisa = - 'ewogICAgInJlYXNvbiI6IG51bGwsCiAgICAiYnJhbmNoIjogImNvb2wtbmV3LWZlYXR1cmUiLAogICAgImNyZWF0ZWRfYXQiOiAiMjAyMi0wNi0xNVQyMToxMjoxNC4wNDFaIiwKICAgICJjcmVhdGVkX2J5IjogIm1vbmFsaXNhIiwKICAgICJzdGlja3kiOiBmYWxzZSwKICAgICJlbnZpcm9ubWVudCI6ICJwcm9kdWN0aW9uIiwKICAgICJ1bmxvY2tfY29tbWFuZCI6ICIudW5sb2NrIHByb2R1Y3Rpb24iLAogICAgImdsb2JhbCI6IGZhbHNlLAogICAgImxpbmsiOiAiaHR0cHM6Ly9naXRodWIuY29tL3Rlc3Qtb3JnL3Rlc3QtcmVwby9wdWxsLzMjaXNzdWVjb21tZW50LTEyMyIKfQo=' - -const lockBase64Octocat = - 'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJlbnZpcm9ubWVudCI6ICJwcm9kdWN0aW9uIiwKICAgICJ1bmxvY2tfY29tbWFuZCI6ICIudW5sb2NrIHByb2R1Y3Rpb24iLAogICAgImdsb2JhbCI6IGZhbHNlLAogICAgImxpbmsiOiAiaHR0cHM6Ly9naXRodWIuY29tL3Rlc3Qtb3JnL3Rlc3QtcmVwby9wdWxsLzIjaXNzdWVjb21tZW50LTQ1NiIKfQo=' - -const lockBase64OctocatNoReason = - 'ewogICAgInJlYXNvbiI6IG51bGwsCiAgICAiYnJhbmNoIjogIm9jdG9jYXRzLWV2ZXJ5d2hlcmUiLAogICAgImNyZWF0ZWRfYXQiOiAiMjAyMi0wNi0xNFQyMToxMjoxNC4wNDFaIiwKICAgICJjcmVhdGVkX2J5IjogIm9jdG9jYXQiLAogICAgInN0aWNreSI6IHRydWUsCiAgICAiZW52aXJvbm1lbnQiOiAicHJvZHVjdGlvbiIsCiAgICAidW5sb2NrX2NvbW1hbmQiOiAiLnVubG9jayBwcm9kdWN0aW9uIiwKICAgICJnbG9iYWwiOiBmYWxzZSwKICAgICJsaW5rIjogImh0dHBzOi8vZ2l0aHViLmNvbS90ZXN0LW9yZy90ZXN0LXJlcG8vcHVsbC8yI2lzc3VlY29tbWVudC00NTYiCn0K' - -const lockBase64OctocatGlobal = - 'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJlbnZpcm9ubWVudCI6IG51bGwsCiAgICAidW5sb2NrX2NvbW1hbmQiOiAiLnVubG9jayAtLWdsb2JhbCIsCiAgICAiZ2xvYmFsIjogdHJ1ZSwKICAgICJsaW5rIjogImh0dHBzOi8vZ2l0aHViLmNvbS90ZXN0LW9yZy90ZXN0LXJlcG8vcHVsbC8yI2lzc3VlY29tbWVudC00NTYiCn0K' - -const saveStateMock = vi.spyOn(core, 'saveState') -const setFailedMock = vi.spyOn(core, 'setFailed') -const infoMock = vi.spyOn(core, 'info') -const debugMock = vi.spyOn(core, 'debug') -const errorMock = vi.spyOn(core, 'error') - -var octokit -var octokitOtherUserHasLock -var createdLock -var monalisaOwner -var noLockFound -var failedToCreateLock - -beforeEach(() => { - vi.clearAllMocks() - - process.env.INPUT_GLOBAL_LOCK_FLAG = '--global' - process.env.INPUT_LOCK_TRIGGER = '.lock' - process.env.INPUT_ENVIRONMENT = 'production' - process.env.INPUT_LOCK_INFO_ALIAS = '.wcid' - - createdLock = { - lockData: null, - status: true, - globalFlag, - environment, - global: false - } - monalisaOwner = { - lockData: { - branch: 'cool-new-feature', - created_at: '2022-06-15T21:12:14.041Z', - created_by: 'monalisa', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/3#issuecomment-123', - reason: null, - sticky: false, - unlock_command: '.unlock production' - }, - status: 'owner', - globalFlag, - environment, - global: false - } - noLockFound = { - lockData: null, - status: null, - globalFlag, - environment, - global: false - } - failedToCreateLock = { - lockData: null, - status: false, - globalFlag, - environment, - global: false - } - - octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('Reference does not exist')) - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - createOrUpdateFileContents: vi.fn().mockReturnValue({}), - getContent: vi - .fn() - .mockRejectedValue(new NotFoundError('file not found')) - }, - git: { - createRef: vi.fn().mockReturnValue({status: 201}) - }, - issues: { - createComment: vi.fn().mockReturnValue({}) - } - } - } - - octokitOtherUserHasLock = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValueOnce({data: {content: lockBase64Octocat}}) - } - } - } -}) - -const context = { - actor: 'monalisa', - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - body: '.lock' - } - } -} - -const ref = 'cool-new-feature' - -test('successfully obtains a deployment lock (non-sticky) by creating the branch and lock file', async () => { - expect( - await lock(octokit, context, ref, 123, false, environment) - ).toStrictEqual(createdLock) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Determines that another user has the lock (GLOBAL) and exits - during a lock claim on deployment', async () => { - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - expect( - await lock(octokitOtherUserHasLock, context, ref, 123, false, environment) - ).toStrictEqual(failedToCreateLock) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(actionStatusSpy).toHaveBeenCalledWith( - context, - octokitOtherUserHasLock, - 123, - expect.stringMatching( - /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ - ) - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(setFailedMock).toHaveBeenCalledWith( - expect.stringMatching( - /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ - ) - ) -}) - -test('Determines that another user has the lock (non-global) and exits - during a lock claim on deployment', async () => { - failedToCreateLock.global = false - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - expect( - await lock(octokitOtherUserHasLock, context, ref, 123, false, environment) - ).toStrictEqual(failedToCreateLock) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(actionStatusSpy).toHaveBeenCalledWith( - context, - octokitOtherUserHasLock, - 123, - expect.stringMatching( - /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ - ) - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(setFailedMock).toHaveBeenCalledWith( - expect.stringMatching( - /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ - ) - ) -}) - -test('Determines that another user has the lock (GLOBAL) and exits - during a direct lock claim with .lock --global', async () => { - context.payload.comment.body = '.lock --global' - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) - .mockReturnValueOnce({data: {content: lockBase64OctocatGlobal}}) - } - } - } - expect(await lock(octokit, context, ref, 123, true, null)).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: null, - global: true, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock --global' - }, - status: false, - globalFlag, - environment: null, - global: true - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) - expect(actionStatusSpy).toHaveBeenCalledWith( - context, - octokit, - 123, - expect.stringMatching( - /Sorry __monalisa__, the `global` deployment lock is currently claimed by __octocat__/ - ) - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(setFailedMock).toHaveBeenCalledWith( - expect.stringMatching(/Cannot claim deployment lock/) - ) -}) - -test('Determines that another user has the lock (non-global) and exits - during a direct lock claim with .lock', async () => { - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) - .mockReturnValueOnce({data: {content: lockBase64Octocat}}) - } - } - } - expect( - await lock(octokit, context, ref, 123, true, environment) - ).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - status: false, - globalFlag, - environment, - global: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(actionStatusSpy).toHaveBeenCalledWith( - context, - octokit, - 123, - expect.stringMatching( - /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ - ) - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(setFailedMock).toHaveBeenCalledWith( - expect.stringMatching(/Cannot claim deployment lock/) - ) -}) - -test('Request detailsOnly on the lock file and gets lock file data successfully', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) // fails the first time looking for a global lock - .mockReturnValueOnce({data: {content: lockBase64Octocat}}) // succeeds the second time looking for a 'local' lock for the environment - } - } - } - expect( - await lock(octokit, context, ref, 123, null, environment, true) - ).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - status: 'details-only', - environment, - globalFlag, - global: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file and gets lock file data successfully - global lock', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) // fails the first time looking for a global lock - .mockReturnValueOnce({data: {content: lockBase64OctocatGlobal}}) // succeeds the second time looking for a 'local' lock for the environment - } - } - } - expect( - await lock(octokit, context, ref, 123, null, environment, true) - ).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: null, - global: true, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock --global' - }, - status: 'details-only', - environment, - globalFlag, - global: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file and gets lock file data successfully -- .wcid', async () => { - context.payload.comment.body = '.wcid' - - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) // fails the first time looking for a global lock - .mockReturnValueOnce({data: {content: lockBase64Octocat}}) // succeeds the second time looking for a 'local' lock for the environment - } - } - } - expect( - await lock(octokit, context, ref, 123, null, null, true) - ).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - status: 'details-only', - environment, - globalFlag, - global: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file and gets lock file data successfully -- .wcid --global', async () => { - context.payload.comment.body = '.wcid --global' - - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValueOnce({data: {content: lockBase64OctocatGlobal}}) // succeeds looking for a global lock - } - } - } - expect( - await lock(octokit, context, ref, 123, null, null, true) - ).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: null, - global: true, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock --global' - }, - status: 'details-only', - environment: null, - globalFlag, - global: true - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file and does not find a lock --global', async () => { - context.payload.comment.body = '.lock -i --global' - - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) // fails looking for a global lock - } - } - } - expect( - await lock(octokit, context, ref, 123, null, null, true) - ).toStrictEqual({ - lockData: null, - status: null, - environment: null, - globalFlag, - global: true - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file and gets lock file data successfully with --details flag', async () => { - context.payload.comment.body = '.lock --details' - - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('file not found')) // fails the first time looking for a global lock - .mockReturnValueOnce({data: {content: lockBase64Octocat}}) // succeeds the second time looking for a 'local' lock for the environment - } - } - } - expect( - await lock(octokit, context, ref, 123, null, null, true) - ).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - status: 'details-only', - globalFlag, - environment, - global: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file when the lock branch exists but no lock file exists', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValue(new NotFoundError('file not found')), - createOrUpdateFileContents: vi.fn().mockReturnValue({}) - }, - issues: { - createComment: vi.fn().mockReturnValue({}) - } - } - } - expect( - await lock(octokit, context, ref, 123, null, environment, true) - ).toStrictEqual(noLockFound) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file when no branch exists', async () => { - context.payload.comment.body = '.lock --details' - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockRejectedValueOnce(new NotFoundError('Reference does not exist')) - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - createOrUpdateFileContents: vi.fn().mockReturnValue({}), - getContent: vi - .fn() - .mockRejectedValue(new NotFoundError('file not found')) - }, - git: { - createRef: vi.fn().mockReturnValue({status: 201}) - }, - issues: { - createComment: vi.fn().mockReturnValue({}) - } - } - } - expect( - await lock(octokit, context, ref, 123, null, environment, true) - ).toStrictEqual(noLockFound) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) -}) - -test('Request detailsOnly on the lock file when no branch exists and hits an error when trying to check the branch', async () => { - context.payload.comment.body = '.lock --details' - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockRejectedValueOnce(new BigBadError('oh no - 500')), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - createOrUpdateFileContents: vi.fn().mockReturnValue({}), - getContent: vi - .fn() - .mockRejectedValue(new NotFoundError('file not found')) - } - } - } - try { - await lock(octokit, context, ref, 123, null, environment, true) - } catch (error) { - expect(errorMock).toHaveBeenCalledWith( - 'an unexpected status code was returned while checking for the lock branch' - ) - expect(error.message).toBe('Error: oh no - 500') - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - } -}) - -test('Determines that the lock request is coming from current owner of the lock and exits - non-sticky', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValue({data: {content: lockBase64Monalisa}}) - } - } - } - expect( - await lock(octokit, context, ref, 123, false, environment) - ).toStrictEqual(monalisaOwner) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith( - `โœ… ${COLORS.highlight}monalisa${COLORS.reset} initiated this request and is also the owner of the current lock` - ) -}) - -test('Determines that the lock request is coming from current owner of the lock and exits - sticky', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValue({data: {content: lockBase64Monalisa}}) - } - } - } - expect( - await lock(octokit, context, ref, 123, true, environment) - ).toStrictEqual(monalisaOwner) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith( - `โœ… ${COLORS.highlight}monalisa${COLORS.reset} initiated this request and is also the owner of the current lock` - ) -}) - -test('checks a lock and finds that it is from another owner and that no reason was set - it was a lock for the production environment', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValue({data: {content: lockBase64OctocatNoReason}}) - } - } - } - expect( - await lock(octokit, context, ref, 123, true, environment) - ).toStrictEqual({ - environment: 'production', - global: false, - globalFlag: '--global', - lockData: null, - status: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(debugMock).toHaveBeenCalledWith(`no reason detected`) - expect(debugMock).toHaveBeenCalledWith( - `the lock was not claimed as it is owned by octocat` - ) -}) - -test('checks a lock and finds that it is from another owner and that no reason was set - it was a lock for the production environment and sticky is set to false', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValue({data: {content: lockBase64OctocatNoReason}}) - } - } - } - expect( - await lock(octokit, context, ref, 123, false, environment) - ).toStrictEqual({ - environment: 'production', - global: false, - globalFlag: '--global', - lockData: null, - status: false - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(debugMock).toHaveBeenCalledWith(`no reason detected`) - expect(debugMock).toHaveBeenCalledWith( - `the lock was not claimed as it is owned by octocat` - ) -}) - -test('Determines that the lock request is coming from current owner of the lock (GLOBAL lock) and exits - sticky', async () => { - context.actor = 'octocat' - context.payload.comment.body = '.lock --global' - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockReturnValue({data: {content: lockBase64OctocatGlobal}}) - } - } - } - expect(await lock(octokit, context, ref, 123, true, null)).toStrictEqual({ - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - environment: null, - global: true, - unlock_command: '.unlock --global' - }, - status: 'owner', - global: true, - globalFlag: '--global', - environment: null - }) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith( - `โœ… ${COLORS.highlight}octocat${COLORS.reset} initiated this request and is also the owner of the current lock` - ) -}) - -test('fails to decode the lock file contents', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi.fn().mockReturnValue({data: {content: null}}) - } - } - } - try { - await lock(octokit, context, ref, 123, true, environment) - } catch (error) { - expect(error.message).toBe( - 'TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received null' - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - } -}) - -test('Creates a lock when the lock branch exists but no lock file exists', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi - .fn() - .mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), - get: vi.fn().mockReturnValue({data: {default_branch: 'main'}}), - getContent: vi - .fn() - .mockRejectedValue(new NotFoundError('file not found')), - createOrUpdateFileContents: vi.fn().mockReturnValue({}) - }, - issues: { - createComment: vi.fn().mockReturnValue({}) - } - } - } - expect( - await lock(octokit, context, ref, 123, false, environment) - ).toStrictEqual(createdLock) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') -}) - -test('successfully obtains a deployment lock (sticky) by creating the branch and lock file - with a --reason', async () => { - context.payload.comment.body = - '.lock --reason testing a super cool new feature' - expect( - await lock(octokit, context, ref, 123, true, environment) - ).toStrictEqual(createdLock) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` - ) -}) - -test('successfully obtains a deployment lock (sticky) by creating the branch and lock file - with an empty --reason', async () => { - context.payload.comment.body = '.lock --reason ' - expect( - await lock(octokit, context, ref, 123, true, environment) - ).toStrictEqual(createdLock) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` - ) -}) - -test('successfully obtains a deployment lock (sticky and global) by creating the branch and lock file', async () => { - context.payload.comment.body = '.lock --global' - createdLock.environment = null - createdLock.global = true - expect(await lock(octokit, context, ref, 123, true, null)).toStrictEqual( - createdLock - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐ŸŒŽ this is a request for a ${COLORS.highlight}global${COLORS.reset} deployment lock` - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}global-branch-deploy-lock` - ) -}) - -test('successfully obtains a deployment lock (sticky and global) by creating the branch and lock file with a --reason', async () => { - context.payload.comment.body = - '.lock --reason because something is broken --global' - createdLock.environment = null - createdLock.global = true - expect(await lock(octokit, context, ref, 123, true, null)).toStrictEqual( - createdLock - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) - expect(debugMock).toHaveBeenCalledWith('reason: because something is broken') - expect(infoMock).toHaveBeenCalledWith( - `๐ŸŒŽ this is a request for a ${COLORS.highlight}global${COLORS.reset} deployment lock` - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}global-branch-deploy-lock` - ) -}) - -test('successfully obtains a deployment lock (sticky and global) by creating the branch and lock file with a --reason at the end of the string', async () => { - context.payload.comment.body = - '.lock --global --reason because something is broken badly ' - createdLock.environment = null - createdLock.global = true - expect(await lock(octokit, context, ref, 123, true, null)).toStrictEqual( - createdLock - ) - expect(debugMock).toHaveBeenCalledWith( - 'reason: because something is broken badly' - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: null`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: true`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: global-branch-deploy-lock` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐ŸŒŽ this is a request for a ${COLORS.highlight}global${COLORS.reset} deployment lock` - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}global-branch-deploy-lock` - ) -}) - -test('successfully obtains a deployment lock (sticky) by creating the branch and lock file with a --reason at the end of the string', async () => { - context.payload.comment.body = - '.lock development --reason because something is broken badly ' - createdLock.environment = 'development' - expect(await lock(octokit, context, ref, 123, true, null)).toStrictEqual( - createdLock - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: development`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: development-branch-deploy-lock` - ) - expect(debugMock).toHaveBeenCalledWith( - 'reason: because something is broken badly' - ) - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}development-branch-deploy-lock` - ) -}) - -test('successfully obtains a deployment lock (sticky) by creating the branch and lock file with a --reason and assuming a null environment to start (but it is production)', async () => { - context.payload.comment.body = '.lock --reason because something is broken' - expect(await lock(octokit, context, ref, 123, true)).toStrictEqual( - createdLock - ) - expect(debugMock).toHaveBeenCalledWith(`detected lock env: ${environment}`) - expect(debugMock).toHaveBeenCalledWith(`detected lock global: false`) - expect(debugMock).toHaveBeenCalledWith( - `constructed lock branch name: ${environment}-branch-deploy-lock` - ) - expect(debugMock).toHaveBeenCalledWith('reason: because something is broken') - expect(infoMock).toHaveBeenCalledWith('โœ… deployment lock obtained') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` - ) -}) - -test('throws an error if an unhandled exception occurs', async () => { - const octokit = { - rest: { - repos: { - getBranch: vi.fn().mockRejectedValueOnce(new Error('oh no')), - getContent: vi.fn().mockRejectedValue(new Error('oh no')) - } - } - } - try { - await lock(octokit, context, ref, 123, true, environment) - } catch (e) { - expect(e.message).toBe('Error: oh no') - } -}) diff --git a/__tests__/functions/lock.test.ts b/__tests__/functions/lock.test.ts new file mode 100644 index 00000000..ea0e52be --- /dev/null +++ b/__tests__/functions/lock.test.ts @@ -0,0 +1,2161 @@ +import assert from 'node:assert/strict' +import {after, beforeEach, mock, test, type Mock} from 'node:test' +import type {InputOptions} from '../../src/actions-core.ts' +import type {LockOctokit, LockRequest} from '../../src/functions/lock.ts' +import {COLORS} from '../../src/functions/colors.ts' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import type {ActionStatusRequest} from '../../src/functions/action-status.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import { + assertCalledTimes, + assertCalledWith, + assertNotCalled, + createMock, + installModuleMock, + queueMockImplementation +} from '../node-test-helpers.ts' +import type { + IssueCommentContext, + LockData, + LockResponse +} from '../../src/types.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const debugMock = createMock(() => undefined) +const errorMock = createMock(() => undefined) +const infoMock = createMock(() => undefined) +const saveStateMock = createMock(() => undefined) +const setFailedMock = createMock(() => undefined) +const setOutputMock = createMock(() => undefined) +const actionStatusMock = createMock< + (request: ActionStatusRequest) => Promise +>(() => Promise.resolve()) + +function getInput(name: string, options?: InputOptions): string { + const value = + process.env[`INPUT_${name.replace(/ /gu, '_').toUpperCase()}`] ?? '' + if (options?.required === true && value === '') { + throw new Error(`Input required and not supplied: ${name}`) + } + return options?.trimWhitespace === false ? value : value.trim() +} + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: errorMock, + getInput, + info: infoMock, + saveState: saveStateMock, + setFailed: setFailedMock, + setOutput: setOutputMock +}) +installModuleMock( + mock, + new URL('../../src/functions/action-status.ts', import.meta.url), + {actionStatus: actionStatusMock} +) + +const {lock} = await import('../../src/functions/lock.ts') +const {checkLockFile, InvalidLockFileError} = + await import('../../src/functions/check-lock-file.ts') + +class NotFoundError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 404 + } +} + +class BigBadError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 500 + } +} + +class ConflictError extends Error { + declare status: number + + constructor(status: 409 | 422) { + super(`conflict ${status}`) + this.status = status + } +} + +const environment = 'production' +const globalFlag = '--global' + +const lockBase64Monalisa = + 'ewogICAgInJlYXNvbiI6IG51bGwsCiAgICAiYnJhbmNoIjogImNvb2wtbmV3LWZlYXR1cmUiLAogICAgImNyZWF0ZWRfYXQiOiAiMjAyMi0wNi0xNVQyMToxMjoxNC4wNDFaIiwKICAgICJjcmVhdGVkX2J5IjogIm1vbmFsaXNhIiwKICAgICJzdGlja3kiOiBmYWxzZSwKICAgICJlbnZpcm9ubWVudCI6ICJwcm9kdWN0aW9uIiwKICAgICJ1bmxvY2tfY29tbWFuZCI6ICIudW5sb2NrIHByb2R1Y3Rpb24iLAogICAgImdsb2JhbCI6IGZhbHNlLAogICAgImxpbmsiOiAiaHR0cHM6Ly9naXRodWIuY29tL3Rlc3Qtb3JnL3Rlc3QtcmVwby9wdWxsLzMjaXNzdWVjb21tZW50LTEyMyIKfQo=' + +const lockBase64Octocat = + 'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJlbnZpcm9ubWVudCI6ICJwcm9kdWN0aW9uIiwKICAgICJ1bmxvY2tfY29tbWFuZCI6ICIudW5sb2NrIHByb2R1Y3Rpb24iLAogICAgImdsb2JhbCI6IGZhbHNlLAogICAgImxpbmsiOiAiaHR0cHM6Ly9naXRodWIuY29tL3Rlc3Qtb3JnL3Rlc3QtcmVwby9wdWxsLzIjaXNzdWVjb21tZW50LTQ1NiIKfQo=' + +const lockBase64OctocatNoReason = + 'ewogICAgInJlYXNvbiI6IG51bGwsCiAgICAiYnJhbmNoIjogIm9jdG9jYXRzLWV2ZXJ5d2hlcmUiLAogICAgImNyZWF0ZWRfYXQiOiAiMjAyMi0wNi0xNFQyMToxMjoxNC4wNDFaIiwKICAgICJjcmVhdGVkX2J5IjogIm9jdG9jYXQiLAogICAgInN0aWNreSI6IHRydWUsCiAgICAiZW52aXJvbm1lbnQiOiAicHJvZHVjdGlvbiIsCiAgICAidW5sb2NrX2NvbW1hbmQiOiAiLnVubG9jayBwcm9kdWN0aW9uIiwKICAgICJnbG9iYWwiOiBmYWxzZSwKICAgICJsaW5rIjogImh0dHBzOi8vZ2l0aHViLmNvbS90ZXN0LW9yZy90ZXN0LXJlcG8vcHVsbC8yI2lzc3VlY29tbWVudC00NTYiCn0K' + +const lockBase64OctocatGlobal = + 'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJlbnZpcm9ubWVudCI6IG51bGwsCiAgICAidW5sb2NrX2NvbW1hbmQiOiAiLnVubG9jayAtLWdsb2JhbCIsCiAgICAiZ2xvYmFsIjogdHJ1ZSwKICAgICJsaW5rIjogImh0dHBzOi8vZ2l0aHViLmNvbS90ZXN0LW9yZy90ZXN0LXJlcG8vcHVsbC8yI2lzc3VlY29tbWVudC00NTYiCn0K' + +const validLockRecord: Readonly> = { + reason: null, + branch: 'feature', + created_at: '2026-06-30T12:34:56.789Z', + created_by: 'monalisa', + sticky: true, + environment: 'production', + global: false, + unlock_command: '.unlock production', + link: 'https://github.example/corp/test/pull/1#issuecomment-123' +} + +function omitLockField(field: string): Readonly> { + return Object.fromEntries( + Object.entries(validLockRecord).filter(([name]) => name !== field) + ) +} + +function encodeLockValue(value: unknown): string { + const serialized = JSON.stringify(value) + if (serialized === undefined) { + throw new Error('test lock value is not serializable') + } + return Buffer.from(serialized).toString('base64') +} + +interface LockOctokitOverrides { + readonly git?: Partial + readonly globalBranchExists?: boolean + readonly issues?: Partial + readonly reactions?: Partial + readonly repos?: Partial +} + +type GetBranch = LockOctokit['rest']['repos']['getBranch'] +type GetBranchResult = Awaited> +type GetContent = LockOctokit['rest']['repos']['getContent'] +type GetContentResult = Awaited> + +function mockGetBranch( + ...outcomes: readonly (GetBranchResult | Error)[] +): Mock { + let call = 0 + return createMock(() => { + const outcome = outcomes[Math.min(call++, outcomes.length - 1)] + if (outcome instanceof Error) { + return Promise.reject(outcome) + } + return Promise.resolve( + outcome ?? { + data: { + commit: {sha: 'abc123', commit: {tree: {sha: 'base-tree-sha'}}} + } + } + ) + }) +} + +function mockGetContent( + ...outcomes: readonly (GetContentResult | Error)[] +): Mock { + let call = 0 + return createMock(() => { + const outcome = outcomes[Math.min(call++, outcomes.length - 1)] + if (outcome instanceof Error) { + return Promise.reject(outcome) + } + return Promise.resolve(outcome ?? {data: undefined}) + }) +} + +function createLockOctokit(overrides: LockOctokitOverrides = {}): LockOctokit { + const {getBranch: getBranchOverride, ...reposOverrides} = + overrides.repos ?? {} + const targetGetBranch = + getBranchOverride ?? + createMock(() => + Promise.resolve({ + data: { + commit: {sha: 'abc123', commit: {tree: {sha: 'base-tree-sha'}}} + } + }) + ) + + return { + rest: { + git: { + createBlob: createMock(() => + Promise.resolve({data: {sha: 'blob-sha'}}) + ), + createCommit: createMock( + () => Promise.resolve({data: {sha: 'commit-sha'}}) + ), + createRef: createMock(() => + Promise.resolve(undefined) + ), + createTree: createMock(() => + Promise.resolve({data: {sha: 'tree-sha'}}) + ), + ...overrides.git + }, + issues: { + createComment: createMock< + LockOctokit['rest']['issues']['createComment'] + >(() => Promise.resolve(undefined)), + ...overrides.issues + }, + reactions: { + createForIssueComment: createMock< + LockOctokit['rest']['reactions']['createForIssueComment'] + >(() => Promise.resolve(undefined)), + deleteForIssueComment: createMock< + LockOctokit['rest']['reactions']['deleteForIssueComment'] + >(() => Promise.resolve(undefined)), + ...overrides.reactions + }, + repos: { + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getBranch: createMock( + parameters => + parameters?.branch === 'global-branch-deploy-lock' && + overrides.globalBranchExists !== true + ? Promise.reject(new NotFoundError('Reference does not exist')) + : targetGetBranch(parameters) + ), + getContent: createMock(() => + Promise.resolve({data: {content: lockBase64Octocat}}) + ), + ...reposOverrides + } + } + } satisfies LockOctokit +} + +function contextFor( + body: string, + actor = 'monalisa', + commentId = 123 +): IssueCommentContext { + return createIssueCommentContext({ + actor, + issue: {number: 1}, + payload: {comment: {body, id: commentId}}, + repo: {owner: 'corp', repo: 'test'} + }) +} + +let context: IssueCommentContext +let octokit: LockOctokit +let octokitOtherUserHasLock: LockOctokit +let createdLock: LockResponse +let monalisaOwner: LockResponse +let noLockFound: LockResponse +let failedToCreateLock: LockResponse +let ambiguousLock: LockResponse +let createBlobMock: Mock +let createCommitMock: Mock +let createRefMock: Mock +let createTreeMock: Mock + +function lockRequest(overrides: Partial = {}): LockRequest { + return { + context, + environment, + leaveComment: true, + mode: {postDeployStep: false, type: 'acquire'}, + octokit, + reactionId: 123, + ref: 'cool-new-feature', + sticky: false, + ...overrides + } +} + +function latestActionStatusRequest(): ActionStatusRequest { + const call = actionStatusMock.mock.calls.at(-1) + assert.ok(call !== undefined, 'expected actionStatus to have been called') + return call.arguments[0] +} + +function assertSetFailedMatches(pattern: RegExp): void { + assert.ok( + setFailedMock.mock.calls.some(call => { + const message = call.arguments[0] + return typeof message === 'string' && pattern.test(message) + }), + `expected setFailed to have been called with ${String(pattern)}` + ) +} + +const inputEnvironment = { + GITHUB_SERVER_URL: 'https://github.example', + INPUT_ENVIRONMENT: 'production', + INPUT_GLOBAL_LOCK_FLAG: '--global', + INPUT_LOCK_INFO_ALIAS: '.wcid', + INPUT_LOCK_TRIGGER: '.lock', + INPUT_UNLOCK_TRIGGER: '.unlock' +} as const +const originalInputEnvironment = new Map( + Object.keys(inputEnvironment).map(name => [name, process.env[name]]) +) + +after(() => { + for (const [name, value] of originalInputEnvironment) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } +}) + +beforeEach(() => { + for (const mockFunction of [ + actionStatusMock, + debugMock, + errorMock, + infoMock, + saveStateMock, + setFailedMock, + setOutputMock + ]) { + mockFunction.mock.resetCalls() + } + actionStatusMock.mock.mockImplementation(() => Promise.resolve()) + + for (const [name, value] of Object.entries(inputEnvironment)) { + process.env[name] = value + } + + createdLock = { + lockData: null, + status: true, + globalFlag, + environment, + global: false, + lockRefSha: 'commit-sha' + } satisfies LockResponse + monalisaOwner = { + lockData: { + branch: 'cool-new-feature', + created_at: '2022-06-15T21:12:14.041Z', + created_by: 'monalisa', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/3#issuecomment-123', + reason: null, + sticky: false, + unlock_command: '.unlock production' + }, + status: 'owner', + globalFlag, + environment, + global: false, + lockRefSha: 'abc123' + } satisfies LockResponse + noLockFound = { + lockData: null, + status: null, + globalFlag, + environment, + global: false + } satisfies LockResponse + failedToCreateLock = { + lockData: null, + status: false, + globalFlag, + environment, + global: false + } satisfies LockResponse + ambiguousLock = { + lockData: null, + status: 'ambiguous', + globalFlag, + environment, + global: false + } satisfies LockResponse + + context = contextFor('.lock') + createBlobMock = createMock(() => + Promise.resolve({data: {sha: 'blob-sha'}}) + ) + createCommitMock = createMock( + () => Promise.resolve({data: {sha: 'commit-sha'}}) + ) + createRefMock = createMock(() => + Promise.resolve({status: 201}) + ) + createTreeMock = createMock(() => + Promise.resolve({data: {sha: 'tree-sha'}}) + ) + const getBranch = createMock( + parameters => + parameters?.branch === 'main' + ? Promise.resolve({ + data: { + commit: { + sha: 'abc123', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }) + : Promise.reject(new NotFoundError('Reference does not exist')) + ) + octokit = createLockOctokit({ + repos: { + getBranch, + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: createMock(() => + Promise.reject(new NotFoundError('file not found')) + ) + }, + git: { + createBlob: createBlobMock, + createCommit: createCommitMock, + createRef: createRefMock, + createTree: createTreeMock + }, + issues: { + createComment: createMock( + () => Promise.resolve({}) + ) + } + }) + + const otherUserGetContent = createMock< + LockOctokit['rest']['repos']['getContent'] + >(() => Promise.resolve({data: {content: lockBase64Octocat}})) + queueMockImplementation(otherUserGetContent, () => + Promise.resolve({data: {content: lockBase64Octocat}}) + ) + octokitOtherUserHasLock = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: otherUserGetContent + } + }) +}) + +for (const field of [ + 'reason', + 'branch', + 'created_at', + 'created_by', + 'sticky', + 'environment', + 'global', + 'unlock_command', + 'link' +] as const) { + test(`rejects lock data without ${field}`, async () => { + const octokit = createLockOctokit({ + repos: { + getContent: mockGetContent({ + data: {content: encodeLockValue(omitLockField(field))} + }) + } + }) + + await assert.rejects( + checkLockFile(octokit, context, 'production-branch-deploy-lock'), + InvalidLockFileError + ) + }) +} + +for (const [name, value] of [ + ['a primitive', 'invalid'], + ['a numeric branch', {...validLockRecord, branch: 42}], + ['a numeric created_at', {...validLockRecord, created_at: 42}], + ['a numeric created_by', {...validLockRecord, created_by: 42}], + ['a string sticky value', {...validLockRecord, sticky: 'true'}], + ['a numeric environment', {...validLockRecord, environment: 42}], + ['a string global value', {...validLockRecord, global: 'false'}], + ['a numeric unlock command', {...validLockRecord, unlock_command: 42}], + ['a numeric link', {...validLockRecord, link: 42}], + ['an unsupported schema version', {...validLockRecord, schema_version: 2}], + ['a non-string claim ID', {...validLockRecord, claim_id: 42}], + ['a malformed claim ID', {...validLockRecord, claim_id: 'sha256:nope'}] +] as const) { + test(`rejects lock data containing ${name}`, async () => { + const octokit = createLockOctokit({ + repos: { + getContent: mockGetContent({ + data: {content: encodeLockValue(value)} + }) + } + }) + + await assert.rejects( + checkLockFile(octokit, context, 'production-branch-deploy-lock'), + InvalidLockFileError + ) + }) +} + +test('accepts nullable legacy lock fields, schema version, and a valid optional claim ID', async () => { + const value = { + ...validLockRecord, + branch: null, + schema_version: 1, + sticky: null, + environment: null, + global: true, + claim_id: `sha256:${'a'.repeat(64)}` + } + const octokit = createLockOctokit({ + repos: { + getContent: mockGetContent({data: {content: encodeLockValue(value)}}) + } + }) + + assert.deepStrictEqual( + await checkLockFile(octokit, context, 'global-branch-deploy-lock'), + value + ) +}) + +test('atomically publishes a complete non-sticky deployment lock', async testContext => { + testContext.mock.timers.enable({ + apis: ['Date'], + now: new Date('2026-06-30T12:34:56.789Z') + }) + assert.deepStrictEqual(await lock(lockRequest()), createdLock) + const lockContents = JSON.stringify({ + schema_version: 1, + reason: 'deployment', + branch: 'cool-new-feature', + created_at: '2026-06-30T12:34:56.789Z', + created_by: 'monalisa', + sticky: false, + environment: 'production', + global: false, + unlock_command: '.unlock production', + link: 'https://github.example/corp/test/pull/1#issuecomment-123', + claim_id: + 'sha256:4be9269547aac9128baf1133938d776d68dacb7a7d7f5083fbcd00fee23d32ca' + }) + assert.deepStrictEqual(createBlobMock.mock.calls[0]?.arguments[0], { + owner: 'corp', + repo: 'test', + content: lockContents, + encoding: 'utf-8', + headers: API_HEADERS + }) + assertCalledWith(createTreeMock, { + owner: 'corp', + repo: 'test', + base_tree: 'base-tree-sha', + tree: [{path: 'lock.json', mode: '100644', type: 'blob', sha: 'blob-sha'}], + headers: API_HEADERS + }) + assertCalledWith(createCommitMock, { + owner: 'corp', + repo: 'test', + message: 'lock [skip ci]', + tree: 'tree-sha', + parents: ['abc123'], + headers: API_HEADERS + }) + assertCalledWith(createRefMock, { + owner: 'corp', + repo: 'test', + ref: 'refs/heads/production-branch-deploy-lock', + sha: 'commit-sha', + headers: API_HEADERS + }) + assertCalledWith(saveStateMock, 'lock_ref_sha', 'commit-sha') + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +for (const status of [409, 422] as const) { + test(`classifies the complete winning lock after a ${status} ref race`, async () => { + const createRef = createMock(() => + Promise.reject(new ConflictError(status)) + ) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch( + new NotFoundError('Reference does not exist'), + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }, + {data: {commit: {sha: 'winning-lock-sha'}}} + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: lockBase64Octocat} + }) + } + }) + + assert.deepStrictEqual(await lock(lockRequest({octokit})), { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + status: false, + globalFlag, + environment, + global: false + }) + assertCalledTimes(createRef, 1) + assertSetFailedMatches(/currently claimed by __octocat__/u) + }) +} + +test('allows one concurrent acquisition and classifies the losing contender', async () => { + const winningLock = { + reason: 'deployment', + branch: 'cool-new-feature', + created_at: '2026-06-30T12:34:56.789Z', + created_by: 'monalisa', + sticky: false, + environment: 'production', + global: false, + unlock_command: '.unlock production', + link: 'https://github.example/corp/test/pull/1#issuecomment-123', + claim_id: + 'sha256:4be9269547aac9128baf1133938d776d68dacb7a7d7f5083fbcd00fee23d32ca' + } satisfies LockData + let refCreated = false + const createRef = createMock(() => { + if (refCreated) { + return Promise.reject(new ConflictError(422)) + } + refCreated = true + return Promise.resolve({status: 201}) + }) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch( + new NotFoundError('Reference does not exist'), + new NotFoundError('Reference does not exist'), + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }, + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }, + {data: {commit: {sha: 'winning-lock-sha'}}} + ), + getContent: mockGetContent( + new NotFoundError('file not found'), + new NotFoundError('file not found'), + { + data: { + content: Buffer.from(JSON.stringify(winningLock)).toString('base64') + } + } + ) + } + }) + const winnerContext = contextFor('.deploy') + const loserContext = contextFor('.deploy', 'octocat', 456) + + const [winner, loser] = await Promise.all([ + lock(lockRequest({context: winnerContext, octokit})), + lock(lockRequest({context: loserContext, octokit})) + ]) + + assert.deepStrictEqual(winner, createdLock) + assert.deepStrictEqual(loser, { + lockData: winningLock, + status: false, + globalFlag, + environment, + global: false + }) + assertCalledTimes(createRef, 2) + assertSetFailedMatches(/currently claimed by __monalisa__/u) +}) + +for (const status of [409, 422] as const) { + test(`rethrows the original ${status} when no competing ref exists`, async () => { + const conflict = new ConflictError(status) + const createRef = createMock(() => + Promise.reject(conflict) + ) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch( + new NotFoundError('Reference does not exist'), + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }, + new NotFoundError('Reference does not exist') + ), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + + await assert.rejects( + lock(lockRequest({octokit})), + error => error === conflict + ) + assertNotCalled(actionStatusMock) + }) +} + +for (const [name, finalContent] of [ + ['has no lock file', new NotFoundError('file not found')], + ['has invalid lock data', {data: {content: null}}] +] as const) { + test(`fails closed when a winning ref ${name}`, async () => { + const createRef = createMock(() => + Promise.reject(new ConflictError(422)) + ) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch( + new NotFoundError('Reference does not exist'), + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }, + {data: {commit: {sha: 'winning-lock-sha'}}} + ), + getContent: mockGetContent( + new NotFoundError('file not found'), + finalContent + ) + } + }) + + assert.deepStrictEqual(await lock(lockRequest({octokit})), ambiguousLock) + assertSetFailedMatches(/Cannot process deployment lock/u) + }) +} + +test('rethrows an API failure while reading the winning lock', async () => { + const createRef = createMock(() => + Promise.reject(new ConflictError(422)) + ) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch( + new NotFoundError('Reference does not exist'), + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }, + {data: {commit: {sha: 'winning-lock-sha'}}} + ), + getContent: mockGetContent( + new NotFoundError('file not found'), + new BigBadError('winner read failed') + ) + } + }) + + await assert.rejects(lock(lockRequest({octokit})), /winner read failed/u) +}) + +test('rethrows a non-conflict ref creation error without reporting success', async () => { + const error = new BigBadError('ref failed') + const createRef = createMock(() => + Promise.reject(error) + ) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch(new NotFoundError('Reference does not exist'), { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + }), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + + await assert.rejects( + lock(lockRequest({octokit})), + candidate => candidate === error + ) + assert.ok( + !infoMock.mock.calls.some( + call => call.arguments[0] === 'โœ… deployment lock obtained' + ) + ) +}) + +test('rejects a default-branch response without a tree SHA', async () => { + const createRef = createMock(() => + Promise.resolve({status: 201}) + ) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch(new NotFoundError('Reference does not exist'), { + data: {commit: {sha: 'base-commit-sha'}} + }), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + + await assert.rejects( + lock(lockRequest({octokit})), + /default branch response did not include a tree SHA/u + ) + assertNotCalled(createRef) +}) + +test('treats a rerun of the same claim as idempotently acquired', async () => { + const lockData = { + reason: 'deployment', + branch: 'cool-new-feature', + created_at: '2026-06-30T12:34:56.789Z', + created_by: 'monalisa', + sticky: false, + environment: 'production', + global: false, + unlock_command: '.unlock production', + link: 'https://github.example/corp/test/pull/1#issuecomment-123', + claim_id: + 'sha256:4be9269547aac9128baf1133938d776d68dacb7a7d7f5083fbcd00fee23d32ca' + } satisfies LockData + const createRef = createMock(() => + Promise.resolve({status: 201}) + ) + const getContent = mockGetContent(new NotFoundError('file not found'), { + data: { + content: Buffer.from(JSON.stringify(lockData)).toString('base64') + } + }) + const octokit = createLockOctokit({ + git: {createRef}, + repos: { + getBranch: mockGetBranch({data: {commit: {sha: 'lock-sha'}}}), + getContent + } + }) + + assert.deepStrictEqual(await lock(lockRequest({octokit})), { + lockData, + status: 'owner', + globalFlag, + environment, + global: false, + lockRefSha: 'lock-sha' + }) + assertNotCalled(createRef) + assertNotCalled(actionStatusMock) + assertCalledWith( + infoMock, + 'โœ… this deployment lock claim was already acquired' + ) + assertCalledWith(saveStateMock, 'lock_ref_sha', 'lock-sha') + assertCalledWith(getContent, { + owner: 'corp', + repo: 'test', + path: 'lock.json', + ref: 'lock-sha', + headers: API_HEADERS + }) +}) + +test('uses normal owner handling when the same owner makes a different claim', async () => { + const lockData = { + branch: 'cool-new-feature', + claim_id: `sha256:${'a'.repeat(64)}`, + created_at: new Date().toISOString(), + created_by: 'monalisa', + environment: 'production', + global: false, + link: 'https://github.example/corp/test/pull/1#issuecomment-122', + reason: 'deployment', + sticky: false, + unlock_command: '.unlock production' + } satisfies LockData + const octokit = createLockOctokit({ + repos: { + getBranch: mockGetBranch({data: {commit: {sha: 'lock-sha'}}}), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: { + content: Buffer.from(JSON.stringify(lockData)).toString('base64') + } + }) + } + }) + + assert.deepStrictEqual(await lock(lockRequest({octokit})), { + lockData, + status: 'owner', + globalFlag, + environment, + global: false, + lockRefSha: 'lock-sha' + }) + assertCalledWith( + infoMock, + `โœ… ${COLORS.highlight}monalisa${COLORS.reset} initiated this request and is also the owner of the current lock` + ) + assertCalledWith(saveStateMock, 'lock_ref_sha', 'lock-sha') +}) + +for (const failurePoint of ['blob', 'tree', 'commit'] as const) { + test(`does not publish a ref when ${failurePoint} creation fails`, async () => { + const failure = new Error(`${failurePoint} failed`) + const createBlob = createMock( + () => + failurePoint === 'blob' + ? Promise.reject(failure) + : Promise.resolve({data: {sha: 'blob-sha'}}) + ) + const createTree = createMock( + () => + failurePoint === 'tree' + ? Promise.reject(failure) + : Promise.resolve({data: {sha: 'tree-sha'}}) + ) + const createCommit = createMock( + () => + failurePoint === 'commit' + ? Promise.reject(failure) + : Promise.resolve({data: {sha: 'commit-sha'}}) + ) + const createRef = createMock(() => + Promise.resolve({status: 201}) + ) + const octokit = createLockOctokit({ + git: {createBlob, createCommit, createRef, createTree}, + repos: { + getBranch: mockGetBranch( + new NotFoundError('Reference does not exist'), + { + data: { + commit: { + sha: 'base-commit-sha', + commit: {tree: {sha: 'base-tree-sha'}} + } + } + } + ), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + + await assert.rejects( + lock(lockRequest({octokit})), + error => error === failure + ) + assertNotCalled(createRef) + assert.ok( + !infoMock.mock.calls.some( + call => call.arguments[0] === 'โœ… deployment lock obtained' + ) + ) + }) +} + +test('Determines that another user has the lock (GLOBAL) and exits - during a lock claim on deployment', async () => { + assert.deepStrictEqual( + await lock(lockRequest({octokit: octokitOtherUserHasLock})), + failedToCreateLock + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + const request = latestActionStatusRequest() + assert.strictEqual(request.context, context) + assert.strictEqual(request.octokit, octokitOtherUserHasLock) + assert.strictEqual(request.reactionId, 123) + assert.match( + request.message, + /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ + ) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertSetFailedMatches( + /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ + ) +}) + +test('Determines that another user has the lock (non-global) and exits - during a lock claim on deployment', async () => { + assert.deepStrictEqual( + await lock(lockRequest({octokit: octokitOtherUserHasLock})), + failedToCreateLock + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + const request = latestActionStatusRequest() + assert.strictEqual(request.context, context) + assert.strictEqual(request.octokit, octokitOtherUserHasLock) + assert.strictEqual(request.reactionId, 123) + assert.match( + request.message, + /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ + ) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertSetFailedMatches( + /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ + ) +}) + +test('fails closed when lock JSON contains an invalid global field', async () => { + const malformedLockData = { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: 'false', + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: null, + sticky: true, + unlock_command: '.unlock production' + } + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: { + content: Buffer.from(JSON.stringify(malformedLockData)).toString( + 'base64' + ) + } + }) + } + }) + + const result = await lock(lockRequest({octokit})) + assert.strictEqual(result.status, 'ambiguous') + const message = latestActionStatusRequest().message + assert.ok(message.includes('does not contain a readable `lock.json`')) + assert.ok(message.includes('`.unlock production`')) +}) + +test('Determines that another user has the lock (GLOBAL) and exits - during a direct lock claim with .lock --global', async () => { + context = contextFor('.lock --global') + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({data: {content: lockBase64OctocatGlobal}}) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({context, environment: null, octokit, sticky: true}) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: null, + global: true, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock --global' + }, + status: false, + globalFlag, + environment: null, + global: true + } + ) + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) + const request = latestActionStatusRequest() + assert.strictEqual(request.context, context) + assert.strictEqual(request.octokit, octokit) + assert.strictEqual(request.reactionId, 123) + assert.match( + request.message, + /Sorry __monalisa__, the `global` deployment lock is currently claimed by __octocat__/ + ) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertSetFailedMatches(/Cannot claim deployment lock/) + assert.ok( + request.message.includes( + '- __Reason__:\n\n Testing my new feature with lots of cats' + ) + ) +}) + +test('Determines that another user has the lock (non-global) and exits - during a direct lock claim with .lock', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: lockBase64Octocat} + }) + } + }) + assert.deepStrictEqual(await lock(lockRequest({octokit, sticky: true})), { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + status: false, + globalFlag, + environment, + global: false + }) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + const request = latestActionStatusRequest() + assert.strictEqual(request.context, context) + assert.strictEqual(request.octokit, octokit) + assert.strictEqual(request.reactionId, 123) + assert.match( + request.message, + /Sorry __monalisa__, the `production` environment deployment lock is currently claimed by __octocat__/ + ) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertSetFailedMatches(/Cannot claim deployment lock/) +}) + +test('renders a stored multiline lock reason as inert code when another user encounters the lock', async () => { + const collisionEnvironment = '__BRANCH_DEPLOY_LOCK_REASON__' + const injectedReason = + 'routine `\n\n## Deployment approved\n[continue](https://example.com)' + const lockData = { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: collisionEnvironment, + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: injectedReason, + sticky: true, + unlock_command: `.unlock ${collisionEnvironment}` + } satisfies LockData + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: { + content: Buffer.from(JSON.stringify(lockData)).toString('base64') + } + }) + } + }) + + const result = await lock( + lockRequest({environment: collisionEnvironment, octokit}) + ) + assert.strictEqual(result.status, false) + assert.deepStrictEqual(result.lockData, lockData) + + const comment = latestActionStatusRequest().message + assert.ok( + comment.includes( + '- __Reason__:\n\n routine `\n \n ## Deployment approved\n [continue](https://example.com)\n\n- __Environment__: `__BRANCH_DEPLOY_LOCK_REASON__`' + ) + ) + assert.ok(!comment.includes('\n## Deployment approved')) + assert.ok(!comment.includes('\n[continue](https://example.com)')) +}) + +test('Request detailsOnly on the lock file and gets lock file data successfully', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + // The global lookup fails before the environment lock is found. + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: lockBase64Octocat} + }) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + status: 'details-only', + environment, + globalFlag, + global: false + } + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('Request detailsOnly on the lock file and gets lock file data successfully - global lock', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: lockBase64OctocatGlobal} + }) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: null, + global: true, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock --global' + }, + status: 'details-only', + environment, + globalFlag, + global: false + } + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('Request detailsOnly on the lock file and gets lock file data successfully -- .wcid', async () => { + context = contextFor('.wcid') + + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: lockBase64Octocat} + }) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + context, + environment: null, + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + status: 'details-only', + environment, + globalFlag, + global: false + } + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('Request detailsOnly on the lock file and gets lock file data successfully -- .wcid --global', async () => { + context = contextFor('.wcid --global') + + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({ + data: {content: lockBase64OctocatGlobal} + }) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + context, + environment: null, + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: null, + global: true, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock --global' + }, + status: 'details-only', + environment: null, + globalFlag, + global: true + } + ) + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) +}) + +test('Request detailsOnly on the lock file and does not find a lock --global', async () => { + context = contextFor('.lock -i --global') + + const octokit = createLockOctokit({ + repos: { + getBranch: mockGetBranch(new NotFoundError('Reference does not exist')), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + context, + environment: null, + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: null, + status: null, + environment: null, + globalFlag, + global: true + } + ) + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) +}) + +test('Request detailsOnly on the lock file and gets lock file data successfully with --details flag', async () => { + context = contextFor('.lock --details') + + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: lockBase64Octocat} + }) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + context, + environment: null, + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + status: 'details-only', + globalFlag, + environment, + global: false + } + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('fails closed when a details request finds a lock branch without a lock file', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found')) + }, + issues: { + createComment: createMock( + () => Promise.resolve({}) + ) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + ambiguousLock + ) + assert.match( + latestActionStatusRequest().message, + /exists but does not contain a readable `lock\.json`/u + ) + assertSetFailedMatches(/Cannot process deployment lock/u) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('fails closed when the global lock branch exists without a lock file', async () => { + const createRef = createMock(() => + Promise.resolve({status: 201}) + ) + const octokit = createLockOctokit({ + globalBranchExists: true, + git: {createRef}, + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'global-lock-sha'}}}) + ), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + + assert.deepStrictEqual(await lock(lockRequest({octokit})), { + lockData: null, + status: 'ambiguous', + environment: null, + globalFlag, + global: true + }) + assertNotCalled(createRef) + assert.match( + latestActionStatusRequest().message, + /global-branch-deploy-lock.*does not contain a readable `lock\.json`/u + ) + assertSetFailedMatches(/Cannot process deployment lock/u) +}) + +test('fails closed when the global lock file contains a non-object value', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent( + {data: {content: Buffer.from('null').toString('base64')}}, + new NotFoundError('file not found') + ) + } + }) + + assert.deepStrictEqual( + await lock( + lockRequest({ + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + { + lockData: null, + status: 'ambiguous', + environment: null, + globalFlag, + global: true + } + ) + assertSetFailedMatches(/Cannot process deployment lock/u) +}) + +test('fails closed when the global lock file cannot be decoded', async () => { + const octokit = createLockOctokit({ + repos: {getContent: mockGetContent({data: {content: null}})} + }) + + assert.deepStrictEqual(await lock(lockRequest({octokit})), { + lockData: null, + status: 'ambiguous', + environment: null, + globalFlag, + global: true + }) + assertSetFailedMatches(/Cannot process deployment lock/u) +}) + +test('Request detailsOnly on the lock file when no branch exists', async () => { + context = contextFor('.lock --details') + const octokit = createLockOctokit({ + repos: { + getBranch: mockGetBranch(new NotFoundError('Reference does not exist'), { + data: {commit: {sha: 'abc123'}} + }), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found')) + }, + git: { + createRef: createMock(() => + Promise.resolve({status: 201}) + ) + }, + issues: { + createComment: createMock( + () => Promise.resolve({}) + ) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({ + context, + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + noLockFound + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('Request detailsOnly on the lock file when no branch exists and hits an error when trying to check the branch', async () => { + context = contextFor('.lock --details') + const octokit = createLockOctokit({ + repos: { + getBranch: mockGetBranch(new BigBadError('oh no - 500')), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found')) + } + }) + + await assert.rejects( + lock( + lockRequest({ + context, + mode: {postDeployStep: false, type: 'details'}, + octokit, + sticky: null + }) + ), + /Error: oh no - 500/u + ) + assertCalledWith( + errorMock, + 'an unexpected status code was returned while checking for the lock branch' + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('Determines that the lock request is coming from current owner of the lock and exits - non-sticky', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({data: {content: lockBase64Monalisa}}) + } + }) + assert.deepStrictEqual(await lock(lockRequest({octokit})), monalisaOwner) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith( + infoMock, + `โœ… ${COLORS.highlight}monalisa${COLORS.reset} initiated this request and is also the owner of the current lock` + ) +}) + +test('Determines that the lock request is coming from current owner of the lock and exits - sticky', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({data: {content: lockBase64Monalisa}}) + } + }) + const {lockRefSha, ...stickyOwner} = monalisaOwner + assert.strictEqual(lockRefSha, 'abc123') + assert.deepStrictEqual( + await lock(lockRequest({octokit, sticky: true})), + stickyOwner + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith( + infoMock, + `โœ… ${COLORS.highlight}monalisa${COLORS.reset} initiated this request and is also the owner of the current lock` + ) +}) + +test('checks a lock and finds that it is from another owner and that no reason was set - it was a lock for the production environment', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({ + data: {content: lockBase64OctocatNoReason} + }) + } + }) + assert.deepStrictEqual(await lock(lockRequest({octokit, sticky: true})), { + environment: 'production', + global: false, + globalFlag: '--global', + lockData: null, + status: false + }) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith(debugMock, `no reason detected`) + assertCalledWith( + debugMock, + `the lock was not claimed as it is owned by octocat` + ) +}) + +test('checks a lock and finds that it is from another owner and that no reason was set - it was a lock for the production environment and sticky is set to false', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({ + data: {content: lockBase64OctocatNoReason} + }) + } + }) + assert.deepStrictEqual(await lock(lockRequest({octokit})), { + environment: 'production', + global: false, + globalFlag: '--global', + lockData: null, + status: false + }) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith(debugMock, `no reason detected`) + assertCalledWith( + debugMock, + `the lock was not claimed as it is owned by octocat` + ) +}) + +test('Determines that the lock request is coming from current owner of the lock (GLOBAL lock) and exits - sticky', async () => { + context = contextFor('.lock --global', 'octocat') + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent({data: {content: lockBase64OctocatGlobal}}) + } + }) + assert.deepStrictEqual( + await lock( + lockRequest({context, environment: null, octokit, sticky: true}) + ), + { + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + environment: null, + global: true, + unlock_command: '.unlock --global' + }, + status: 'owner', + global: true, + globalFlag: '--global', + environment: null + } + ) + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) + assertCalledWith( + infoMock, + `โœ… ${COLORS.highlight}octocat${COLORS.reset} initiated this request and is also the owner of the current lock` + ) +}) + +test('fails closed when the lock file cannot be decoded', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found'), { + data: {content: null} + }) + } + }) + + assert.deepStrictEqual( + await lock(lockRequest({octokit, sticky: true})), + ambiguousLock + ) + assertSetFailedMatches(/Cannot process deployment lock/u) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) +}) + +test('rethrows an API failure while reading an existing lock branch', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: mockGetBranch({data: {commit: {sha: 'lock-sha'}}}), + getContent: mockGetContent( + new NotFoundError('file not found'), + new BigBadError('lock read failed') + ) + } + }) + + await assert.rejects(lock(lockRequest({octokit})), /lock read failed/u) +}) + +test('does not repair a lock branch that exists without a lock file', async () => { + const createRef = createMock(() => + Promise.resolve({status: 201}) + ) + const octokit = createLockOctokit({ + repos: { + getBranch: createMock(() => + Promise.resolve({data: {commit: {sha: 'abc123'}}}) + ), + get: createMock(() => + Promise.resolve({data: {default_branch: 'main'}}) + ), + getContent: mockGetContent(new NotFoundError('file not found')) + }, + issues: { + createComment: createMock( + () => Promise.resolve({}) + ) + }, + git: {createRef} + }) + assert.deepStrictEqual(await lock(lockRequest({octokit})), ambiguousLock) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertNotCalled(createRef) + assertSetFailedMatches(/Cannot process deployment lock/u) +}) + +test('successfully obtains a deployment lock (sticky) by creating the branch and lock file - with a --reason', async () => { + context = contextFor('.lock --reason testing a super cool new feature') + assert.deepStrictEqual( + await lock(lockRequest({context, sticky: true})), + createdLock + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` + ) +}) + +test('successfully obtains a deployment lock (sticky) by creating the branch and lock file - with an empty --reason', async () => { + context = contextFor('.lock --reason ') + assert.deepStrictEqual( + await lock(lockRequest({context, sticky: true})), + createdLock + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` + ) +}) + +test('successfully obtains a deployment lock (sticky and global) by creating the branch and lock file', async () => { + context = contextFor('.lock --global') + assert.deepStrictEqual( + await lock(lockRequest({context, environment: null, sticky: true})), + {...createdLock, environment: null, global: true} + ) + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) + assertCalledWith( + infoMock, + `๐ŸŒŽ this is a request for a ${COLORS.highlight}global${COLORS.reset} deployment lock` + ) + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}global-branch-deploy-lock` + ) +}) + +test('successfully obtains a deployment lock (sticky and global) by creating the branch and lock file with a --reason', async () => { + context = contextFor('.lock --reason because something is broken --global') + assert.deepStrictEqual( + await lock(lockRequest({context, environment: null, sticky: true})), + {...createdLock, environment: null, global: true} + ) + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) + assertCalledWith(debugMock, 'reason: because something is broken') + assertCalledWith( + infoMock, + `๐ŸŒŽ this is a request for a ${COLORS.highlight}global${COLORS.reset} deployment lock` + ) + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}global-branch-deploy-lock` + ) +}) + +test('successfully obtains a deployment lock (sticky and global) by creating the branch and lock file with a --reason at the end of the string', async () => { + context = contextFor( + '.lock --global --reason because something is broken badly ' + ) + assert.deepStrictEqual( + await lock(lockRequest({context, environment: null, sticky: true})), + {...createdLock, environment: null, global: true} + ) + assertCalledWith(debugMock, 'reason: because something is broken badly') + assertCalledWith(debugMock, `detected lock env: null`) + assertCalledWith(debugMock, `detected lock global: true`) + assertCalledWith( + debugMock, + `constructed lock branch name: global-branch-deploy-lock` + ) + assertCalledWith( + infoMock, + `๐ŸŒŽ this is a request for a ${COLORS.highlight}global${COLORS.reset} deployment lock` + ) + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}global-branch-deploy-lock` + ) +}) + +test('successfully obtains a deployment lock (sticky) by creating the branch and lock file with a --reason at the end of the string', async () => { + context = contextFor( + '.lock development --reason because something is broken badly ' + ) + assert.deepStrictEqual( + await lock(lockRequest({context, environment: null, sticky: true})), + {...createdLock, environment: 'development'} + ) + assertCalledWith(debugMock, `detected lock env: development`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: development-branch-deploy-lock` + ) + assertCalledWith(debugMock, 'reason: because something is broken badly') + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}development-branch-deploy-lock` + ) +}) + +test('successfully obtains a deployment lock (sticky) by creating the branch and lock file with a --reason and assuming a null environment to start (but it is production)', async () => { + context = contextFor('.lock --reason because something is broken') + assert.deepStrictEqual( + await lock(lockRequest({context, environment: null, sticky: true})), + createdLock + ) + assertCalledWith(debugMock, `detected lock env: ${environment}`) + assertCalledWith(debugMock, `detected lock global: false`) + assertCalledWith( + debugMock, + `constructed lock branch name: ${environment}-branch-deploy-lock` + ) + assertCalledWith(debugMock, 'reason: because something is broken') + assertCalledWith(infoMock, 'โœ… deployment lock obtained') + assertCalledWith(infoMock, `๐Ÿฏ deployment lock is ${COLORS.highlight}sticky`) + assertCalledWith( + infoMock, + `๐Ÿ”’ created lock branch: ${COLORS.highlight}production-branch-deploy-lock` + ) +}) + +test('throws an error if an unhandled exception occurs', async () => { + const octokit = createLockOctokit({ + repos: { + getBranch: mockGetBranch(new Error('oh no')), + getContent: mockGetContent(new Error('oh no')) + } + }) + + await assert.rejects( + lock(lockRequest({octokit, sticky: true})), + /Error: oh no/u + ) +}) diff --git a/__tests__/functions/naked-command-check.test.js b/__tests__/functions/naked-command-check.test.ts similarity index 53% rename from __tests__/functions/naked-command-check.test.js rename to __tests__/functions/naked-command-check.test.ts index 56b15e80..742a50cf 100644 --- a/__tests__/functions/naked-command-check.test.js +++ b/__tests__/functions/naked-command-check.test.ts @@ -1,156 +1,189 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {nakedCommandCheck} from '../../src/functions/naked-command-check.js' -import {COLORS} from '../../src/functions/colors.js' +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import type {NakedCommandOctokit} from '../../src/functions/naked-command-check.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionIo = typeof import('../../src/action-io.ts') +type NakedCommandModule = + typeof import('../../src/functions/naked-command-check.ts') + +const debugMock = createMock() +const warningMock = createMock() +const getActionInputMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + warning: warningMock +}) +installModuleMock(mock, new URL('../../src/action-io.ts', import.meta.url), { + getActionInput: getActionInputMock +}) + +const {nakedCommandCheck} = + await import('../../src/functions/naked-command-check.ts') const docs = 'https://github.com/github/branch-deploy/blob/main/docs/naked-commands.md' -const warningMock = vi.spyOn(core, 'warning') -var context -var octokit -var triggers -var param_separator +let context: Parameters[4] +let octokit: Parameters[3] +let triggers: Parameters[2] +let param_separator: Parameters[1] beforeEach(() => { - vi.clearAllMocks() - - process.env.INPUT_GLOBAL_LOCK_FLAG = '--global' + debugMock.mock.resetCalls() + warningMock.mock.resetCalls() + getActionInputMock.mock.resetCalls() + getActionInputMock.mock.mockImplementation(() => '--global') triggers = ['.deploy', '.noop', '.lock', '.unlock', '.wcid'] param_separator = '|' - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - id: '1' - } - } - } + context = createIssueCommentContext({ + repo: {owner: 'corp', repo: 'test'}, + issue: {number: 1}, + payload: {comment: {id: 1}} + }) octokit = { rest: { reactions: { - createForIssueComment: vi.fn().mockReturnValueOnce({ - data: {} - }) + createForIssueComment: + createMock< + NakedCommandOctokit['rest']['reactions']['createForIssueComment'] + >() }, issues: { - createComment: vi.fn().mockReturnValueOnce({ - data: {} - }) + createComment: + createMock() } } - } + } satisfies NakedCommandOctokit }) test('checks the command and finds that it is naked', async () => { const body = '.deploy' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) - expect(warningMock).toHaveBeenCalledWith( + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) + assertCalledWith( + warningMock, `๐Ÿฉฒ naked commands are ${COLORS.warning}not${COLORS.reset} allowed based on your configuration: ${COLORS.highlight}${body}${COLORS.reset}` ) - expect(warningMock).toHaveBeenCalledWith( + assertCalledWith( + warningMock, `๐Ÿ“š view the documentation around ${COLORS.highlight}naked commands${COLORS.reset} to learn more: ${docs}` ) }) test('checks the command and finds that it is naked (noop)', async () => { const body = '.noop' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is naked (lock)', async () => { const body = '.lock' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is naked (lock) with a reason', async () => { const body = '.lock --reason I am testing a big change' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is NOT naked (lock) with a reason', async () => { const body = '.lock production --reason I am testing a big change' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(false) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + false + ) }) test('checks the command and finds that it is naked (unlock)', async () => { const body = '.unlock' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is NOT naked because it is global', async () => { const body = '.unlock --global' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(false) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + false + ) }) test('checks the command and finds that it is naked (alias)', async () => { const body = '.wcid' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is naked (whitespaces)', async () => { const body = '.deploy ' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is not naked', async () => { const body = '.deploy production' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(false) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + false + ) }) test('checks the command and finds that it is not naked with "to"', async () => { const body = '.deploy to production' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(false) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + false + ) }) test('checks the command and finds that it is not naked with an alias lock command', async () => { const body = '.wcid staging ' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(false) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + false + ) }) test('checks the command and finds that it is naked with params', async () => { const body = '.deploy | cpus=1 memory=2g,3g env=production' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) test('checks the command and finds that it is naked with params and extra whitespace', async () => { const body = '.deploy | cpus=1 memory=2g,3g env=production' - expect( - await nakedCommandCheck(body, param_separator, triggers, octokit, context) - ).toBe(true) + assert.strictEqual( + await nakedCommandCheck(body, param_separator, triggers, octokit, context), + true + ) }) diff --git a/__tests__/functions/outdated-check.test.js b/__tests__/functions/outdated-check.test.js deleted file mode 100644 index 0f3ed553..00000000 --- a/__tests__/functions/outdated-check.test.js +++ /dev/null @@ -1,168 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {isOutdated} from '../../src/functions/outdated-check.js' -import {COLORS} from '../../src/functions/colors.js' - -const debugMock = vi.spyOn(core, 'debug') -const warningMock = vi.spyOn(core, 'warning') - -var context -var octokit -var data - -beforeEach(() => { - vi.clearAllMocks() - - data = { - outdated_mode: 'strict', - mergeStateStatus: 'CLEAN', - stableBaseBranch: { - data: { - commit: {sha: 'beefdead'}, - name: 'stable-branch' - }, - status: 200 - }, - baseBranch: { - data: { - commit: {sha: 'deadbeef'}, - name: 'test-branch' - }, - status: 200 - }, - pr: { - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'base-ref' - } - }, - status: 200 - } - } - - context = { - repo: { - owner: 'corp', - repo: 'test' - } - } - - octokit = { - rest: { - repos: { - compareCommits: vi - .fn() - .mockReturnValue({data: {behind_by: 0}, status: 200}) - } - } - } -}) - -test('checks if the branch is out-of-date via commit comparison and finds that it is not', async () => { - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'test-branch|stable-branch', - outdated: false - }) -}) - -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 () => { - data.baseBranch = data.stableBaseBranch - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'stable-branch|stable-branch', - outdated: false - }) -}) - -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 () => { - data.baseBranch = data.stableBaseBranch - - octokit.rest.repos.compareCommits = vi - .fn() - .mockReturnValue({data: {behind_by: 1}, status: 200}) - - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'stable-branch', - outdated: true - }) -}) - -test('checks if the branch is out-of-date via commit comparison and finds that it is not using outdated_mode pr_base', async () => { - data.outdated_mode = 'pr_base' - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'test-branch', - outdated: false - }) - expect(debugMock).toHaveBeenCalledWith( - 'checking isOutdated with pr_base mode' - ) -}) - -test('checks if the branch is out-of-date via commit comparison and finds that it is not using outdated_mode default_branch', async () => { - data.outdated_mode = 'default_branch' - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'stable-branch', - outdated: false - }) - expect(debugMock).toHaveBeenCalledWith( - 'checking isOutdated with default_branch mode' - ) -}) - -test('checks if the branch is out-of-date via commit comparison and finds that it is', async () => { - octokit.rest.repos.compareCommits = vi - .fn() - .mockReturnValue({data: {behind_by: 1}, status: 200}) - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'test-branch', - outdated: true - }) - expect(debugMock).toHaveBeenCalledWith('checking isOutdated with strict mode') - expect(warningMock).toHaveBeenCalledWith( - `The PR branch is behind the base branch by ${COLORS.highlight}1 commit${COLORS.reset}` - ) -}) - -test('checks if the branch is out-of-date via commit comparison and finds that it is by many commits', async () => { - octokit.rest.repos.compareCommits = vi - .fn() - .mockReturnValue({data: {behind_by: 45}, status: 200}) - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'test-branch', - outdated: true - }) - expect(debugMock).toHaveBeenCalledWith('checking isOutdated with strict mode') - expect(warningMock).toHaveBeenCalledWith( - `The PR branch is behind the base branch by ${COLORS.highlight}45 commits${COLORS.reset}` - ) -}) - -test('checks if the branch is out-of-date via commit comparison and finds that it is only behind the stable branch', async () => { - octokit.rest.repos.compareCommits = vi - .fn() - .mockImplementationOnce(() => - Promise.resolve({data: {behind_by: 0}, status: 200}) - ) - .mockImplementationOnce(() => - Promise.resolve({data: {behind_by: 1}, status: 200}) - ) - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'stable-branch', - outdated: true - }) - expect(debugMock).toHaveBeenCalledWith('checking isOutdated with strict mode') -}) - -test('checks the mergeStateStatus and finds that it is BEHIND', async () => { - data.mergeStateStatus = 'BEHIND' - expect(await isOutdated(context, octokit, data)).toStrictEqual({ - branch: 'test-branch', - outdated: true - }) - expect(debugMock).toHaveBeenCalledWith( - 'mergeStateStatus is BEHIND - exiting isOutdated logic early' - ) -}) diff --git a/__tests__/functions/outdated-check.test.ts b/__tests__/functions/outdated-check.test.ts new file mode 100644 index 00000000..9cb8f347 --- /dev/null +++ b/__tests__/functions/outdated-check.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type OutdatedCheckModule = + typeof import('../../src/functions/outdated-check.ts') + +const debugMock = createMock() +const warningMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + warning: warningMock +}) + +const {isOutdated} = await import('../../src/functions/outdated-check.ts') + +let context: Parameters[0] +let octokit: Parameters[1] +let data: Parameters[2] +const compareCommitsMock = + createMock< + Parameters< + OutdatedCheckModule['isOutdated'] + >[1]['rest']['repos']['compareCommits'] + >() + +beforeEach(() => { + debugMock.mock.resetCalls() + warningMock.mock.resetCalls() + compareCommitsMock.mock.resetCalls() + + data = { + outdated_mode: 'strict', + mergeStateStatus: 'CLEAN', + stableBaseBranch: { + data: { + commit: {sha: 'beefdead'}, + name: 'stable-branch' + } + }, + baseBranch: { + data: { + commit: {sha: 'deadbeef'}, + name: 'test-branch' + } + }, + pr: {data: {head: {sha: 'abc123'}}} + } + + context = createContext({ + repo: { + owner: 'corp', + repo: 'test' + } + }) + + compareCommitsMock.mock.mockImplementation(() => + Promise.resolve({ + data: {behind_by: 0} + }) + ) + octokit = { + rest: { + repos: { + compareCommits: compareCommitsMock + } + } + } +}) + +test('checks if the branch is out-of-date via commit comparison and finds that it is not', async () => { + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'test-branch|stable-branch', + outdated: false + }) +}) + +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 () => { + data.baseBranch = data.stableBaseBranch + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'stable-branch|stable-branch', + outdated: false + }) +}) + +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 () => { + data.baseBranch = data.stableBaseBranch + + compareCommitsMock.mock.mockImplementation(() => + Promise.resolve({ + data: {behind_by: 1} + }) + ) + + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'stable-branch', + outdated: true + }) +}) + +test('checks if the branch is out-of-date via commit comparison and finds that it is not using outdated_mode pr_base', async () => { + data.outdated_mode = 'pr_base' + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'test-branch', + outdated: false + }) + assertCalledWith(debugMock, 'checking isOutdated with pr_base mode') +}) + +test('checks if the branch is out-of-date via commit comparison and finds that it is not using outdated_mode default_branch', async () => { + data.outdated_mode = 'default_branch' + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'stable-branch', + outdated: false + }) + assertCalledWith(debugMock, 'checking isOutdated with default_branch mode') +}) + +test('checks if the branch is out-of-date via commit comparison and finds that it is', async () => { + compareCommitsMock.mock.mockImplementation(() => + Promise.resolve({ + data: {behind_by: 1} + }) + ) + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'test-branch', + outdated: true + }) + assertCalledWith(debugMock, 'checking isOutdated with strict mode') + assertCalledWith( + warningMock, + `The PR branch is behind the base branch by ${COLORS.highlight}1 commit${COLORS.reset}` + ) +}) + +test('checks if the branch is out-of-date via commit comparison and finds that it is by many commits', async () => { + compareCommitsMock.mock.mockImplementation(() => + Promise.resolve({ + data: {behind_by: 45} + }) + ) + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'test-branch', + outdated: true + }) + assertCalledWith(debugMock, 'checking isOutdated with strict mode') + assertCalledWith( + warningMock, + `The PR branch is behind the base branch by ${COLORS.highlight}45 commits${COLORS.reset}` + ) +}) + +test('checks if the branch is out-of-date via commit comparison and finds that it is only behind the stable branch', async () => { + compareCommitsMock.mock.mockImplementationOnce( + () => Promise.resolve({data: {behind_by: 0}}), + 0 + ) + compareCommitsMock.mock.mockImplementationOnce( + () => Promise.resolve({data: {behind_by: 1}}), + 1 + ) + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'stable-branch', + outdated: true + }) + assertCalledWith(debugMock, 'checking isOutdated with strict mode') +}) + +test('checks the mergeStateStatus and finds that it is BEHIND', async () => { + data.mergeStateStatus = 'BEHIND' + assert.deepStrictEqual(await isOutdated(context, octokit, data), { + branch: 'test-branch', + outdated: true + }) + assertCalledWith( + debugMock, + 'mergeStateStatus is BEHIND - exiting isOutdated logic early' + ) +}) diff --git a/__tests__/functions/params.test.js b/__tests__/functions/params.test.js deleted file mode 100644 index e299e625..00000000 --- a/__tests__/functions/params.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import {parseParams} from '../../src/functions/params.js' - -beforeEach(() => { - vi.clearAllMocks() -}) - -test('with empty param object', async () => { - expect(parseParams('')).toStrictEqual({_: []}) - expect(parseParams(null)).toStrictEqual({_: []}) - expect(parseParams(undefined)).toStrictEqual({_: []}) -}) - -test('it parses positional parameters', async () => { - expect(parseParams('foo bar baz')).toHaveProperty('_', ['foo', 'bar', 'baz']) -}) - -test('it parses arguments using the default settings of library', async () => { - const parsed = parseParams('--foo bar --env.foo=bar baz') - expect(parsed).toHaveProperty('foo', 'bar') - expect(parsed).toHaveProperty('env', {foo: 'bar'}) - expect(parsed).toHaveProperty('_', ['baz']) -}) - -test('it works with empty string', async () => { - expect(parseParams('')).toHaveProperty('_', []) -}) - -test('it parses multiple positional parameters', async () => { - expect(parseParams('foo bar baz')).toHaveProperty('_', ['foo', 'bar', 'baz']) -}) - -test('it parses flags correctly', async () => { - const parsed = parseParams('--foo --bar') - expect(parsed).toHaveProperty('foo', true) - expect(parsed).toHaveProperty('bar', true) - expect(parsed).toHaveProperty('_', []) -}) - -test('it parses numeric values correctly', async () => { - const parsed = parseParams('--count 42') - expect(parsed).toHaveProperty('count', 42) - expect(parsed).toHaveProperty('_', []) -}) - -test('it parses plain values', async () => { - const parsed = parseParams('count 42') - expect(parsed).toHaveProperty('_', ['count', 42]) -}) - -test('it parses string values with comma separation', async () => { - const parsed = parseParams('LOG_LEVEL=debug,CPU_CORES=4') - expect(parsed).toHaveProperty('_', ['LOG_LEVEL=debug,CPU_CORES=4']) -}) - -test('it parses boolean values correctly', async () => { - const parsed = parseParams('--enabled=true --disabled false') - expect(parsed).toHaveProperty('enabled', 'true') - expect(parsed).toHaveProperty('disabled', 'false') - expect(parsed).toHaveProperty('_', []) -}) - -test('it parses nested objects correctly', async () => { - const parsed = parseParams( - 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432' - ) - expect(parsed).toHaveProperty('config', {db: {host: 'localhost', port: 5432}}) - expect(parsed).toHaveProperty('_', ['LOG_LEVEL=debug']) - expect(parsed).toStrictEqual({ - config: {db: {host: 'localhost', port: 5432}}, - _: ['LOG_LEVEL=debug'] - }) -}) - -test('it parses a real world example correctly', async () => { - const parsed = parseParams( - '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' - ) - expect(parsed).toHaveProperty('cpu', 2) - expect(parsed).toHaveProperty('memory', '4G') - expect(parsed).toHaveProperty('env', 'development') - expect(parsed).toHaveProperty('port', 8080) - expect(parsed).toHaveProperty('name', 'my-app') - expect(parsed).toHaveProperty('q', 'my-queue') - expect(parsed).toHaveProperty('_', []) -}) diff --git a/__tests__/functions/params.test.ts b/__tests__/functions/params.test.ts new file mode 100644 index 00000000..03b6e1c0 --- /dev/null +++ b/__tests__/functions/params.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {parseParams} from '../../src/functions/params.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +test('with empty param object', () => { + assert.deepStrictEqual(parseParams(''), {_: []}) + assert.deepStrictEqual(parseParams(null), {_: []}) + assert.deepStrictEqual( + parseParams( + unsafeInvalidValue[0]>(undefined) + ), + {_: []} + ) +}) + +test('it parses positional parameters', () => { + assert.deepStrictEqual(parseParams('foo bar baz')['_'], ['foo', 'bar', 'baz']) +}) + +test('it parses arguments using the default settings of library', () => { + const parsed = parseParams('--foo bar --env.foo=bar baz') + assert.strictEqual(parsed['foo'], 'bar') + assert.deepStrictEqual(parsed['env'], {foo: 'bar'}) + assert.deepStrictEqual(parsed['_'], ['baz']) +}) + +test('it works with empty string', () => { + assert.deepStrictEqual(parseParams('')['_'], []) +}) + +test('it parses multiple positional parameters', () => { + assert.deepStrictEqual(parseParams('foo bar baz')['_'], ['foo', 'bar', 'baz']) +}) + +test('it parses flags correctly', () => { + const parsed = parseParams('--foo --bar') + assert.strictEqual(parsed['foo'], true) + assert.strictEqual(parsed['bar'], true) + assert.deepStrictEqual(parsed['_'], []) +}) + +test('it parses numeric values correctly', () => { + const parsed = parseParams('--count 42') + assert.strictEqual(parsed['count'], 42) + assert.deepStrictEqual(parsed['_'], []) +}) + +test('it parses plain values', () => { + const parsed = parseParams('count 42') + assert.deepStrictEqual(parsed['_'], ['count', 42]) +}) + +test('it parses string values with comma separation', () => { + const parsed = parseParams('LOG_LEVEL=debug,CPU_CORES=4') + assert.deepStrictEqual(parsed['_'], ['LOG_LEVEL=debug,CPU_CORES=4']) +}) + +test('it parses boolean values correctly', () => { + const parsed = parseParams('--enabled=true --disabled false') + assert.strictEqual(parsed['enabled'], 'true') + assert.strictEqual(parsed['disabled'], 'false') + assert.deepStrictEqual(parsed['_'], []) +}) + +test('it parses nested objects correctly', () => { + const parsed = parseParams( + 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432' + ) + assert.deepStrictEqual(parsed['config'], { + db: {host: 'localhost', port: 5432} + }) + assert.deepStrictEqual(parsed['_'], ['LOG_LEVEL=debug']) + assert.deepStrictEqual(parsed, { + config: {db: {host: 'localhost', port: 5432}}, + _: ['LOG_LEVEL=debug'] + }) +}) + +test('it parses a real world example correctly', () => { + const parsed = parseParams( + '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' + ) + assert.strictEqual(parsed['cpu'], 2) + assert.strictEqual(parsed['memory'], '4G') + assert.strictEqual(parsed['env'], 'development') + assert.strictEqual(parsed['port'], 8080) + assert.strictEqual(parsed['name'], 'my-app') + assert.strictEqual(parsed['q'], 'my-queue') + assert.deepStrictEqual(parsed['_'], []) +}) + +test('it preserves quoted parameter values and quoted positional arguments', () => { + assert.deepStrictEqual( + parseParams( + `--message="hello world" --single='two words' 'standalone value'` + ), + { + _: [`'standalone value'`], + message: 'hello world', + single: 'two words' + } + ) +}) + +test('it preserves repeated long and short options as arrays', () => { + assert.deepStrictEqual(parseParams('--tag=a --tag=b -q first -q second'), { + _: [], + tag: ['a', 'b'], + q: ['first', 'second'] + }) +}) + +test('it preserves numeric coercion, empty values, and no-prefix flags', () => { + assert.deepStrictEqual( + parseParams( + '--count=-2 --ratio=1e3 --hex=0x10 --leading=08 --zero=0 --empty= --no-cache --enabled' + ), + { + _: [], + count: -2, + ratio: 1000, + hex: 16, + leading: '08', + zero: 0, + empty: '', + cache: false, + enabled: true + } + ) +}) + +test('it treats values following the end-of-options marker as positional', () => { + assert.deepStrictEqual(parseParams('-- foo --bar --no-cache'), { + _: ['foo', '--bar', '--no-cache'] + }) +}) + +test('it does not allow dotted parameter names to pollute Object.prototype', () => { + const parsed = parseParams( + '--__proto__.polluted=yes --constructor.prototype.bad=yes --prototype.value=x' + ) + + assert.deepStrictEqual(parsed, { + _: [], + ___proto___: {polluted: 'yes'}, + constructor: {prototype: {bad: 'yes'}}, + prototype: {value: 'x'} + }) + assert.strictEqual(Object.hasOwn(Object.prototype, 'polluted'), false) + assert.strictEqual(Object.hasOwn(Object.prototype, 'bad'), false) + assert.strictEqual(Object.hasOwn(Object.prototype, 'value'), false) +}) diff --git a/__tests__/functions/post-deploy-message.test.js b/__tests__/functions/post-deploy-message.test.js deleted file mode 100644 index 52c54187..00000000 --- a/__tests__/functions/post-deploy-message.test.js +++ /dev/null @@ -1,358 +0,0 @@ -import {postDeployMessage} from '../../src/functions/post-deploy-message.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' -import dedent from 'dedent-js' - -const debugMock = vi.spyOn(core, 'debug') - -var context -var environment -var environment_url -var environment_url_simple -var status -var noop -var ref -var approved_reviews_count -var sha -var deployment_id -var data -var review_decision -var fork -var params -var parsed_params -var deployment_end_time -var logs -var deployment_metadata -var total_seconds - -function renderDeploymentMetadata(data) { - return dedent(` -
Details - - - - \t\t\t\t\`\`\`json - \t\t\t\t{ - \t\t\t\t "status": "${data.status}", - \t\t\t\t "environment": { - \t\t\t\t "name": "${data.environment}", - \t\t\t\t "url": ${data.environment_url ? `"${data.environment_url}"` : null} - \t\t\t\t }, - \t\t\t\t "deployment": { - \t\t\t\t "id": ${data.deployment_id ? parseInt(data.deployment_id) : null}, - \t\t\t\t "timestamp": "${data.deployment_end_time}", - \t\t\t\t "logs": "${data.logs}", - \t\t\t\t "duration": ${data.total_seconds} - \t\t\t\t }, - \t\t\t\t "git": { - \t\t\t\t "branch": "${data.ref}", - \t\t\t\t "commit": "${data.sha}", - \t\t\t\t "verified": ${data.commit_verified} - \t\t\t\t }, - \t\t\t\t "context": { - \t\t\t\t "actor": "${data.actor}", - \t\t\t\t "noop": ${data.noop}, - \t\t\t\t "fork": ${data.fork} - \t\t\t\t }, - \t\t\t\t "reviews": { - \t\t\t\t "count": ${data.approved_reviews_count ? parseInt(data.approved_reviews_count) : null}, - \t\t\t\t "decision": ${data.review_decision ? `"${data.review_decision}"` : null} - \t\t\t\t }, - \t\t\t\t "parameters": { - \t\t\t\t "raw": ${data.params ? `"${data.params}"` : null}, - \t\t\t\t "parsed": ${data.parsed_params || null} - \t\t\t\t } - \t\t\t\t} - \`\`\` - - - -
- `) -} - -beforeEach(() => { - vi.clearAllMocks() - - process.env.GITHUB_SERVER_URL = 'https://github.com' - process.env.GITHUB_RUN_ID = '12345' - - process.env.DEPLOY_MESSAGE = null - process.env.INPUT_ENVIRONMENT_URL_IN_COMMENT = 'true' - process.env.INPUT_DEPLOY_MESSAGE_PATH = '.github/deployment_message.md' - - environment = 'production' - environment_url = 'https://example.com' - environment_url_simple = 'example.com' - status = 'success' - noop = false - ref = 'test-ref' - sha = 'abc123' - approved_reviews_count = '4' - deployment_id = 456 - review_decision = 'APPROVED' - fork = false - params = 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432' - parsed_params = JSON.stringify({ - config: {db: {host: 'localhost', port: 5432}}, - _: ['LOG_LEVEL=debug'] - }) - deployment_end_time = '2024-01-01T00:00:00Z' - total_seconds = 27 - - context = { - actor: 'monalisa', - eventName: 'issue_comment', - workflow: 'test-workflow', - repo: { - owner: 'corp', - repo: 'test' - }, - payload: { - comment: { - id: '1' - } - } - } - - logs = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}` - - data = { - environment: environment, - environment_url: environment_url, - status: status, - noop: noop, - ref: ref, - sha: sha, - approved_reviews_count: approved_reviews_count, - review_decision: review_decision, - deployment_id: deployment_id, - fork: fork, - params: params, - parsed_params: parsed_params, - deployment_end_time: deployment_end_time, - actor: context.actor, - logs: logs, - commit_verified: false, - total_seconds: total_seconds - } - - deployment_metadata = renderDeploymentMetadata(data) -}) - -test('successfully constructs a post deploy message with the defaults', async () => { - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โœ… - - **${context.actor}** successfully deployed branch \`${ref}\` to **${environment}** - - ${deployment_metadata} - - > **Environment URL:** [${environment_url_simple}](${environment_url}) - `) - ) -}) - -test('successfully constructs a post deploy message with the defaults during a "noop" deploy', async () => { - data.noop = true - deployment_metadata = renderDeploymentMetadata(data) - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โœ… - - **${context.actor}** successfully **noop** deployed branch \`${ref}\` to **${environment}** - - ${deployment_metadata}`) - ) -}) - -test('successfully constructs a post deploy message with the defaults during a deployment failure', async () => { - data.status = 'failure' - deployment_metadata = renderDeploymentMetadata(data) - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โŒ - - **${context.actor}** had a failure when deploying branch \`${ref}\` to **${environment}** - - ${deployment_metadata} - `) - ) -}) - -test('successfully constructs a post deploy message with the defaults during a deployment with an unknown status', async () => { - data.status = 'unknown' - deployment_metadata = renderDeploymentMetadata(data) - - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โš ๏ธ - - Warning: deployment status is unknown, please use caution - - ${deployment_metadata}`) - ) -}) - -test('successfully constructs a post deploy message with the defaults during a deployment with an unknown status and the DEPLOY_MESSAGE_PATH is unset', async () => { - process.env.INPUT_DEPLOY_MESSAGE_PATH = '' - data.status = 'unknown' - deployment_metadata = renderDeploymentMetadata(data) - - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โš ๏ธ - - Warning: deployment status is unknown, please use caution - - ${deployment_metadata} - `) - ) - - expect(debugMock).toHaveBeenCalledWith('deployMessagePath is not set - null') -}) - -test('successfully constructs a post deploy message with a custom env var', async () => { - process.env.DEPLOY_MESSAGE = 'Deployed 1 shiny new server' - - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โœ… - - **${context.actor}** successfully deployed branch \`${ref}\` to **${environment}** - -
Show Results - - Deployed 1 shiny new server - -
- - ${deployment_metadata} - - > **Environment URL:** [${environment_url_simple}](${environment_url})`) - ) -}) - -test('successfully constructs a post deploy message with a custom env var when certain values are undefined', async () => { - process.env.DEPLOY_MESSAGE = 'Deployed 1 shiny new server' - - data.deployment_id = undefined - data.approved_reviews_count = null - data.parsed_params = '' - data.environment_url = '' - data.params = '' - data.review_decision = null - - deployment_metadata = renderDeploymentMetadata(data) - - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(` - ### Deployment Results โœ… - - **${context.actor}** successfully deployed branch \`${ref}\` to **${environment}** - -
Show Results - - Deployed 1 shiny new server - -
- - ${deployment_metadata}`) - ) -}) - -test('successfully constructs a post deploy message with a custom markdown file', async () => { - process.env.INPUT_DEPLOY_MESSAGE_PATH = - '__tests__/templates/test_deployment_message.md' - expect( - await postDeployMessage( - context, // context - data - ) - ).toStrictEqual( - dedent(`### Deployment Results :rocket: - - The following variables are available to use in this template: - - - \`environment\` - The name of the environment (String) - - \`environment_url\` - The URL of the environment (String) {Optional} - - \`status\` - The status of the deployment (String) - \`success\`, \`failure\`, or \`unknown\` - - \`noop\` - Whether or not the deployment is a noop (Boolean) - - \`ref\` - The ref of the deployment (String) - - \`sha\` - The exact commit SHA of the deployment (String) - - \`actor\` - The GitHub username of the actor who triggered the deployment (String) - - \`approved_reviews_count\` - The number of approved reviews on the pull request at the time of deployment (String of a number) - - \`deployment_id\` - The ID of the deployment (String) - - \`review_decision\` - The decision of the review (String or null) - \`"APPROVED"\`, \`"REVIEW_REQUIRED"\`, \`"CHANGES_REQUESTED"\`, \`null\`, etc. - - \`params\` - The raw parameters provided in the deploy command (String) - - \`parsed_params\` - The parsed parameters provided in the deploy command (String) - - \`deployment_end_time\` - The end time of the deployment - this value is not _exact_ but it is very close (String) - - \`logs\` - The url to the logs of the deployment (String) - - \`commit_verified\` - Whether or not the commit was verified (Boolean) - - \`total_seconds\` - The total number of seconds the deployment took (String of a number) - - Here is an example: - - monalisa deployed branch \`test-ref\` to the **production** environment. This deployment was a success :rocket:. - - The exact commit sha that was used for the deployment was \`${sha}\`. - - The exact deployment ID for this deployment was \`${deployment_id}\`. - - The review decision for this deployment was \`${review_decision}\`. - - The deployment had the following parameters provided in the deploy command: \`LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432\` - - The deployment had the following "parsed" parameters provided in the deploy command: \`{"config":{"db":{"host":"localhost","port":5432}},"_":["LOG_LEVEL=debug"]}\` - - The deployment process ended at \`2024-01-01T00:00:00Z\` and it took \`27\` seconds to complete. - - Here are the deployment logs: https://github.com/corp/test/actions/runs/12345 - - The commit was not verified. - - You can view the deployment [here](https://example.com). - - - - > This deployment had \`4\` approvals. - - `) - ) -}) diff --git a/__tests__/functions/post-deploy-message.test.ts b/__tests__/functions/post-deploy-message.test.ts new file mode 100644 index 00000000..1bb57995 --- /dev/null +++ b/__tests__/functions/post-deploy-message.test.ts @@ -0,0 +1,455 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import type {PostDeployMessageData} from '../../src/types.ts' +import {createContext} from '../test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' +import {decodedJsonValue} from '../../src/trust-boundaries.ts' +import { + assertCalledWith, + createMock, + stubEnv, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +function readInput(name: string, trimWhitespace = true): string { + const value = + process.env[`INPUT_${name.replace(/ /gu, '_').toUpperCase()}`] ?? '' + return trimWhitespace ? value.trim() : value +} + +const debugMock = createMock() +const getInputMock = createMock((name, options) => + readInput(name, options?.trimWhitespace !== false) +) +const getBooleanInputMock = createMock( + (name, options) => + readInput(name, options?.trimWhitespace !== false) === 'true' +) + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + getBooleanInput: getBooleanInputMock, + getInput: getInputMock +}) + +const {postDeployMessage} = + await import('../../src/functions/post-deploy-message.ts') + +let context: Parameters[0] +let environment: string +let environment_url: string +let environment_url_simple: string +let status: string +let noop: boolean +let ref: string +let approved_reviews_count: string +let sha: string +let deployment_id: number +let data: PostDeployMessageData +let review_decision: string +let fork: boolean +let params: string +let parsed_params: string +let deployment_end_time: string +let logs: string +let deployment_metadata: string +let total_seconds: number + +function renderDeploymentMetadata(data: PostDeployMessageData): string { + const environmentUrl = + data.environment_url === '' ? null : data.environment_url + const deploymentId = data.deployment_id ? parseInt(data.deployment_id) : null + const reviewCount = data.approved_reviews_count + ? parseInt(data.approved_reviews_count) + : null + const metadata = { + status: data.status, + environment: {name: data.environment, url: environmentUrl}, + deployment: { + id: deploymentId, + timestamp: data.deployment_end_time, + logs, + duration: data.total_seconds + }, + git: {branch: data.ref, commit: data.sha, verified: data.commit_verified}, + context: {actor: context.actor, noop: data.noop, fork: data.fork}, + reviews: { + count: reviewCount, + decision: data.review_decision === '' ? null : data.review_decision + }, + parameters: { + raw: data.params === '' ? null : data.params, + parsed: + data.parsed_params === '' ? null : decodedJsonValue(data.parsed_params) + } + } + + return [ + '
Details', + '', + '', + '', + '```json', + JSON.stringify(metadata, null, 2), + '```', + '', + '', + '', + '
' + ].join('\n') +} + +function defaultMessage( + heading: string, + message: string, + metadata: string, + environmentUrl?: string +): string { + const parts = [heading, '', message, '', metadata] + if (environmentUrl !== undefined) { + parts.push('', environmentUrl) + } + return parts.join('\n') +} + +beforeEach(testContext => { + if (!('after' in testContext)) { + throw new Error('expected a test context') + } + + debugMock.mock.resetCalls() + getInputMock.mock.resetCalls() + getBooleanInputMock.mock.resetCalls() + + stubEnv(testContext, 'GITHUB_SERVER_URL', 'https://github.com') + stubEnv(testContext, 'GITHUB_RUN_ID', '12345') + + stubEnv(testContext, 'DEPLOY_MESSAGE', undefined) + stubEnv(testContext, 'INPUT_ENVIRONMENT_URL_IN_COMMENT', 'true') + stubEnv( + testContext, + 'INPUT_DEPLOY_MESSAGE_PATH', + '.github/deployment_message.md' + ) + + environment = 'production' + environment_url = 'https://example.com' + environment_url_simple = 'example.com' + status = 'success' + noop = false + ref = 'test-ref' + sha = 'abc123' + approved_reviews_count = '4' + deployment_id = 456 + review_decision = 'APPROVED' + fork = false + params = 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432' + parsed_params = JSON.stringify({ + config: {db: {host: 'localhost', port: 5432}}, + _: ['LOG_LEVEL=debug'] + }) + deployment_end_time = '2024-01-01T00:00:00Z' + total_seconds = 27 + + context = createContext({ + actor: 'monalisa', + eventName: 'issue_comment', + repo: { + owner: 'corp', + repo: 'test' + }, + payload: { + comment: { + id: '1' + } + } + }) + + logs = `${String(process.env['GITHUB_SERVER_URL'])}/${context.repo.owner}/${context.repo.repo}/actions/runs/${String(process.env['GITHUB_RUN_ID'])}` + + data = { + environment: environment, + environment_url: environment_url, + status: status, + noop: noop, + ref: ref, + sha: sha, + approved_reviews_count: approved_reviews_count, + review_decision: review_decision, + deployment_id: String(deployment_id), + fork: fork, + params: params, + parsed_params: parsed_params, + deployment_end_time: deployment_end_time, + commit_verified: false, + total_seconds: total_seconds + } + + deployment_metadata = renderDeploymentMetadata(data) +}) + +test('successfully constructs a post deploy message with the defaults', () => { + assert.strictEqual( + postDeployMessage(context, data), + defaultMessage( + '### Deployment Results โœ…', + `**${context.actor}** successfully deployed branch \`${ref}\` to **${environment}**`, + deployment_metadata, + `> **Environment URL:** [${environment_url_simple}](${environment_url})` + ) + ) +}) + +test('successfully constructs a post deploy message with the defaults during a "noop" deploy', () => { + data = {...data, noop: true} + deployment_metadata = renderDeploymentMetadata(data) + assert.strictEqual( + postDeployMessage(context, data), + defaultMessage( + '### Deployment Results โœ…', + `**${context.actor}** successfully **noop** deployed branch \`${ref}\` to **${environment}**`, + deployment_metadata + ) + ) +}) + +test('successfully constructs a post deploy message with the defaults during a deployment failure', () => { + data = {...data, status: 'failure'} + deployment_metadata = renderDeploymentMetadata(data) + assert.strictEqual( + postDeployMessage(context, data), + defaultMessage( + '### Deployment Results โŒ', + `**${context.actor}** had a failure when deploying branch \`${ref}\` to **${environment}**`, + deployment_metadata + ) + ) +}) + +test('successfully constructs a post deploy message with the defaults during a deployment with an unknown status', () => { + data = {...data, status: 'unknown'} + deployment_metadata = renderDeploymentMetadata(data) + + assert.strictEqual( + postDeployMessage(context, data), + defaultMessage( + '### Deployment Results โš ๏ธ', + 'Warning: deployment status is unknown, please use caution', + deployment_metadata + ) + ) +}) + +test('falls back to the default message when no trusted template is supplied', () => { + data = {...data, status: 'unknown'} + deployment_metadata = renderDeploymentMetadata(data) + + assert.strictEqual( + postDeployMessage(context, data, null), + defaultMessage( + '### Deployment Results โš ๏ธ', + 'Warning: deployment status is unknown, please use caution', + deployment_metadata + ) + ) +}) + +test('successfully constructs a post deploy message with a custom env var', testContext => { + stubEnv(testContext, 'DEPLOY_MESSAGE', 'Deployed 1 shiny new server') + + assert.strictEqual( + postDeployMessage(context, data), + [ + '### Deployment Results โœ…', + '', + `**${context.actor}** successfully deployed branch \`${ref}\` to **${environment}**`, + '', + '
Show Results', + '', + 'Deployed 1 shiny new server', + '', + '
', + '', + deployment_metadata, + '', + `> **Environment URL:** [${environment_url_simple}](${environment_url})` + ].join('\n') + ) +}) + +test('expands escaped newlines and tabs in the custom deployment message', testContext => { + stubEnv( + testContext, + 'DEPLOY_MESSAGE', + 'First line\\nSecond line\\tindented\\nThird line' + ) + + assert.strictEqual( + postDeployMessage(context, data), + [ + '### Deployment Results โœ…', + '', + `**${context.actor}** successfully deployed branch \`${ref}\` to **${environment}**`, + '', + '
Show Results', + '', + 'First line\nSecond line\tindented\nThird line', + '', + '
', + '', + deployment_metadata, + '', + `> **Environment URL:** [${environment_url_simple}](${environment_url})` + ].join('\n') + ) +}) + +test('successfully constructs a post deploy message with a custom env var when certain values are undefined', testContext => { + stubEnv(testContext, 'DEPLOY_MESSAGE', 'Deployed 1 shiny new server') + + data = unsafeInvalidValue({ + ...data, + deployment_id: undefined, + approved_reviews_count: null, + parsed_params: '', + environment_url: '', + params: '', + review_decision: null + }) + + deployment_metadata = renderDeploymentMetadata(data) + + assert.strictEqual( + postDeployMessage(context, data), + [ + '### Deployment Results โœ…', + '', + `**${context.actor}** successfully deployed branch \`${ref}\` to **${environment}**`, + '', + '
Show Results', + '', + 'Deployed 1 shiny new server', + '', + '
', + '', + deployment_metadata + ].join('\n') + ) +}) + +test('renders an empty review decision as null metadata', () => { + data = {...data, review_decision: ''} + + assert.ok(postDeployMessage(context, data).includes('"decision": null')) +}) + +test('renders arbitrary post-deploy values as valid fenced JSON', () => { + const hostile = 'quote " slash \\ newline\nUnicode ๐Ÿš€ and `````` backticks' + data = { + ...data, + environment: hostile, + environment_url: `https://example.com/${hostile}`, + params: hostile, + parsed_params: JSON.stringify({_: [hostile], value: hostile}), + ref: hostile, + sha: hostile + } + + const rendered = postDeployMessage(context, data) + const match = rendered.match( + /\n\n(`{3,})json\n([\s\S]*?)\n\1\n\n/u + ) + if (match?.[1] === undefined || match[2] === undefined) { + throw new Error('expected post-deploy metadata block') + } + assert.ok(match[1].length > 6) + const metadata = decodedJsonValue(match[2]) + assert.deepStrictEqual(metadata, { + status: 'success', + environment: {name: hostile, url: `https://example.com/${hostile}`}, + deployment: { + id: 456, + timestamp: '2024-01-01T00:00:00Z', + logs, + duration: 27 + }, + git: {branch: hostile, commit: hostile, verified: false}, + context: {actor: 'monalisa', noop: false, fork: false}, + reviews: {count: 4, decision: 'APPROVED'}, + parameters: {raw: hostile, parsed: {_: [hostile], value: hostile}} + }) +}) + +test('renders every allowlisted variable in a trusted template', testContext => { + stubEnv(testContext, 'DEPLOY_MESSAGE', 'deployment output') + const template = [ + '{{ environment }}', + '{{ environment_url }}', + '{{ status }}', + '{{ noop }}', + '{{ ref }}', + '{{ sha }}', + '{{ approved_reviews_count }}', + '{{ review_decision }}', + '{{ deployment_id }}', + '{{ fork }}', + '{{ params }}', + '{{ parsed_params }}', + '{{ deployment_end_time }}', + '{{ actor }}', + '{{ logs }}', + '{{ commit_verified }}', + '{{ total_seconds }}', + '{{ results }}' + ].join('|') + + assert.strictEqual( + postDeployMessage(context, data, template), + [ + environment, + environment_url, + status, + String(noop), + ref, + sha, + approved_reviews_count, + review_decision, + String(deployment_id), + String(fork), + params, + parsed_params.replaceAll('"', '"'), + deployment_end_time, + context.actor, + logs, + 'false', + String(total_seconds), + 'deployment output' + ].join('|') + ) + assertCalledWith(debugMock, 'using trusted deployment template') +}) + +test('escapes ordinary variables while rendering results raw and only once', testContext => { + const rawResults = + '
{{ actor }}{% if status %}do not evaluate{% endif %}
' + stubEnv(testContext, 'DEPLOY_MESSAGE', rawResults) + data = { + ...data, + environment: 'Tom & Jerry\'s' + } + + assert.strictEqual( + postDeployMessage( + context, + data, + '{{ environment }}\n{{ results }}\n{{ actor }}' + ), + [ + '<prod data-name="blue">Tom & Jerry's</prod>', + rawResults, + 'monalisa' + ].join('\n') + ) +}) diff --git a/__tests__/functions/post-deploy.test.js b/__tests__/functions/post-deploy.test.js deleted file mode 100644 index a5ff9cb4..00000000 --- a/__tests__/functions/post-deploy.test.js +++ /dev/null @@ -1,681 +0,0 @@ -import {postDeploy} from '../../src/functions/post-deploy.js' -import {vi, expect, test, beforeEach} from 'vitest' -import {COLORS} from '../../src/functions/colors.js' -import * as actionStatus from '../../src/functions/action-status.js' -import * as lock from '../../src/functions/lock.js' -import * as unlock from '../../src/functions/unlock.js' -import * as createDeploymentStatus from '../../src/functions/deployment.js' -import * as postDeployMessage from '../../src/functions/post-deploy-message.js' -import * as core from '@actions/core' -import * as label from '../../src/functions/label.js' - -const infoMock = vi.spyOn(core, 'info') -const debugMock = vi.spyOn(core, 'debug') -const warningMock = vi.spyOn(core, 'warning') - -const review_decision = 'APPROVED' - -var octokit -var context -var labels -var data - -beforeEach(() => { - vi.clearAllMocks() - - vi.spyOn(label, 'label').mockImplementation(() => { - return undefined - }) - - vi.spyOn(postDeployMessage, 'postDeployMessage').mockImplementation(() => { - return 'Updated 1 server' - }) - - vi.spyOn(lock, 'lock').mockImplementation(() => { - return {lockData: {sticky: true}} - }) - - vi.spyOn(createDeploymentStatus, 'createDeploymentStatus').mockImplementation( - () => { - return undefined - } - ) - - context = { - actor: 'monalisa', - eventName: 'issue_comment', - workflow: 'test-workflow', - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - id: '1' - } - } - } - - octokit = { - rest: { - repos: { - createDeploymentStatus: vi.fn().mockReturnValue({ - data: {} - }) - }, - issues: { - createComment: vi.fn().mockReturnValue({ - data: {} - }) - }, - reactions: { - createForIssueComment: vi.fn().mockReturnValue({ - data: {} - }), - deleteForIssueComment: vi.fn().mockReturnValue({ - data: {} - }) - } - } - } - - labels = { - successful_deploy: [], - successful_noop: [], - failed_deploy: [], - failed_noop: [], - skip_successful_noop_labels_if_approved: false, - skip_successful_deploy_labels_if_approved: false - } - - data = { - sha: 'abc123', - ref: 'test-ref', - comment_id: 123, - reaction_id: 12345, - status: 'success', - message: 'test-message', - noop: false, - deployment_id: 456, - environment: 'production', - environment_url: null, - approved_reviews_count: 1, - labels: labels, - review_decision: review_decision, - fork: 'false', - params: 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432', - parsed_params: JSON.stringify({ - config: {db: {host: 'localhost', port: 5432}}, - _: ['LOG_LEVEL=debug'] - }), - commit_verified: false, - deployment_start_time: '2024-01-01T00:00:00Z' - } -}) - -test('successfully completes a production branch deployment', async () => { - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - const createDeploymentStatusSpy = vi.spyOn( - createDeploymentStatus, - 'createDeploymentStatus' - ) - expect(await postDeploy(context, octokit, data)).toBe('success') - - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - true - ) - expect(createDeploymentStatusSpy).toHaveBeenCalled() - expect(createDeploymentStatusSpy).toHaveBeenCalledWith( - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - 'test-ref', - 'success', - 456, - 'production', - null // environment_url - ) -}) - -test('successfully completes a production branch deployment that fails', async () => { - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - const createDeploymentStatusSpy = vi.spyOn( - createDeploymentStatus, - 'createDeploymentStatus' - ) - - data.status = 'failure' - - expect(await postDeploy(context, octokit, data)).toBe('success') - - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - false - ) - expect(createDeploymentStatusSpy).toHaveBeenCalled() - expect(createDeploymentStatusSpy).toHaveBeenCalledWith( - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - 'test-ref', - 'failure', - 456, - 'production', - null // environment_url - ) -}) - -test('successfully completes a production branch deployment with an environment url', async () => { - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - const createDeploymentStatusSpy = vi.spyOn( - createDeploymentStatus, - 'createDeploymentStatus' - ) - - data.environment_url = 'https://example.com' - - expect(await postDeploy(context, octokit, data)).toBe('success') - - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - true - ) - expect(createDeploymentStatusSpy).toHaveBeenCalled() - expect(createDeploymentStatusSpy).toHaveBeenCalledWith( - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - 'test-ref', - 'success', - 456, - 'production', - 'https://example.com' // environment_url - ) -}) - -test('successfully completes a production branch deployment and removes a non-sticky lock', async () => { - const lockSpy = vi.spyOn(lock, 'lock').mockImplementation(() => { - return {lockData: {sticky: false}} - }) - - vi.spyOn(unlock, 'unlock').mockImplementation(() => { - return true - }) - - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - const createDeploymentStatusSpy = vi.spyOn( - createDeploymentStatus, - 'createDeploymentStatus' - ) - expect(await postDeploy(context, octokit, data)).toBe('success') - - expect(lockSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - true - ) - expect(createDeploymentStatusSpy).toHaveBeenCalled() - expect(createDeploymentStatusSpy).toHaveBeenCalledWith( - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - 'test-ref', - 'success', - 456, - 'production', - null // environment_url - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงน ${COLORS.highlight}non-sticky${COLORS.reset} lock detected, will remove lock` - ) -}) - -test('successfully completes a noop branch deployment and removes a non-sticky lock', async () => { - const lockSpy = vi.spyOn(lock, 'lock').mockImplementation(() => { - return {lockData: {sticky: false}} - }) - - vi.spyOn(unlock, 'unlock').mockImplementation(() => { - return true - }) - - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - - data.noop = true - - expect(await postDeploy(context, octokit, data)).toBe('success - noop') - - expect(lockSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - true - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿงน ${COLORS.highlight}non-sticky${COLORS.reset} lock detected, will remove lock` - ) -}) - -test('successfully completes a noop branch deployment but does not get any lock data', async () => { - const lockSpy = vi.spyOn(lock, 'lock').mockImplementation(() => { - return {lockData: null} - }) - - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - - data.noop = true - - expect(await postDeploy(context, octokit, data)).toBe('success - noop') - - expect(lockSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - true - ) - expect(warningMock).toHaveBeenCalledWith( - '๐Ÿ’ก a request to obtain the lock data returned null or undefined - the lock may have been removed by another process while this Action was running' - ) -}) - -test('successfully completes a production branch deployment with no custom message', async () => { - const actionStatusSpy = vi.spyOn(actionStatus, 'actionStatus') - expect(await postDeploy(context, octokit, data)).toBe('success') - expect(actionStatusSpy).toHaveBeenCalled() - expect(actionStatusSpy).toHaveBeenCalledWith( - { - actor: 'monalisa', - eventName: 'issue_comment', - issue: {number: 1}, - payload: {comment: {id: '1'}}, - repo: {owner: 'corp', repo: 'test'}, - workflow: 'test-workflow' - }, - { - rest: { - issues: { - createComment: octokit.rest.issues.createComment - }, - reactions: { - createForIssueComment: octokit.rest.reactions.createForIssueComment, - deleteForIssueComment: octokit.rest.reactions.deleteForIssueComment - }, - repos: { - createDeploymentStatus: octokit.rest.repos.createDeploymentStatus - } - } - }, - 12345, - 'Updated 1 server', - true - ) -}) - -test('successfully completes a noop branch deployment', async () => { - data.noop = true - expect(await postDeploy(context, octokit, data)).toBe('success - noop') -}) - -test('successfully completes a noop branch deployment and applies success labels', async () => { - data.labels.successful_noop = ['ready-for-review', 'noop-success'] - data.noop = true - expect(await postDeploy(context, octokit, data)).toBe('success - noop') -}) - -test('successfully completes a noop branch deployment and does not apply labels due to skip config', async () => { - data.labels.successful_noop = ['ready-for-review', 'noop-success'] - data.labels.skip_successful_noop_labels_if_approved = true - data.noop = true - - expect(await postDeploy(context, octokit, data)).toBe('success - noop') - - expect(infoMock).toHaveBeenCalledWith( - `โฉ skipping noop labels since the pull request is ${COLORS.success}approved${COLORS.reset} (based on your configuration)` - ) -}) - -test('successfully completes a branch deployment and does not apply labels due to skip config', async () => { - data.labels.successful_deploy = ['ready-to-merge', 'deploy-success'] - data.labels.skip_successful_deploy_labels_if_approved = true - - expect(await postDeploy(context, octokit, data)).toBe('success') - - expect(infoMock).toHaveBeenCalledWith( - `โฉ skipping deploy labels since the pull request is ${COLORS.success}approved${COLORS.reset} (based on your configuration)` - ) -}) - -test('successfully completes a noop branch deployment that fails and applies failure labels', async () => { - data.labels.failed_noop = ['help', 'oh-no'] - data.noop = true - data.status = 'failure' - - expect(await postDeploy(context, octokit, data)).toBe('success - noop') - - expect(debugMock).toHaveBeenCalledWith('deploymentStatus: failure') - expect(debugMock).toHaveBeenCalledWith('deployment mode: noop') -}) - -test('updates with a failure for a production branch deployment', async () => { - data.status = 'failure' - - expect(await postDeploy(context, octokit, data)).toBe('success') -}) - -test('updates with an unknown for a production branch deployment', async () => { - data.status = 'unknown' - - expect(await postDeploy(context, octokit, data)).toBe('success') -}) - -test('fails due to no comment_id', async () => { - data.comment_id = '' - - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no comment_id provided') - } -}) - -test('fails due to no status', async () => { - data.status = '' - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no status provided') - } -}) - -test('fails due to no ref', async () => { - data.ref = '' - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no ref provided') - } -}) - -test('fails due to no deployment_id', async () => { - vi.resetAllMocks() - data.deployment_id = '' - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no deployment_id provided') - } -}) - -test('fails due to no environment', async () => { - vi.resetAllMocks() - data.environment = '' - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no environment provided') - } -}) - -test('fails due to no reaction_id', async () => { - vi.resetAllMocks() - data.reaction_id = '' - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no reaction_id provided') - } -}) - -test('fails due to no environment (noop)', async () => { - vi.resetAllMocks() - data.environment = '' - data.noop = true - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no environment provided') - } -}) - -test('fails due to no noop', async () => { - vi.resetAllMocks() - data.noop = null - try { - await postDeploy(context, octokit, data) - } catch (e) { - expect(e.message).toBe('no noop value provided') - } -}) - -test('skips lock check and release when disable_lock is true', async () => { - const lockSpy = vi.spyOn(lock, 'lock') - const unlockSpy = vi.spyOn(unlock, 'unlock') - - data.disable_lock = true - - expect(await postDeploy(context, octokit, data)).toBe('success') - expect(lockSpy).not.toHaveBeenCalled() - expect(unlockSpy).not.toHaveBeenCalled() -}) diff --git a/__tests__/functions/post-deploy.test.ts b/__tests__/functions/post-deploy.test.ts new file mode 100644 index 00000000..ee6361bd --- /dev/null +++ b/__tests__/functions/post-deploy.test.ts @@ -0,0 +1,723 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import type {PostDeployOctokit} from '../../src/functions/post-deploy.ts' +import type { + IssueCommentContext, + PostDeployLabels, + RawPostDeployData +} from '../../src/types.ts' +import { + createIssueCommentContext, + createOctokit, + type DeepMutable +} from '../test-helpers.ts' +import { + assertCalledTimes, + assertCalledWith, + assertNotCalled, + createMock, + installModuleMock +} from '../node-test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionStatus = typeof import('../../src/functions/action-status.ts') +type CreateDeploymentStatus = typeof import('../../src/functions/deployment.ts') +type Label = typeof import('../../src/functions/label.ts') +type Lock = typeof import('../../src/functions/lock.ts') +type PostDeployMessage = + typeof import('../../src/functions/post-deploy-message.ts') +type TrustedDeploymentTemplate = + typeof import('../../src/functions/trusted-deployment-template.ts') +type UnlockIfUnchanged = + typeof import('../../src/functions/unlock-if-unchanged.ts') + +const actualCore = await import('../../src/actions-core.ts') + +const debugMock = createMock() +const infoMock = createMock() +const warningMock = createMock() +const setOutputMock = createMock() +const actionStatusMock = createMock() +const createDeploymentStatusMock = + createMock() +const labelMock = createMock() +const lockMock = createMock() +const postDeployMessageMock = + createMock() +const loadTrustedDeploymentTemplateMock = + createMock() +const unlockIfUnchangedMock = + createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + ...actualCore, + debug: debugMock, + info: infoMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../../src/functions/action-status.ts', import.meta.url), + {actionStatus: actionStatusMock} +) +installModuleMock( + mock, + new URL('../../src/functions/deployment.ts', import.meta.url), + {createDeploymentStatus: createDeploymentStatusMock} +) +installModuleMock( + mock, + new URL('../../src/functions/label.ts', import.meta.url), + {label: labelMock} +) +installModuleMock( + mock, + new URL('../../src/functions/lock.ts', import.meta.url), + {lock: lockMock} +) +installModuleMock( + mock, + new URL('../../src/functions/post-deploy-message.ts', import.meta.url), + {postDeployMessage: postDeployMessageMock} +) +installModuleMock( + mock, + new URL( + '../../src/functions/trusted-deployment-template.ts', + import.meta.url + ), + {loadTrustedDeploymentTemplate: loadTrustedDeploymentTemplateMock} +) +installModuleMock( + mock, + new URL('../../src/functions/unlock-if-unchanged.ts', import.meta.url), + {unlockIfUnchanged: unlockIfUnchangedMock} +) + +const {postDeploy} = await import('../../src/functions/post-deploy.ts') + +const review_decision = 'APPROVED' + +function createLockResponse( + sticky: boolean +): Awaited> { + return { + environment: 'production', + global: false, + globalFlag: '', + lockData: { + branch: 'test-ref', + created_at: '2024-01-01T00:00:00Z', + created_by: 'monalisa', + environment: 'production', + global: false, + link: 'https://github.com/corp/test/pull/1', + reason: 'test', + sticky, + unlock_command: '.unlock production' + }, + status: 'owner' + } +} + +let octokit: PostDeployOctokit +let context: IssueCommentContext & {readonly workflow: string} +let labels: DeepMutable +let data: DeepMutable + +beforeEach(() => { + for (const mockFunction of [ + debugMock, + infoMock, + warningMock, + setOutputMock, + actionStatusMock, + createDeploymentStatusMock, + labelMock, + lockMock, + postDeployMessageMock, + loadTrustedDeploymentTemplateMock, + unlockIfUnchangedMock + ]) { + mockFunction.mock.resetCalls() + } + + actionStatusMock.mock.mockImplementation(() => Promise.resolve(undefined)) + labelMock.mock.mockImplementation(() => + Promise.resolve({added: [], removed: []}) + ) + postDeployMessageMock.mock.mockImplementation(() => 'Updated 1 server') + loadTrustedDeploymentTemplateMock.mock.mockImplementation(() => + Promise.resolve(null) + ) + delete process.env['INPUT_DEPLOY_MESSAGE_PATH'] + lockMock.mock.mockImplementation(() => + Promise.resolve(createLockResponse(true)) + ) + createDeploymentStatusMock.mock.mockImplementation(() => + Promise.resolve({ + url: 'https://api.github.com/deployment-status/1', + id: 1 + }) + ) + unlockIfUnchangedMock.mock.mockImplementation(() => Promise.resolve(true)) + + context = { + ...createIssueCommentContext({ + actor: 'monalisa', + repo: {owner: 'corp', repo: 'test'}, + issue: {number: 1}, + payload: {comment: {id: 1}} + }), + workflow: 'test-workflow' + } + + octokit = createOctokit() + + labels = { + successful_deploy: [], + successful_noop: [], + failed_deploy: [], + failed_noop: [], + skip_successful_noop_labels_if_approved: false, + skip_successful_deploy_labels_if_approved: false + } + + data = { + sha: 'abc123', + ref: 'test-ref', + comment_id: '123', + reaction_id: '12345', + status: 'success', + noop: false, + deployment_id: '456', + environment: 'production', + environment_url: null, + approved_reviews_count: '1', + labels, + review_decision, + fork: false, + params: 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432', + parsed_params: JSON.stringify({ + config: {db: {host: 'localhost', port: 5432}}, + _: ['LOG_LEVEL=debug'] + }), + commit_verified: false, + deployment_start_time: '2024-01-01T00:00:00Z', + disable_lock: false, + lock_ref_sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + trusted_sha: '0123456789abcdef0123456789abcdef01234567' + } +}) + +test('successfully completes a production branch deployment', async () => { + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) + assertCalledWith( + createDeploymentStatusMock, + octokit, + context, + 'test-ref', + 'success', + '456', + 'production', + null + ) +}) + +test('loads the configured template at the trusted workflow SHA', async () => { + process.env['INPUT_DEPLOY_MESSAGE_PATH'] = '.github/deployment_message.md' + loadTrustedDeploymentTemplateMock.mock.mockImplementation(() => + Promise.resolve('trusted template') + ) + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + assertCalledWith( + loadTrustedDeploymentTemplateMock, + octokit, + context, + '.github/deployment_message.md', + '0123456789abcdef0123456789abcdef01234567' + ) + const messageCall = postDeployMessageMock.mock.calls[0] + assert.ok(messageCall) + assert.strictEqual(messageCall.arguments[2], 'trusted template') +}) + +test('successfully completes a production branch deployment that fails', async () => { + data.status = 'failure' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'failure' + }) + assertCalledWith( + createDeploymentStatusMock, + octokit, + context, + 'test-ref', + 'failure', + '456', + 'production', + null + ) +}) + +test('successfully completes a production branch deployment with an environment url', async () => { + data.environment_url = 'https://example.com' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) + assertCalledWith( + createDeploymentStatusMock, + octokit, + context, + 'test-ref', + 'success', + '456', + 'production', + 'https://example.com' + ) +}) + +test('successfully completes a production branch deployment and removes a non-sticky lock', async () => { + lockMock.mock.mockImplementation(() => + Promise.resolve(createLockResponse(false)) + ) + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + + assertCalledTimes(lockMock, 1) + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) + assertCalledWith( + createDeploymentStatusMock, + octokit, + context, + 'test-ref', + 'success', + '456', + 'production', + null + ) + assertCalledWith( + infoMock, + `๐Ÿงน ${COLORS.highlight}non-sticky${COLORS.reset} lock detected, will remove lock` + ) + assertCalledWith( + unlockIfUnchangedMock, + octokit, + context, + 'production', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ) +}) + +test('successfully completes a noop branch deployment and removes a non-sticky lock', async () => { + lockMock.mock.mockImplementation(() => + Promise.resolve(createLockResponse(false)) + ) + data.noop = true + + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') + + assertCalledTimes(lockMock, 1) + assertCalledWith(lockMock, { + octokit, + context, + ref: null, + reactionId: null, + sticky: false, + environment: 'production', + mode: {type: 'details', postDeployStep: true}, + leaveComment: true + }) + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) + assertCalledWith( + infoMock, + `๐Ÿงน ${COLORS.highlight}non-sticky${COLORS.reset} lock detected, will remove lock` + ) + assertCalledWith( + unlockIfUnchangedMock, + octokit, + context, + 'production', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ) +}) + +for (const lockRefSha of [undefined, null, '']) { + test(`leaves a non-sticky lock in place when the saved ref SHA is ${String(lockRefSha)}`, async () => { + lockMock.mock.mockImplementation(() => + Promise.resolve(createLockResponse(false)) + ) + data.lock_ref_sha = lockRefSha + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + assertNotCalled(unlockIfUnchangedMock) + assertCalledWith( + warningMock, + 'could not remove the deployment lock because its original ref SHA was not saved; leaving the current lock in place' + ) + }) +} + +test('successfully completes a noop branch deployment but does not get any lock data', async () => { + lockMock.mock.mockImplementation(() => + Promise.resolve({ + environment: 'production', + global: false, + globalFlag: '', + lockData: null, + status: null + }) + ) + data.noop = true + + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') + + assertCalledTimes(lockMock, 1) + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) + assertCalledWith( + warningMock, + '๐Ÿ’ก a request to obtain the lock data returned null or undefined - the lock may have been removed by another process while this Action was running' + ) +}) + +for (const noop of [false, true]) { + test(`stops ${noop ? 'noop' : 'deployment'} post processing for an ambiguous lock`, async () => { + lockMock.mock.mockImplementation(() => + Promise.resolve({ + environment: 'production', + global: false, + globalFlag: '', + lockData: null, + status: 'ambiguous' + }) + ) + data.noop = noop + + assert.strictEqual(await postDeploy(context, octokit, data), undefined) + assertNotCalled(unlockIfUnchangedMock) + assertNotCalled(labelMock) + assert.ok( + !infoMock.mock.calls.some(call => + String(call.arguments[0]).includes('post deploy completed') + ) + ) + }) +} + +test('successfully completes a production branch deployment with no custom message', async () => { + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) +}) + +for (const noop of [false, true]) { + test(`skips ${noop ? 'noop' : 'deployment'} lock completion when locking is disabled`, async () => { + data.disable_lock = true + data.noop = noop + + assert.strictEqual( + await postDeploy(context, octokit, data), + noop ? 'success - noop' : 'success' + ) + assertNotCalled(lockMock) + assertNotCalled(unlockIfUnchangedMock) + assertCalledWith( + infoMock, + '๐Ÿ”“ deployment locking is disabled; skipping lock completion' + ) + assertCalledWith(actionStatusMock, { + context, + octokit, + reactionId: 12345, + message: 'Updated 1 server', + result: 'success' + }) + assertCalledTimes(labelMock, 1) + if (noop) assertNotCalled(createDeploymentStatusMock) + else assertCalledTimes(createDeploymentStatusMock, 1) + }) +} + +test('successfully completes a noop branch deployment', async () => { + data.noop = true + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') +}) + +test('successfully completes a noop branch deployment and applies success labels', async () => { + data.labels.successful_noop = ['ready-for-review', 'noop-success'] + data.noop = true + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') +}) + +test('successfully completes a noop branch deployment and does not apply labels due to skip config', async () => { + data.labels.successful_noop = ['ready-for-review', 'noop-success'] + data.labels.skip_successful_noop_labels_if_approved = true + data.noop = true + + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') + + assertCalledWith( + infoMock, + `โฉ skipping noop labels since the pull request is ${COLORS.success}approved${COLORS.reset} (based on your configuration)` + ) +}) + +test('successfully completes a branch deployment and does not apply labels due to skip config', async () => { + data.labels.successful_deploy = ['ready-to-merge', 'deploy-success'] + data.labels.skip_successful_deploy_labels_if_approved = true + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + + assertCalledWith( + infoMock, + `โฉ skipping deploy labels since the pull request is ${COLORS.success}approved${COLORS.reset} (based on your configuration)` + ) +}) + +test('applies failed noop labels even when successful-label skipping is enabled', async () => { + data.labels.failed_noop = ['noop-failed'] + data.labels.skip_successful_noop_labels_if_approved = true + data.noop = true + data.status = 'failure' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') + assertCalledWith(labelMock, context, octokit, ['noop-failed'], []) +}) + +test('applies failed deploy labels even when successful-label skipping is enabled', async () => { + data.labels.failed_deploy = ['deploy-failed'] + data.labels.skip_successful_deploy_labels_if_approved = true + data.status = 'failure' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + assertCalledWith(labelMock, context, octokit, ['deploy-failed'], []) +}) + +test('tolerates an already-removed deployment lock', async () => { + lockMock.mock.mockImplementation(() => + Promise.resolve({ + environment: 'production', + global: false, + globalFlag: '', + lockData: null, + status: null + }) + ) + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') + assertNotCalled(unlockIfUnchangedMock) + assertCalledWith( + warningMock, + '๐Ÿ’ก a request to obtain the lock data returned null or undefined - the lock may have been removed by another process while this Action was running' + ) +}) + +test('successfully completes a noop branch deployment that fails and applies failure labels', async () => { + data.labels.failed_noop = ['help', 'oh-no'] + data.noop = true + data.status = 'failure' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success - noop') + + assertCalledWith(debugMock, 'deploymentStatus: failure') + assertCalledWith(debugMock, 'deployment mode: noop') +}) + +test('updates with a failure for a production branch deployment', async () => { + data.status = 'failure' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') +}) + +test('updates with an unknown for a production branch deployment', async () => { + data.status = 'unknown' + + assert.strictEqual(await postDeploy(context, octokit, data), 'success') +}) + +for (const noop of [false, true]) { + for (const status of ['success', 'failure'] as const) { + for (const failurePoint of ['template', 'render', 'comment'] as const) { + test(`completes ${status} ${noop ? 'noop' : 'deployment'} cleanup when post ${failurePoint} fails`, async () => { + const error = new Error(`${failurePoint} unavailable`) + data.noop = noop + data.status = status + data.labels.successful_deploy = ['deploy-success'] + data.labels.failed_deploy = ['deploy-failed'] + data.labels.successful_noop = ['noop-success'] + data.labels.failed_noop = ['noop-failed'] + lockMock.mock.mockImplementation(() => + Promise.resolve(createLockResponse(false)) + ) + + if (failurePoint === 'template') { + process.env['INPUT_DEPLOY_MESSAGE_PATH'] = + '.github/deployment_message.md' + loadTrustedDeploymentTemplateMock.mock.mockImplementation(() => + Promise.reject(error) + ) + } else if (failurePoint === 'render') { + postDeployMessageMock.mock.mockImplementation(() => { + throw error + }) + } else { + actionStatusMock.mock.mockImplementation(() => Promise.reject(error)) + } + + await assert.rejects( + postDeploy(context, octokit, data), + candidate => candidate === error + ) + assertCalledWith( + unlockIfUnchangedMock, + octokit, + context, + 'production', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ) + assertCalledWith( + labelMock, + context, + octokit, + [ + noop + ? status === 'success' + ? 'noop-success' + : 'noop-failed' + : status === 'success' + ? 'deploy-success' + : 'deploy-failed' + ], + [ + noop + ? status === 'success' + ? 'noop-failed' + : 'noop-success' + : status === 'success' + ? 'deploy-failed' + : 'deploy-success' + ] + ) + if (noop) { + assertNotCalled(createDeploymentStatusMock) + } else { + assertCalledWith( + createDeploymentStatusMock, + octokit, + context, + 'test-ref', + status, + '456', + 'production', + null + ) + } + }) + } + } +} + +test('fails due to no comment_id', async () => { + data.comment_id = '' + + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no comment_id provided' + }) +}) + +test('fails due to no status', async () => { + data.status = '' + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no status provided' + }) +}) + +test('fails due to no ref', async () => { + data.ref = '' + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no ref provided' + }) +}) + +test('fails due to no deployment_id', async () => { + data.deployment_id = '' + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no deployment_id provided' + }) +}) + +test('fails due to no environment', async () => { + data.environment = '' + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no environment provided' + }) +}) + +test('skips decorative reaction handling when reaction_id is empty', async () => { + data.reaction_id = '' + await postDeploy(context, octokit, data) + const actionStatusRequest = actionStatusMock.mock.calls.at(-1)?.arguments[0] + assert.strictEqual(actionStatusRequest?.reactionId, null) +}) + +test('fails due to no environment (noop)', async () => { + data.environment = '' + data.noop = true + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no environment provided' + }) +}) + +test('fails due to no noop', async () => { + data.noop = unsafeInvalidValue(null) + await assert.rejects(postDeploy(context, octokit, data), { + message: 'no noop value provided' + }) +}) diff --git a/__tests__/functions/post.test.js b/__tests__/functions/post.test.js deleted file mode 100644 index ae51b277..00000000 --- a/__tests__/functions/post.test.js +++ /dev/null @@ -1,139 +0,0 @@ -import * as github from '@actions/github' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' - -import {post} from '../../src/functions/post.js' -import {COLORS} from '../../src/functions/colors.js' -import * as postDeploy from '../../src/functions/post-deploy.js' -import * as contextCheck from '../../src/functions/context-check.js' - -vi.mock('@actions/github', {spy: true}) - -const validBooleanInputs = { - skip_completing: false -} -const validInputs = { - status: 'success', - successful_deploy_labels: '', - successful_noop_labels: '', - failed_deploy_labels: '', - failed_noop_labels: '', - skip_successful_noop_labels_if_approved: 'false', - skip_successful_deploy_labels_if_approved: 'false' -} - -const validStates = { - sha: 'abc123', - ref: 'test-ref', - comment_id: '123', - noop: 'false', - deployment_id: '456', - environment: 'production', - token: 'test-token', - approved_reviews_count: '1', - environment_url: 'https://example.com', - review_decision: 'APPROVED', - fork: 'false', - params: 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432', - parsed_params: JSON.stringify({ - config: {db: {host: 'localhost', port: 5432}}, - _: ['LOG_LEVEL=debug'] - }), - deployment_start_time: '2024-01-01T00:00:00Z' -} - -const setFailedMock = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) -const setWarningMock = vi.spyOn(core, 'warning').mockImplementation(() => {}) -const infoMock = vi.spyOn(core, 'info').mockImplementation(() => {}) - -beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(core, 'error').mockImplementation(() => {}) - vi.spyOn(core, 'debug').mockImplementation(() => {}) - vi.spyOn(core, 'getBooleanInput').mockImplementation(name => { - return validBooleanInputs[name] - }) - vi.spyOn(core, 'getInput').mockImplementation(name => { - return validInputs[name] - }) - vi.spyOn(core, 'getState').mockImplementation(name => { - return validStates[name] - }) - - vi.spyOn(postDeploy, 'postDeploy').mockImplementation(() => { - return undefined - }) - - vi.spyOn(contextCheck, 'contextCheck').mockImplementation(() => { - return true - }) - - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return true - }) -}) - -test('successfully runs post() Action logic', async () => { - expect(await post()).toBeUndefined() - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit SHA: ${COLORS.highlight}${validStates.sha}${COLORS.reset}` - ) -}) - -test('successfully runs post() Action logic when environment_url is not defined', async () => { - const noEnvironmentUrl = { - environment_url: null - } - - vi.spyOn(core, 'getState').mockImplementation(name => { - return noEnvironmentUrl[name] - }) - - expect(await post()).toBeUndefined() -}) - -test('exits due to an invalid Actions context', async () => { - vi.spyOn(contextCheck, 'contextCheck').mockImplementation(() => { - return false - }) - - expect(await post()).toBeUndefined() -}) - -test('exits due to a bypass being set', async () => { - const bypassed = { - bypass: 'true' - } - vi.spyOn(core, 'getState').mockImplementation(name => { - return bypassed[name] - }) - expect(await post()).toBeUndefined() - expect(setWarningMock).toHaveBeenCalledWith( - `โ›” ${COLORS.highlight}bypass${COLORS.reset} set, exiting` - ) -}) - -test('skips the process of completing a deployment', async () => { - const skipped = { - skip_completing: 'true' - } - vi.spyOn(core, 'getBooleanInput').mockImplementation(name => { - return skipped[name] - }) - expect(await post()).toBeUndefined() - expect(infoMock).toHaveBeenCalledWith( - `โฉ ${COLORS.highlight}skip_completing${COLORS.reset} set, exiting` - ) -}) - -test('throws an error', async () => { - try { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - throw new Error('test error') - }) - await post() - } catch (e) { - expect(e.message).toBe('test error') - expect(setFailedMock).toHaveBeenCalledWith('test error') - } -}) diff --git a/__tests__/functions/post.test.ts b/__tests__/functions/post.test.ts new file mode 100644 index 00000000..1ff5a63e --- /dev/null +++ b/__tests__/functions/post.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import * as github from '@actions/github' +import type {ActionInputKey, ActionStateKey} from '../../src/action-io.ts' +import {COLORS} from '../../src/functions/colors.ts' +import {createOctokit} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from '../node-test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionIo = typeof import('../../src/action-io.ts') +type ContextCheck = typeof import('../../src/functions/context-check.ts') +type PostDeploy = typeof import('../../src/functions/post-deploy.ts') + +const errorMock = createMock() +const debugMock = createMock() +const infoMock = createMock() +const setFailedMock = createMock() +const warningMock = createMock() +const getActionInputMock = createMock() +const getActionStateMock = createMock() +const getBooleanActionInputMock = + createMock() +const contextCheckMock = createMock() +const postDeployMock = createMock() +const getOctokitMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: errorMock, + info: infoMock, + setFailed: setFailedMock, + warning: warningMock +}) +installModuleMock(mock, new URL('../../src/action-io.ts', import.meta.url), { + getActionInput: getActionInputMock, + getActionState: getActionStateMock, + getBooleanActionInput: getBooleanActionInputMock +}) +installModuleMock( + mock, + new URL('../../src/functions/context-check.ts', import.meta.url), + {contextCheck: contextCheckMock} +) +installModuleMock( + mock, + new URL('../../src/functions/post-deploy.ts', import.meta.url), + {postDeploy: postDeployMock} +) +installModuleMock(mock, '@actions/github', { + context: github.context, + getOctokit: getOctokitMock +}) + +const {post} = await import('../../src/functions/post.ts') + +const validBooleanInputs: Partial> = { + skip_completing: false +} +const validInputs: Partial> = { + status: 'success', + successful_deploy_labels: '', + successful_noop_labels: '', + failed_deploy_labels: '', + failed_noop_labels: '', + skip_successful_noop_labels_if_approved: 'false', + skip_successful_deploy_labels_if_approved: 'false' +} + +const validStates: Record = { + actionsToken: 'test-token', + bypass: 'false', + sha: 'abc123', + ref: 'test-ref', + comment_id: '123', + reaction_id: '12345', + noop: 'false', + deployment_id: '456', + environment: 'production', + approved_reviews_count: '1', + environment_url: 'https://example.com', + review_decision: 'APPROVED', + fork: 'false', + commit_verified: 'false', + initial_comment_id: '123', + isPost: 'true', + lock_ref_sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + trusted_sha: '0123456789abcdef0123456789abcdef01234567', + params: 'LOG_LEVEL=debug --config.db.host=localhost --config.db.port=5432', + parsed_params: JSON.stringify({ + config: {db: {host: 'localhost', port: 5432}}, + _: ['LOG_LEVEL=debug'] + }), + deployment_start_time: '2024-01-01T00:00:00Z', + disable_lock: 'false' +} + +beforeEach(() => { + for (const mockFunction of [ + errorMock, + debugMock, + infoMock, + setFailedMock, + warningMock, + getActionInputMock, + getActionStateMock, + getBooleanActionInputMock, + contextCheckMock, + postDeployMock, + getOctokitMock + ]) { + mockFunction.mock.resetCalls() + } + + getBooleanActionInputMock.mock.mockImplementation( + name => validBooleanInputs[name] ?? false + ) + getActionInputMock.mock.mockImplementation(name => validInputs[name] ?? '') + getActionStateMock.mock.mockImplementation(name => validStates[name]) + postDeployMock.mock.mockImplementation(() => Promise.resolve(undefined)) + contextCheckMock.mock.mockImplementation(() => true) + getOctokitMock.mock.mockImplementation(() => createOctokit()) +}) + +test('successfully runs post() Action logic', async () => { + assert.strictEqual(await post(), undefined) + assertCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit SHA: ${COLORS.highlight}${validStates.sha}${COLORS.reset}` + ) + assert.strictEqual( + postDeployMock.mock.calls.at(-1)?.arguments[2].disable_lock, + false + ) +}) + +test('passes the saved disable_lock state to post deployment', async () => { + getActionStateMock.mock.mockImplementation(name => + name === 'disable_lock' ? 'true' : validStates[name] + ) + + assert.strictEqual(await post(), undefined) + assert.strictEqual( + postDeployMock.mock.calls.at(-1)?.arguments[2].disable_lock, + true + ) +}) + +test('passes the saved lock ref SHA to post deployment', async () => { + assert.strictEqual(await post(), undefined) + assert.strictEqual( + postDeployMock.mock.calls.at(-1)?.arguments[2].lock_ref_sha, + validStates.lock_ref_sha + ) +}) + +test('successfully runs post() Action logic when environment_url is not defined', async () => { + getActionStateMock.mock.mockImplementation(name => + name === 'environment_url' + ? unsafeInvalidValue(null) + : validStates[name] + ) + + assert.strictEqual(await post(), undefined) + assertCalledWith(debugMock, 'environment_url not set, its value is null') +}) + +test('exits due to an invalid Actions context', async () => { + contextCheckMock.mock.mockImplementation(() => false) + + assert.strictEqual(await post(), undefined) +}) + +test('exits due to a bypass being set', async () => { + const bypassed: Partial> = { + bypass: 'true' + } + getActionStateMock.mock.mockImplementation( + name => bypassed[name] ?? validStates[name] + ) + + assert.strictEqual(await post(), undefined) + assertCalledWith( + warningMock, + `โ›” ${COLORS.highlight}bypass${COLORS.reset} set, exiting` + ) +}) + +test('skips the process of completing a deployment', async () => { + const skipped: Partial> = { + skip_completing: true + } + getBooleanActionInputMock.mock.mockImplementation( + name => skipped[name] ?? validBooleanInputs[name] ?? false + ) + + assert.strictEqual(await post(), undefined) + assertCalledWith( + infoMock, + `โฉ ${COLORS.highlight}skip_completing${COLORS.reset} set, exiting` + ) +}) + +test('reports an error', async () => { + getOctokitMock.mock.mockImplementation(() => { + throw new Error('test error') + }) + + assert.strictEqual(await post(), undefined) + assertCalledWith(setFailedMock, 'test error') +}) diff --git a/__tests__/functions/prechecks.test.js b/__tests__/functions/prechecks.test.js deleted file mode 100644 index e06bf60a..00000000 --- a/__tests__/functions/prechecks.test.js +++ /dev/null @@ -1,3883 +0,0 @@ -import {prechecks} from '../../src/functions/prechecks.js' -import {vi, expect, test, beforeEach} from 'vitest' -import {COLORS} from '../../src/functions/colors.js' -import * as isAdmin from '../../src/functions/admin.js' -import * as isOutdated from '../../src/functions/outdated-check.js' -import * as core from '@actions/core' - -// Globals for testing -const infoMock = vi.spyOn(core, 'info') -const warningMock = vi.spyOn(core, 'warning') -const debugMock = vi.spyOn(core, 'debug') -const setOutputMock = vi.spyOn(core, 'setOutput') - -var context -var getCollabOK -var getPullsOK -var graphQLOK -var octokit -var data -var baseCommitWithOid - -beforeEach(() => { - vi.clearAllMocks() - process.env.INPUT_PERMISSIONS = 'admin,write' - - baseCommitWithOid = { - nodes: [ - { - commit: { - oid: 'abc123' - } - } - ] - } - - data = { - environment: 'production', - environmentObj: { - target: 'production', - stable_branch_used: false, - noop: false, - params: null, - sha: null - }, - issue_number: '123', - inputs: { - allow_sha_deployments: false, - update_branch: 'disabled', - stable_branch: 'main', - trigger: '.deploy', - allowForks: true, - skipCi: '', - skipReviews: '', - draft_permitted_targets: '', - checks: 'all', - permissions: ['admin', 'write'], - commit_verification: false, - ignored_checks: [], - use_security_warnings: true, - allow_non_default_target_branch_deployments: false - } - } - - context = { - actor: 'monalisa', - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 123 - } - } - - getCollabOK = vi - .fn() - .mockReturnValue({data: {permission: 'write'}, status: 200}) - getPullsOK = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - graphQLOK = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - signature: null, - checkSuites: { - totalCount: 3 - }, - statusCheckRollup: { - state: 'SUCCESS', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'SUCCESS', - name: 'build' - } - ] - } - } - } - } - ] - } - } - } - }) - - octokit = { - rest: { - repos: { - getCollaboratorPermissionLevel: getCollabOK - }, - pulls: { - get: getPullsOK - } - }, - graphql: graphQLOK - } - - // mock the request for fetching the baseBranch variable - octokit.rest.repos.getBranch = vi.fn().mockReturnValue({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'test-branch' - }, - status: 200 - }) - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: false, branch: 'test-branch'} - }) - - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment', async () => { - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with required checks', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 3 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = 'required' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with required checks and some ignored checks', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 4 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = 'required' - data.inputs.ignored_checks = ['markdown-lint'] - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with a few explictly requested checks and a few ignored checks', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: false, - conclusion: 'SUCCESS', - name: 'acceptance-test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = ['test', 'acceptance-test', 'lint'] - data.inputs.ignored_checks = ['lint'] - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: test' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: acceptance-test' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: lint' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - markdown-lint is not in the explicit list of checks to include (test,acceptance-test,lint)' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with a few explictly requested checks and a few ignored checks but one CI check is missing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: false, - conclusion: 'SUCCESS', - name: 'acceptance-test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = ['test', 'acceptance-test', 'quality-control', 'lint'] - data.inputs.ignored_checks = ['lint'] - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `MISSING`\n\n> The `checks` input option requires that all of the following checks are passing: `test,acceptance-test,quality-control,lint`. However, the following checks are missing: `quality-control`', - status: false - }) - - expect(warningMock).toHaveBeenCalledWith( - `the ${COLORS.info}checks${COLORS.reset} input option requires that all of the following checks are passing: ${COLORS.highlight}${data.inputs.checks.join(', ')}${COLORS.reset} - however, the following checks are missing: ${COLORS.highlight}quality-control${COLORS.reset}` - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: test' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: acceptance-test' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: lint' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - markdown-lint is not in the explicit list of checks to include (test,acceptance-test,lint)' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment but checks and ignore checks cancel eachother out', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: false, - conclusion: 'SUCCESS', - name: 'acceptance-test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = [ - 'test', - 'acceptance-test', - 'lint', - 'markdown-lint', - 'build' - ] - data.inputs.ignored_checks = [ - 'markdown-lint', - 'lint', - 'build', - 'test', - 'acceptance-test' - ] - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: test' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: acceptance-test' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: lint' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: markdown-lint' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - explicitly including ci check: build' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: lint' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: build' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: test' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: acceptance-test' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - after filtering, no checks remain - this will result in a SUCCESS state as it is treated as if no checks are defined' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required but the user has provided some checks to ignore', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'NEUTRAL', - name: 'acceptance-test' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = 'all' - data.inputs.ignored_checks = ['markdown-lint', 'build'] - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: build' - ) - expect(debugMock).toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required but the user has provided some checks to ignore', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'NEUTRAL', - name: 'acceptance-test' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = [] // if the array is empty, this essentially says "include all checks" - data.inputs.ignored_checks = [] // if the array is empty, this essentially says "don't ignore any checks" - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', - status: false - }) - - expect(debugMock).not.toHaveBeenCalledWith( - 'explicitly including ci check: test' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: build' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required but the user has provided some checks to ignore but none match', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'NEUTRAL', - name: 'acceptance-test' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = 'all' - data.inputs.ignored_checks = ['xyz', 'abc'] - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', - status: false - }) - - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: build' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required and the user did not provided checks to ignore and some are failing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'CLEAN', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 5 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS', - name: 'test' - }, - { - isRequired: true, - conclusion: 'SKIPPED', - name: 'lint' - }, - { - isRequired: false, - conclusion: 'NEUTRAL', - name: 'acceptance-test' - }, - { - isRequired: false, - conclusion: 'FAILURE', - name: 'build' - }, - { - isRequired: true, - conclusion: 'FAILURE', - name: 'markdown-lint' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = 'all' - data.inputs.ignored_checks = null - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', - status: false - }) - - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: build' - ) - expect(debugMock).not.toHaveBeenCalledWith( - 'filterChecks() - ignoring ci check: markdown-lint' - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a rollback deployment', async () => { - octokit.rest.repos.getBranch = vi.fn().mockReturnValue({ - data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, - status: 200 - }) - - data.environmentObj.stable_branch_used = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… deployment to the ${COLORS.highlight}stable${COLORS.reset} branch requested`, - noopMode: false, - ref: 'main', - status: true, - sha: 'deadbeef', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a noop deployment', async () => { - data.environmentObj.noop = true - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: true, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds the commit fetched via the rest call does not match the commit returned from the graphql call', async () => { - octokit.graphql = vi.fn().mockReturnValueOnce({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - commits: { - nodes: [ - { - commit: { - oid: 'evilcommit123' - } - } - ] - } - } - } - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\nThe commit sha from the PR head does not match the commit sha from the graphql query\n\n- sha: `abc123`\n- commit_oid: `evilcommit123`\n\nThis is unexpected and could be caused by a commit being pushed to the branch after the initial rest call was made. Please review your PR timeline and try again.', - status: false - }) -}) - -test('runs prechecks and finds that the IssueOps command is valid without defined CI checks', async () => { - octokit.graphql = vi.fn().mockReturnValueOnce({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - commits: baseCommitWithOid - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… CI checks have not been defined but the PR has been approved', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(debugMock).toHaveBeenCalledWith( - `could not retrieve PR commit status: TypeError: Cannot read properties of undefined (reading 'totalCount') - Handled: ${COLORS.success}OK` - ) - expect(debugMock).toHaveBeenCalledWith( - 'this repo may not have any CI checks defined' - ) - expect(debugMock).toHaveBeenCalledWith( - 'skipping commit status check and proceeding...' - ) -}) - -test('runs prechecks and fails due to bad user permissions', async () => { - octokit.rest.repos.getCollaboratorPermissionLevel = vi - .fn() - .mockReturnValueOnce({data: {permission: 'read'}, status: 200}) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '๐Ÿ‘‹ @monalisa, that command requires the following permission(s): `admin/write`\n\nYour current permissions: `read`', - status: false - }) -}) - -test('runs prechecks and fails due to a bad pull request', async () => { - octokit.rest.pulls.get = vi.fn().mockReturnValueOnce({status: 500}) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'Could not retrieve PR info: 500', - status: false - }) -}) - -// Review checks and CI checks - -test('runs prechecks and finds that reviews and CI checks have not been defined', async () => { - octokit.graphql = vi.fn().mockReturnValueOnce({ - repository: { - pullRequest: { - reviewDecision: null, - commits: baseCommitWithOid - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(debugMock).toHaveBeenCalledWith( - `could not retrieve PR commit status: TypeError: Cannot read properties of undefined (reading 'totalCount') - Handled: ${COLORS.success}OK` - ) - expect(debugMock).toHaveBeenCalledWith( - 'this repo may not have any CI checks defined' - ) - expect(debugMock).toHaveBeenCalledWith( - 'skipping commit status check and proceeding...' - ) - expect(infoMock).toHaveBeenCalledWith( - '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined' - ) -}) - -test('runs prechecks and finds CI checks pass but reviews are not defined', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '๐ŸŽ›๏ธ CI checks have been defined but required reviewers have not been defined', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(infoMock).toHaveBeenLastCalledWith( - '๐ŸŽ›๏ธ CI checks have been defined but required reviewers have not been defined' - ) -}) - -test('runs prechecks and finds CI is passing and the PR has not been reviewed BUT it is a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… all CI checks passed and ${COLORS.highlight}noop${COLORS.reset} deployment requested`, - status: true, - noopMode: true, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment and is from a forked repository', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - status: true, - noopMode: false, - ref: 'abcde12345', - sha: 'abcde12345', - isFork: true - }) - - expect(setOutputMock).not.toHaveBeenCalledWith( - 'non_default_target_branch_used', - 'true' - ) -}) - -test('runs prechecks and finds that the PR from a fork is targeting a non-default branch and rejects the deployment', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'some-other-branch' - } - }, - status: 200 - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\nThis pull request is attempting to merge into the \`some-other-branch\` branch which is not the default branch of this repository (\`${data.inputs.stable_branch}\`). This deployment has been rejected since it could be dangerous to proceed.`, - status: false - }) - - expect(setOutputMock).toHaveBeenCalledWith( - 'non_default_target_branch_used', - 'true' - ) -}) - -test('runs prechecks and finds that the PR from a fork is targeting a non-default branch and allows it based on the action config', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'some-other-branch' - } - }, - status: 200 - }) - - data.inputs.allow_non_default_target_branch_deployments = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… PR is approved and all CI checks passed`, - status: true, - noopMode: false, - ref: 'abcde12345', - sha: 'abcde12345', - isFork: true - }) - - expect(setOutputMock).toHaveBeenCalledWith( - 'non_default_target_branch_used', - 'true' - ) -}) - -test('runs prechecks and finds that the PR is targeting a non-default branch and rejects the deployment', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - repo: { - fork: false - }, - base: { - ref: 'not-main' - } - }, - status: 200 - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\nThis pull request is attempting to merge into the \`not-main\` branch which is not the default branch of this repository (\`${data.inputs.stable_branch}\`). This deployment has been rejected since it could be dangerous to proceed.`, - status: false - }) - - expect(setOutputMock).toHaveBeenCalledWith( - 'non_default_target_branch_used', - 'true' - ) -}) - -test('runs prechecks and finds that the PR is targeting a non-default branch and allows the deployment based on the action config and logs a warning', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abcde12345' - }, - repo: { - fork: false - }, - base: { - ref: 'not-main' - } - }, - status: 200 - }) - - data.inputs.allow_non_default_target_branch_deployments = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… PR is approved and all CI checks passed`, - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abcde12345', - isFork: false - }) - - expect(setOutputMock).toHaveBeenCalledWith( - 'non_default_target_branch_used', - 'true' - ) - - expect(warningMock).toHaveBeenCalledWith( - `๐Ÿšจ this pull request is attempting to merge into the \`not-main\` branch which is not the default branch of this repository (\`${data.inputs.stable_branch}\`) - this action is potentially dangerous` - ) -}) - -test('runs prechecks and finds that the IssueOps command is valid for a branch deployment and is from a forked repository and the PR is approved but CI is failing and it is a noop', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 4 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', - status: false - }) -}) - -test('runs prechecks and finds that the IssueOps command is a fork and does not require reviews so it proceeds but with a warning', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '๐ŸŽ›๏ธ CI checks have been defined but required reviewers have not been defined', - status: true, - noopMode: false, - ref: 'abcde12345', - sha: 'abcde12345', - isFork: true - }) - - expect(warningMock).toHaveBeenCalledWith( - '๐Ÿšจ pull request reviews are not enforced by this repository and this operation is being performed on a fork - this operation is dangerous! You should require reviews via branch protection settings (or rulesets) to ensure that the changes being deployed are the changes that you reviewed.' - ) -}) - -test('runs prechecks and rejects a pull request from a forked repository because it does not have completed reviews', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - // Even admins cannot deploy from a forked repository without reviews - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - // Even with skipReviews set, the PR is from a forked repository and must have reviews out of pure safety - data.environment = 'staging' - data.inputs.skipReviews = 'staging' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n\n> All deployments from forks **must** have the required reviews before they can proceed. Please ensure this PR has been reviewed and approved before trying again.', - status: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'rejecting deployment from fork without required reviews - noopMode: false' - ) -}) - -test('runs prechecks and rejects a pull request from a forked repository because it does not have completed reviews (noop)', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - // Even admins cannot deploy from a forked repository without reviews - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - // Even with skipReviews set, the PR is from a forked repository and must have reviews out of pure safety - data.environment = 'staging' - data.inputs.skipReviews = 'staging' - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n\n> All deployments from forks **must** have the required reviews before they can proceed. Please ensure this PR has been reviewed and approved before trying again.', - status: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'rejecting deployment from fork without required reviews - noopMode: true' - ) -}) - -test('runs prechecks and rejects a pull request from a forked repository because it does not have completed reviews [CHANGES_REQUESTED] (noop)', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'CHANGES_REQUESTED', - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abcde12345', - checkSuites: { - totalCount: 8 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - label: 'test-repo:test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - // Even admins cannot deploy from a forked repository without reviews - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - // Even with skipReviews set, the PR is from a forked repository and must have reviews out of pure safety - data.environment = 'staging' - data.inputs.skipReviews = 'staging' - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n\n> All deployments from forks **must** have the required reviews before they can proceed. Please ensure this PR has been reviewed and approved before trying again.', - status: false - }) - - expect(debugMock).toHaveBeenCalledWith( - 'rejecting deployment from fork without required reviews - noopMode: true' - ) -}) - -test('runs prechecks and finds that the IssueOps command is on a PR from a forked repo and is not allowed', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 4 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - sha: 'abcde12345', - ref: 'test-ref', - repo: { - fork: true - } - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - data.inputs.allowForks = false - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\nThis Action has been explicity configured to prevent deployments from forks. You can change this via this Action's inputs if needed`, - status: false - }) -}) - -test('runs prechecks and finds CI is pending and the PR has not been reviewed BUT it is a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 2 - }, - statusCheckRollup: { - state: 'PENDING' - } - } - } - ] - } - } - } - }) - - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `PENDING`\n\n> Reviews are not required for a noop deployment but CI checks must be passing in order to continue', - status: false - }) -}) - -test('runs prechecks and finds CI checks are pending, the PR has not been reviewed, and it is not a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'PENDING' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `PENDING`\n\n> CI checks must be passing and the PR must be approved in order to continue', - status: false - }) -}) - -test('runs prechecks and finds CI is pending and reviewers have not been defined', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - reviews: { - totalCount: 0 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 3 - }, - statusCheckRollup: { - state: 'PENDING' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `null`\n- commitStatus: `PENDING`\n\n> CI checks must be passing in order to continue', - status: false - }) -}) - -test('runs prechecks and finds CI checked have not been defined, the PR has not been reviewed, and it IS a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 0 - }, - commits: baseCommitWithOid - } - } - }) - - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… CI checks have not been defined and ${COLORS.highlight}noop${COLORS.reset} requested`, - status: true, - noopMode: true, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and deploys to the stable branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - reviews: { - totalCount: 0 - } - } - } - }) - octokit.rest.repos.getBranch = vi.fn().mockReturnValue({ - data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, - status: 200 - }) - - data.environmentObj.stable_branch_used = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… deployment to the ${COLORS.highlight}stable${COLORS.reset} branch requested`, - status: true, - noopMode: false, - ref: 'main', - sha: 'deadbeef', - isFork: false - }) -}) - -test('runs prechecks and finds the PR has been approved but CI checks are pending and it is not a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 14 - }, - statusCheckRollup: { - state: 'PENDING' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `PENDING`\n\n> CI checks must be passing in order to continue', - status: false - }) -}) - -test('runs prechecks and finds CI is passing but the PR is missing an approval', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `SUCCESS`\n\n> CI checks are passing but an approval is required before you can proceed with deployment', - status: false - }) -}) - -test('runs prechecks and finds CI is passing but the PR is in a CHANGES_REQUESTED state for reviews', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'CHANGES_REQUESTED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n- commitStatus: `SUCCESS`\n\n> CI checks are passing but an approval is required before you can proceed with deployment', - status: false - }) - - // the same request works for a noop as changes requested is treated the same as no approval and approvals are not required for noops - data.environmentObj.noop = true - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… all CI checks passed and ${COLORS.highlight}noop${COLORS.reset} deployment requested`, - status: true, - noopMode: true, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds the PR is approved but CI is failing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', - status: false - }) -}) - -test('runs prechecks and finds the PR is in a changes requested state and CI is failing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'CHANGES_REQUESTED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n- commitStatus: `FAILURE`\n\n> Your pull request needs to address the requested changes, get approvals, and have passing CI checks before you can proceed with deployment', - status: false - }) -}) - -test('runs prechecks and finds the PR is in a REVIEW_REQUIRED state and CI is failing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `FAILURE`\n\n> Your pull request needs to get approvals and have passing CI checks before you can proceed with deployment', - status: false - }) -}) - -test('runs prechecks and finds the PR is in a changes requested state and has no CI checks defined', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'CHANGES_REQUESTED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 0 - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n- commitStatus: `null`\n\n> Your pull request is missing required approvals', - status: false - }) -}) - -test('runs prechecks and finds the PR is approved but CI is failing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 3 - }, - statusCheckRollup: { - state: 'FAILURE', - contexts: { - nodes: [ - { - isRequired: true, - conclusion: 'SUCCESS' - }, - { - isRequired: true, - conclusion: 'FAILURE' - }, - { - isRequired: false, - conclusion: 'SUCCESS' - } - ] - } - } - } - } - ] - } - } - } - }) - - data.inputs.checks = 'required' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', - status: false - }) -}) - -test('runs prechecks and finds the PR does not require approval but CI is failing', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `null`\n- commitStatus: `FAILURE`\n\n> Your pull request does not require approvals but CI checks are failing', - status: false - }) -}) - -test('runs prechecks and finds the PR is NOT reviewed and CI checks have NOT been defined and NOT a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: baseCommitWithOid - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `null`\n\n> Your pull request is missing required approvals', - status: false - }) -}) - -test('runs prechecks and finds the PR is approved and CI checks have NOT been defined and NOT a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: baseCommitWithOid - } - } - }) - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… CI checks have not been defined but the PR has been approved', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds the PR is behind the stable branch and a noop deploy and force updates the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue({ - data: { - message: 'Updating pull request branch.', - url: 'https://api.github.com/repos/foo/bar/pulls/123' - }, - status: 202 - }) - - data.inputs.update_branch = 'force' - data.environmentObj.noop = true - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `force`\n\n> I went ahead and updated your branch with `main` - Please try again once this operation is complete', - status: false - }) -}) - -test('runs prechecks and finds the PR is un-mergable and a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'DIRTY', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - data.environmentObj.noop = true - data.inputs.update_branch = 'warn' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n- mergeStateStatus: `DIRTY`\n\n> A merge commit cannot be cleanly created', - status: false - }) -}) - -test('runs prechecks and finds the PR is BEHIND and a noop deploy and it fails to update the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue({ - data: { - message: 'merge conflict between base and head', - url: 'https://api.github.com/repos/foo/bar/pulls/123' - }, - status: 422 - }) - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - data.environmentObj.noop = true - data.inputs.update_branch = 'force' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- update_branch http code: `422`\n- update_branch: `force`\n\n> Failed to update pull request branch with the `main` branch', - status: false - }) -}) - -test('runs prechecks and finds the PR is BEHIND and a noop deploy and it hits an error when force updating the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue(null) - - data.environmentObj.noop = true - data.inputs.update_branch = 'force' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - "### โš ๏ธ Cannot proceed with deployment\n\n```text\nCannot read properties of null (reading 'status')\n```", - status: false - }) -}) - -test('runs prechecks and finds the PR is BEHIND and a noop deploy and update_branch is set to warn', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - data.environmentObj.noop = true - data.inputs.update_branch = 'warn' - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\nYour branch is behind the base branch and will need to be updated before deployments can continue.\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `warn`\n\n> Please ensure your branch is up to date with the `main` branch and try again', - status: false - }) -}) - -test('runs prechecks and finds the PR is a DRAFT PR and a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BLOCKED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'main' - }, - draft: true - }, - status: 200 - }) - octokit.rest.repos.getBranch = vi.fn().mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, - status: 200 - }) - octokit.rest.repos.compareCommits = vi - .fn() - .mockReturnValueOnce({data: {behind_by: 0}, status: 200}) - - data.environmentObj.noop = true - data.inputs.update_branch = 'warn' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n> Your pull request is in a draft state', - status: false - }) - expect(warningMock).toHaveBeenCalledWith( - 'deployment requested on a draft PR from a non-allowed environment' - ) -}) - -test('runs prechecks and finds the PR is a DRAFT PR and from an allowed environment for draft deployments', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'CLEAN', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'main' - }, - draft: true // telling the test suite that our PR is in a draft state - }, - status: 200 - }) - - data.environment = 'staging' - data.inputs.update_branch = 'warn' - data.inputs.draft_permitted_targets = 'sandbox,staging' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds the PR is BEHIND and a noop deploy and the commit status is null', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILED' - } - } - } - ] - } - } - } - }) - - data.environmentObj.noop = true - data.inputs.update_branch = 'warn' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILED`\n\n> This is usually caused by missing PR approvals or CI checks failing', - status: false - }) -}) - -test('runs prechecks and finds the PR is BEHIND and a full deploy and update_branch is set to warn', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - data.inputs.update_branch = 'warn' - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\nYour branch is behind the base branch and will need to be updated before deployments can continue.\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `warn`\n\n> Please ensure your branch is up to date with the `main` branch and try again', - status: false - }) -}) - -test('runs prechecks and finds the PR is behind the stable branch and a full deploy and force updates the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - mergeStateStatus: 'BEHIND', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue({ - data: { - message: 'Updating pull request branch.', - url: 'https://api.github.com/repos/foo/bar/pulls/123' - }, - status: 202 - }) - - data.inputs.update_branch = 'force' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `force`\n\n> I went ahead and updated your branch with `main` - Please try again once this operation is complete', - status: false - }) -}) - -test('runs prechecks and fails with a non 200 permissionRes.status', async () => { - octokit.rest.repos.getCollaboratorPermissionLevel = vi - .fn() - .mockReturnValueOnce({data: {permission: 'admin'}, status: 500}) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'Permission check returns non-200 status: 500', - status: false - }) -}) - -test('runs prechecks and finds that the IssueOps commands are valid and from a defined admin', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… CI is passing and approval is bypassed due to admin rights', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps commands are valid with parameters and from a defined admin', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - data.environmentObj.params = 'something something something' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… CI is passing and approval is bypassed due to admin rights', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps commands are valid with parameters and from a defined admin', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - data.environmentObj.noop = true - data.environmentObj.params = 'something something something' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `โœ… all CI checks passed and ${COLORS.highlight}noop${COLORS.reset} deployment requested`, - noopMode: true, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds that the IssueOps commands are valid with parameters and from a defined admin when CI is not defined', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: null - } - } - } - ] - } - } - } - }) - - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI checks have not been defined and approval is bypassed due to admin rights', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenLastCalledWith( - 'โœ… CI checks have not been defined and approval is bypassed due to admin rights' - ) -}) - -test('runs prechecks and finds that no CI checks exist and reviews are not defined', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 0 - }, - statusCheckRollup: null - } - } - ] - } - } - } - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(infoMock).toHaveBeenLastCalledWith( - '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined' - ) -}) - -test('runs prechecks and finds that no CI checks exist but reviews are defined and it is from an admin', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 0 - }, - statusCheckRollup: null - } - } - ] - } - } - } - }) - - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI checks have not been defined and approval is bypassed due to admin rights', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(infoMock).toHaveBeenLastCalledWith( - 'โœ… CI checks have not been defined and approval is bypassed due to admin rights' - ) -}) - -test('runs prechecks and finds that no CI checks exist and the PR is not approved, but it is from an admin', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 0 - }, - statusCheckRollup: null - } - } - ] - } - } - } - }) - - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI checks have not been defined and approval is bypassed due to admin rights', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(infoMock).toHaveBeenLastCalledWith( - 'โœ… CI checks have not been defined and approval is bypassed due to admin rights' - ) -}) - -test('runs prechecks and finds that skip_ci is set and the PR has been approved', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - reviews: { - totalCount: 1 - }, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 0 - }, - statusCheckRollup: null - } - } - ] - } - } - } - }) - - data.environment = 'development' - data.inputs.skipCi = 'development' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI requirements have been disabled for this environment and the PR has been approved', - status: true, - noopMode: false, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI requirements have been disabled for this environment and the PR has been approved' - ) -}) - -test('runs prechecks and finds that the commit status is success and skip_reviews is set for the environment', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environment = 'staging' - data.inputs.skipReviews = 'staging' - data.inputs.skipCi = 'development' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI checks passed and required reviewers have been disabled for this environment', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI checks passed and required reviewers have been disabled for this environment' - ) -}) - -test('runs prechecks and finds that no ci checks are defined and skip_reviews is set for the environment', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 0 - }, - statusCheckRollup: null - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environment = 'staging' - data.inputs.skipReviews = 'staging' - data.inputs.skipCi = 'development' - data.inputs.draft_permitted_targets = 'development' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI checks have not been defined and required reviewers have been disabled for this environment', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI checks have not been defined and required reviewers have been disabled for this environment' - ) -}) - -test('runs prechecks on a custom deploy comment with a custom variable at the end', async () => { - data.environment = 'dev' - data.environmentObj.params = 'something' - data.inputs.skipCi = 'dev' - data.inputs.skipReviews = 'dev' - - expect( - await prechecks( - context, // event context - octokit, // octokit instance - data // data object - ) - ).toStrictEqual({ - message: - 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment' - ) -}) - -test('runs prechecks when an exact sha is set, but the sha deployment feature is not enabled', async () => { - data.inputs.allow_sha_deployments = false - data.environmentObj.sha = '82c238c277ca3df56fe9418a5913d9188eafe3bc' - - expect( - await prechecks( - context, // event context - octokit, // octokit instance - data // data object - ) - ).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\n- allow_sha_deployments: \`${data.inputs.allow_sha_deployments}\`\n\n> sha deployments have not been enabled`, - status: false - }) -}) - -test('runs prechecks when an exact sha is set, and the sha deployment feature is enabled', async () => { - data.inputs.allow_sha_deployments = true - data.environmentObj.sha = '82c238c277ca3df56fe9418a5913d9188eafe3bc' - - expect( - await prechecks( - context, // event context - octokit, // octokit instance - data // data object - ) - ).toStrictEqual({ - message: `โœ… deployment requested using an exact ${COLORS.highlight}sha${COLORS.reset}`, - noopMode: false, - ref: data.environmentObj.sha, - status: true, - sha: data.environmentObj.sha, - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - `โœ… deployment requested using an exact ${COLORS.highlight}sha${COLORS.reset}` - ) - - expect(warningMock).toHaveBeenCalledWith( - `โš ๏ธ sha deployments are ${COLORS.warning}unsafe${COLORS.reset} as they bypass all checks - read more here: https://github.com/github/branch-deploy/blob/main/docs/sha-deployments.md` - ) - - expect(setOutputMock).toHaveBeenCalledWith( - 'sha_deployment', - data.environmentObj.sha - ) -}) - -test('runs prechecks and finds that skip_ci is set and now reviews are defined', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environment = 'development' - data.inputs.skipCi = 'development' - data.inputs.skipReviews = 'staging' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '๐ŸŽ›๏ธ CI requirements have been disabled for this environment and required reviewers have not been defined', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - '๐ŸŽ›๏ธ CI requirements have been disabled for this environment and required reviewers have not been defined' - ) -}) - -test('runs prechecks and finds that skip_ci is set, reviews are required, and its a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environment = 'development' - data.environmentObj.noop = true - data.inputs.skipCi = 'development' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI requirements have been disabled for this environment and **noop** requested', - noopMode: true, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI requirements have been disabled for this environment and **noop** requested' - ) -}) - -test('runs prechecks and finds that skip_ci is set and skip_reviews is set', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environment = 'development' - data.inputs.skipCi = 'development' - data.inputs.skipReviews = 'development' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment' - ) -}) - -test('runs prechecks and finds that skip_ci is set and the deployer is an admin', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'FAILURE' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return true - }) - - data.environment = 'development' - data.inputs.skipCi = 'development' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - 'โœ… CI requirements have been disabled for this environment and approval is bypassed due to admin rights', - noopMode: false, - ref: 'test-ref', - status: true, - sha: 'abc123', - isFork: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'โœ… CI requirements have been disabled for this environment and approval is bypassed due to admin rights' - ) -}) - -test('runs prechecks and finds that CI is pending and reviewers have not been defined and it IS a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: null, - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'PENDING' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: \`null\`\n- commitStatus: \`PENDING\`\n\n> CI checks must be passing in order to continue`, - status: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'note: even noop deploys require CI to finish and be in a passing state' - ) -}) - -test('runs prechecks and finds that the PR is NOT reviewed and CI checks have been disabled and it is NOT a noop deploy', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'REVIEW_REQUIRED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'PENDING' - } - } - } - ] - } - } - } - }) - vi.spyOn(isAdmin, 'isAdmin').mockImplementation(() => { - return false - }) - - data.environment = 'staging' - data.inputs.skipCi = 'staging' - data.inputs.skipReviews = 'production' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: `### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: \`REVIEW_REQUIRED\`\n- commitStatus: \`skip_ci\`\n\n> Your pull request is missing required approvals`, - status: false - }) - - expect(infoMock).toHaveBeenCalledWith( - 'note: CI checks are disabled for this environment so they will not be evaluated' - ) -}) - -test('runs prechecks and finds the PR is behind the stable branch (BLOCKED) and a noop deploy and force updates the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'BLOCKED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - - vi.spyOn(isOutdated, 'isOutdated').mockImplementation(() => { - return {outdated: true, branch: 'main'} - }) - - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue({ - data: { - message: 'Updating pull request branch.', - url: 'https://api.github.com/repos/foo/bar/pulls/123' - }, - status: 202 - }) - - data.environmentObj.noop = true - data.inputs.update_branch = 'force' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: - '### โš ๏ธ Cannot proceed with deployment\n\n- mergeStateStatus: `BLOCKED`\n- update_branch: `force`\n\n> I went ahead and updated your branch with `main` - Please try again once this operation is complete', - status: false - }) -}) - -test('runs prechecks and finds the PR is NOT behind the stable branch (BLOCKED) and a noop deploy and does not update the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'BLOCKED', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - octokit.rest.repos.getBranch = vi.fn().mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, - status: 200 - }) - - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue({ - data: { - message: 'Updating pull request branch.', - url: 'https://api.github.com/repos/foo/bar/pulls/123' - }, - status: 202 - }) - - data.environmentObj.noop = true - data.inputs.update_branch = 'force' - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - status: true, - noopMode: true, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) -}) - -test('runs prechecks and finds the PR is NOT behind the stable branch (HAS_HOOKS) and a noop deploy and does not update the branch', async () => { - octokit.graphql = vi.fn().mockReturnValue({ - repository: { - pullRequest: { - reviewDecision: 'APPROVED', - mergeStateStatus: 'HAS_HOOKS', - commits: { - nodes: [ - { - commit: { - oid: 'abc123', - checkSuites: { - totalCount: 1 - }, - statusCheckRollup: { - state: 'SUCCESS' - } - } - } - ] - } - } - } - }) - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123' - }, - base: { - ref: 'main' - } - }, - status: 200 - }) - octokit.rest.repos.getBranch = vi.fn().mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, - status: 200 - }) - octokit.rest.pulls.updateBranch = vi.fn().mockReturnValue({ - data: { - message: 'Updating pull request branch.', - url: 'https://api.github.com/repos/foo/bar/pulls/123' - }, - status: 202 - }) - - data.environmentObj.noop = true - - expect(await prechecks(context, octokit, data)).toStrictEqual({ - message: 'โœ… PR is approved and all CI checks passed', - status: true, - noopMode: true, - ref: 'test-ref', - sha: 'abc123', - isFork: false - }) - - expect(setOutputMock).toHaveBeenCalledWith( - 'default_branch_tree_sha', - 'beefdead' - ) -}) - -// Tests for branch existence checks -class NotFoundError extends Error { - constructor(message) { - super(message) - this.status = 404 - } -} - -class UnexpectedError extends Error { - constructor(message) { - super(message) - this.status = 500 - } -} - -test('fails prechecks when the branch does not exist (deleted branch)', async () => { - // Mock getBranch to throw a 404 error for the PR branch check - octokit.rest.repos.getBranch = vi - .fn() - // First call: stable branch check (succeeds) - .mockReturnValueOnce({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'main' - }, - status: 200 - }) - // Second call: base branch check (succeeds) - .mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef'}, name: 'main'}, - status: 200 - }) - // Third call: PR branch check (fails with 404) - .mockRejectedValueOnce(new NotFoundError('Reference does not exist')) - - const result = await prechecks(context, octokit, data) - - expect(result.status).toBe(false) - expect(result.message).toContain('Cannot proceed with deployment') - expect(result.message).toContain('ref: `test-ref`') - expect(result.message).toContain( - 'The branch for this pull request no longer exists' - ) - expect(warningMock).toHaveBeenCalledWith('branch does not exist: test-ref') -}) - -test('passes prechecks when branch exists (normal deployment)', async () => { - // Mock getBranch to succeed for all calls - octokit.rest.repos.getBranch = vi - .fn() - // First call: stable branch check - .mockReturnValueOnce({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'main' - }, - status: 200 - }) - // Second call: base branch check - .mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef'}, name: 'main'}, - status: 200 - }) - // Third call: PR branch check (succeeds) - .mockReturnValueOnce({ - data: {commit: {sha: 'abc123'}, name: 'test-ref'}, - status: 200 - }) - - const result = await prechecks(context, octokit, data) - - expect(result.status).toBe(true) - expect(result.ref).toBe('test-ref') - expect(debugMock).toHaveBeenCalledWith('checking if branch exists: test-ref') - expect(infoMock).toHaveBeenCalledWith('โœ… branch exists: test-ref') -}) - -test('skips branch existence check when deploying to stable branch', async () => { - data.environmentObj.stable_branch_used = true - - // Mock getBranch - should only be called twice (not three times) - octokit.rest.repos.getBranch = vi - .fn() - .mockReturnValueOnce({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'main' - }, - status: 200 - }) - .mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef'}, name: 'main'}, - status: 200 - }) - - const result = await prechecks(context, octokit, data) - - expect(result.status).toBe(true) - // Verify the branch existence check was skipped (only 2 getBranch calls, not 3) - expect(octokit.rest.repos.getBranch).toHaveBeenCalledTimes(2) - expect(debugMock).not.toHaveBeenCalledWith( - 'checking if branch exists: test-ref' - ) -}) - -test('skips branch existence check when deploying an exact SHA', async () => { - data.environmentObj.sha = 'abc123def456' - data.inputs.allow_sha_deployments = true - - octokit.rest.repos.getBranch = vi - .fn() - .mockReturnValueOnce({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'main' - }, - status: 200 - }) - .mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef'}, name: 'main'}, - status: 200 - }) - - const result = await prechecks(context, octokit, data) - - expect(result.status).toBe(true) - // Verify the branch existence check was skipped - expect(octokit.rest.repos.getBranch).toHaveBeenCalledTimes(2) - expect(debugMock).not.toHaveBeenCalledWith( - 'checking if branch exists: test-ref' - ) -}) - -test('skips branch existence check when PR is from a fork', async () => { - // Mock the PR as a fork - octokit.rest.pulls.get = vi.fn().mockReturnValue({ - data: { - head: { - ref: 'test-ref', - sha: 'abc123', - repo: { - fork: true - }, - label: 'fork:test-ref' - }, - base: { - ref: 'main' - }, - draft: false - }, - status: 200 - }) - - octokit.rest.repos.getBranch = vi - .fn() - .mockReturnValueOnce({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'main' - }, - status: 200 - }) - .mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef'}, name: 'main'}, - status: 200 - }) - - const result = await prechecks(context, octokit, data) - - expect(result.status).toBe(true) - expect(result.isFork).toBe(true) - // Verify the branch existence check was skipped for forks - expect(octokit.rest.repos.getBranch).toHaveBeenCalledTimes(2) - expect(debugMock).not.toHaveBeenCalledWith( - 'checking if branch exists: abc123' - ) -}) - -test('fails prechecks when branch check encounters unexpected error', async () => { - // Mock getBranch to throw a non-404 error - octokit.rest.repos.getBranch = vi - .fn() - .mockReturnValueOnce({ - data: { - commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, - name: 'main' - }, - status: 200 - }) - .mockReturnValueOnce({ - data: {commit: {sha: 'deadbeef'}, name: 'main'}, - status: 200 - }) - .mockRejectedValueOnce(new UnexpectedError('Internal server error')) - - const result = await prechecks(context, octokit, data) - - // Should fail and not continue - expect(result.status).toBe(false) - expect(result.message).toContain('Cannot proceed with deployment') - expect(result.message).toContain('ref: `test-ref`') - expect(result.message).toContain( - 'An unexpected error occurred while checking if the branch exists' - ) - expect(result.message).toContain('Internal server error') -}) diff --git a/__tests__/functions/prechecks.test.ts b/__tests__/functions/prechecks.test.ts new file mode 100644 index 00000000..fb62fe6e --- /dev/null +++ b/__tests__/functions/prechecks.test.ts @@ -0,0 +1,5085 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test, type Mock} from 'node:test' +import {isDeepStrictEqual} from 'node:util' +import type { + PrechecksBranchResponse, + PrechecksOctokit +} from '../../src/functions/prechecks.ts' +import {COLORS} from '../../src/functions/colors.ts' +import type { + BranchDeployContext, + PrecheckData, + PrechecksGraphqlContextsPageResult, + PrechecksGraphqlResult, + RawCheckResult +} from '../../src/types.ts' +import { + createActionInputs, + createContext, + type DeepMutable +} from '../test-helpers.ts' +import { + assertCalledTimes, + assertCalledWith, + assertLastCalledWith, + createMock, + installModuleMock, + queueMockImplementation, + stubEnv +} from '../node-test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +type ActionsCoreModule = typeof import('../../src/actions-core.ts') +type AdminModule = typeof import('../../src/functions/admin.ts') +type OutdatedModule = typeof import('../../src/functions/outdated-check.ts') + +const infoMock = createMock() +const warningMock = createMock() +const debugMock = createMock() +const errorMock = createMock() +const setOutputMock = createMock() +const saveStateMock = createMock() +const isAdminMock = createMock(() => + Promise.resolve(false) +) +const isOutdatedMock = createMock(() => + Promise.resolve({outdated: false, branch: 'test-branch'}) +) + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: errorMock, + info: infoMock, + saveState: saveStateMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../../src/functions/admin.ts', import.meta.url), + { + isAdmin: isAdminMock + } +) +installModuleMock( + mock, + new URL('../../src/functions/outdated-check.ts', import.meta.url), + {isOutdated: isOutdatedMock} +) + +const {filterChecks, prechecks} = + await import('../../src/functions/prechecks.ts') + +type Callable = (...arguments_: never[]) => unknown + +function assertNotCalledWith( + mockFunction: Mock, + ...expected: Parameters +): void { + assert.ok( + !mockFunction.mock.calls.some(call => + isDeepStrictEqual(call.arguments, expected) + ), + 'expected mock not to have been called with the supplied arguments' + ) +} + +interface PrechecksOctokitFixture { + graphql: Mock + rest: { + pulls: { + get: Mock + updateBranch: Mock + } + repos: { + compareCommits: Mock + getBranch: Mock + getCollaboratorPermissionLevel: Mock< + PrechecksOctokit['rest']['repos']['getCollaboratorPermissionLevel'] + > + } + } +} + +type TestCommitCollection = NonNullable< + PrechecksGraphqlResult['repository']['pullRequest']['commits'] +> +type TestCommitNode = NonNullable[number] +type TestStatusCheckRollup = Exclude< + TestCommitNode['commit']['statusCheckRollup'], + undefined +> + +const LAST_PAGE = {endCursor: null, hasNextPage: false} as const + +function checkRollup(state: string): TestStatusCheckRollup { + return { + state, + contexts: { + nodes: [{context: 'legacy-ci', isRequired: true, state}], + pageInfo: LAST_PAGE + } + } +} + +let context: BranchDeployContext +let getCollabOK: Mock< + PrechecksOctokit['rest']['repos']['getCollaboratorPermissionLevel'] +> +let getPullsOK: Mock +let graphQLOK: Mock +let compareCommitsMock: Mock< + PrechecksOctokit['rest']['repos']['compareCommits'] +> +let getBranchMock: Mock +let updateBranchMock: Mock +let octokit: PrechecksOctokitFixture +let data: DeepMutable +let baseCommitWithOid: TestCommitCollection + +beforeEach(testContext => { + if (!('after' in testContext)) { + throw new TypeError('Expected a test context') + } + infoMock.mock.resetCalls() + warningMock.mock.resetCalls() + debugMock.mock.resetCalls() + debugMock.mock.mockImplementation(() => undefined) + errorMock.mock.resetCalls() + setOutputMock.mock.resetCalls() + saveStateMock.mock.resetCalls() + isAdminMock.mock.resetCalls() + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + isOutdatedMock.mock.resetCalls() + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({outdated: false, branch: 'test-branch'}) + ) + stubEnv(testContext, 'INPUT_PERMISSIONS', 'admin,write') + + baseCommitWithOid = { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + + data = { + environment: 'production', + environmentObj: { + target: 'production', + stable_branch_used: false, + noop: false, + params: null, + parsed_params: null, + sha: null + }, + issue_number: '123', + inputs: createActionInputs({ + allow_sha_deployments: false, + update_branch: 'disabled', + stable_branch: 'main', + trigger: '.deploy', + allowForks: true, + skipCi: '', + skipReviews: '', + draft_permitted_targets: '', + checks: 'all', + permissions: ['admin', 'write'], + commit_verification: false, + ignored_checks: [], + use_security_warnings: true, + allow_non_default_target_branch_deployments: false + }) + } + + context = createContext({ + actor: 'monalisa', + repo: { + owner: 'corp', + repo: 'test' + }, + issue: { + number: 123 + } + }) + + getCollabOK = createMock< + PrechecksOctokit['rest']['repos']['getCollaboratorPermissionLevel'] + >(() => Promise.resolve({data: {permission: 'write'}, status: 200})) + getPullsOK = createMock(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123', + label: 'corp:test-ref', + repo: {fork: false, full_name: 'corp/test'} + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + graphQLOK = createMock(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'SUCCESS', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'SUCCESS', + name: 'build' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + compareCommitsMock = createMock< + PrechecksOctokit['rest']['repos']['compareCommits'] + >(() => Promise.resolve({data: {behind_by: 0}})) + getBranchMock = createMock( + () => + Promise.resolve({ + data: { + commit: { + sha: 'deadbeef', + commit: {tree: {sha: 'beefdead'}} + }, + name: 'test-branch' + } + }) + ) + updateBranchMock = createMock< + PrechecksOctokit['rest']['pulls']['updateBranch'] + >(() => Promise.resolve({status: 202})) + + octokit = { + rest: { + repos: { + compareCommits: compareCommitsMock, + getBranch: getBranchMock, + getCollaboratorPermissionLevel: getCollabOK + }, + pulls: { + get: getPullsOK, + updateBranch: updateBranchMock + } + }, + graphql: graphQLOK + } +}) + +function mockApprovedCi( + statusCheckRollup: TestStatusCheckRollup, + checkSuiteCount?: number +) { + void checkSuiteCount + const commit = { + oid: 'abc123', + statusCheckRollup + } + + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: {totalCount: 1}, + commits: {nodes: [{commit}]} + } + } + }) + ) +} + +function initialCheckPage( + nodes: readonly RawCheckResult[], + pageInfo: {readonly endCursor: string | null; readonly hasNextPage: boolean}, + state = 'FAILURE' +): PrechecksGraphqlResult { + return { + repository: { + pullRequest: { + commits: { + nodes: [ + { + commit: { + id: 'commit-node', + oid: 'abc123', + statusCheckRollup: {contexts: {nodes, pageInfo}, state} + } + } + ] + }, + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + reviews: {totalCount: 1} + } + } + } +} + +function additionalCheckPage( + nodes: readonly RawCheckResult[], + pageInfo: {readonly endCursor: string | null; readonly hasNextPage: boolean}, + overrides: { + readonly id?: string + readonly oid?: string + } = {} +): PrechecksGraphqlContextsPageResult { + return { + node: { + id: overrides.id ?? 'commit-node', + oid: overrides.oid ?? 'abc123', + statusCheckRollup: { + contexts: {nodes, pageInfo}, + state: 'FAILURE' + } + } + } +} + +function mockCheckPages( + first: PrechecksGraphqlResult, + ...pages: readonly (PrechecksGraphqlContextsPageResult | Error)[] +): void { + queueMockImplementation( + graphQLOK, + () => Promise.resolve(first), + ...pages.map(page => + page instanceof Error + ? () => Promise.reject(page) + : () => Promise.resolve(page) + ) + ) +} + +async function assertChecksUnavailable(): Promise { + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + "### โš ๏ธ Cannot proceed with deployment\n\n- commitStatus: `UNAVAILABLE`\n\n> The Action could not verify all CI checks for this pull request, so no deployment was started. Retry the command after GitHub's check data is available, or explicitly configure `skip_ci` for this environment.", + status: false + }) + assertCalledWith(setOutputMock, 'commit_status', 'UNAVAILABLE') +} + +test('treats an unfinished check run without a conclusion as unhealthy', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [{conclusion: null, isRequired: true, name: 'queued-check'}], + [], + false + ), + { + message: 'one or more checks are pending', + status: 'PENDING' + } + ) +}) + +test('preserves nullish fallbacks for a malformed hybrid check node', () => { + const hybridCheck = unsafeInvalidValue({ + conclusion: undefined, + context: 'legacy-ci', + isRequired: true, + name: null, + state: 'SUCCESS' + }) + assert.deepStrictEqual( + filterChecks(['legacy-ci'], [hybridCheck], [], false), + { + message: 'all checks passed', + status: 'SUCCESS' + } + ) +}) + +test('uses the newest check run after a successful rerun', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + completedAt: '2026-01-01T00:00:00Z', + conclusion: 'FAILURE', + databaseId: 10, + id: 'old', + isRequired: true, + name: 'test' + }, + { + checkSuite: {app: {databaseId: 1}}, + completedAt: '2026-01-01T00:01:00Z', + conclusion: 'SUCCESS', + databaseId: 11, + id: 'new', + isRequired: true, + name: 'test' + } + ], + [], + false + ), + {message: 'all checks passed', status: 'SUCCESS'} + ) +}) + +test('uses the database ID to order check reruns with equal timestamps', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'FAILURE', + databaseId: 10, + id: 'old', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + }, + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + databaseId: 11, + id: 'new', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'all checks passed', status: 'SUCCESS'} + ) +}) + +test('uses the newer check run even when the older run completes later', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + completedAt: '2026-01-01T00:02:00Z', + conclusion: 'SUCCESS', + databaseId: 10, + id: 'old', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + }, + { + checkSuite: {app: {databaseId: 1}}, + completedAt: null, + conclusion: null, + databaseId: 11, + id: 'new', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:01:00Z' + } + ], + [], + false + ), + {message: 'one or more checks are pending', status: 'PENDING'} + ) +}) + +test('keeps a newer pending status context blocking', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + context: 'ci/test', + id: 'old', + isRequired: true, + state: 'SUCCESS', + updatedAt: '2026-01-01T00:00:00Z' + }, + { + context: 'ci/test', + id: 'new', + isRequired: true, + state: 'PENDING', + updatedAt: '2026-01-01T00:01:00Z' + } + ], + [], + false + ), + {message: 'one or more checks are pending', status: 'PENDING'} + ) +}) + +test('keeps a newer status context when an older result appears later', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + context: 'ci/test', + id: 'new', + isRequired: true, + state: 'SUCCESS', + updatedAt: '2026-01-01T00:01:00Z' + }, + { + context: 'ci/test', + id: 'old', + isRequired: true, + state: 'FAILURE', + updatedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'all checks passed', status: 'SUCCESS'} + ) +}) + +test('rejects duplicate checks without deterministic ordering data', () => { + assert.throws( + () => + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'FAILURE', + isRequired: true, + name: 'test' + }, + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + isRequired: true, + name: 'test' + } + ], + [], + false + ), + {message: 'A duplicate check result is missing its timestamp'} + ) +}) + +test('keeps a newer check when an older result appears later', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + databaseId: 11, + id: 'new', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:01:00Z' + }, + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'FAILURE', + databaseId: 10, + id: 'old', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'all checks passed', status: 'SUCCESS'} + ) +}) + +test('keeps the larger check database ID regardless of response order', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + databaseId: 11, + id: 'new', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + }, + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'FAILURE', + databaseId: 10, + id: 'old', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'all checks passed', status: 'SUCCESS'} + ) +}) + +test('accepts a duplicate status node with the same identity and timestamp', () => { + const check = { + context: 'ci/test', + id: 'same', + isRequired: true, + state: 'SUCCESS', + updatedAt: '2026-01-01T00:00:00Z' + } + assert.deepStrictEqual(filterChecks('all', [check, check], [], false), { + message: 'all checks passed', + status: 'SUCCESS' + }) +}) + +test('uses a status context creation time when its update time is null', () => { + assert.deepStrictEqual( + filterChecks( + 'all', + [ + unsafeInvalidValue({ + context: 'ci/test', + createdAt: '2026-01-01T00:00:00Z', + id: 'old', + isRequired: true, + state: 'FAILURE', + updatedAt: null + }), + { + context: 'ci/test', + id: 'new', + isRequired: true, + state: 'SUCCESS', + updatedAt: '2026-01-01T00:01:00Z' + } + ], + [], + false + ), + {message: 'all checks passed', status: 'SUCCESS'} + ) +}) + +test('accepts duplicate check runs with the same node identity', () => { + const check = { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + id: 'same', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + assert.deepStrictEqual(filterChecks('all', [check, check], [], false), { + message: 'all checks passed', + status: 'SUCCESS' + }) +}) + +test('rejects duplicate check runs without integration identities', () => { + const check = { + conclusion: 'SUCCESS', + id: 'same', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + assert.throws(() => filterChecks('all', [check, check], [], false), { + message: + 'A duplicate check result is missing its integration identity: check:null:test' + }) +}) + +test('rejects malformed required-check metadata', () => { + assert.throws( + () => + filterChecks( + 'required', + [ + unsafeInvalidValue({ + conclusion: 'SUCCESS', + isRequired: undefined, + name: 'test' + }) + ], + [], + true + ), + {message: 'A check result has an invalid required-check flag'} + ) +}) + +for (const check of [ + {conclusion: 'SUCCESS', isRequired: true, name: ''}, + {isRequired: true, state: 'SUCCESS'}, + {context: '', isRequired: true, state: 'SUCCESS'} +] as const) { + test(`rejects malformed check identity ${JSON.stringify(check)}`, () => { + assert.throws(() => + filterChecks( + 'all', + [unsafeInvalidValue(check)], + [], + false + ) + ) + }) +} + +test('rejects a duplicate check with a null timestamp', () => { + const check = unsafeInvalidValue({ + checkSuite: {app: {databaseId: 1}}, + completedAt: null, + conclusion: 'SUCCESS', + id: 'same', + isRequired: true, + name: 'test', + startedAt: null + }) + assert.throws(() => filterChecks('all', [check, check], [], false), { + message: 'A duplicate check result is missing its timestamp' + }) +}) + +test('rejects tied check runs without database or node identities', () => { + assert.throws( + () => + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'FAILURE', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + }, + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'Check ordering is ambiguous for check:1:test'} + ) +}) + +test('rejects duplicate malformed status contexts without timestamps', () => { + const malformed = unsafeInvalidValue({ + context: 'ci/test', + id: 'same', + isRequired: true, + state: 'SUCCESS' + }) + assert.throws(() => filterChecks('all', [malformed, malformed], [], false), { + message: 'A duplicate check result is missing its timestamp' + }) +}) + +test('rejects a duplicate check with an invalid timestamp', () => { + assert.throws( + () => + filterChecks( + 'all', + [ + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'FAILURE', + isRequired: true, + name: 'test', + startedAt: 'not-a-time' + }, + { + checkSuite: {app: {databaseId: 1}}, + conclusion: 'SUCCESS', + isRequired: true, + name: 'test', + startedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'A check result has an invalid timestamp: not-a-time'} + ) +}) + +test('rejects status reruns with tied timestamps and different identities', () => { + assert.throws( + () => + filterChecks( + 'all', + [ + { + context: 'ci/test', + id: 'one', + isRequired: true, + state: 'FAILURE', + updatedAt: '2026-01-01T00:00:00Z' + }, + { + context: 'ci/test', + id: 'two', + isRequired: true, + state: 'SUCCESS', + updatedAt: '2026-01-01T00:00:00Z' + } + ], + [], + false + ), + {message: 'Check ordering is ambiguous for status:ci/test'} + ) +}) + +test('fails closed for a malformed GraphQL commit node', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve( + unsafeInvalidValue({ + repository: { + pullRequest: { + commits: {nodes: [{}]}, + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + reviews: {totalCount: 1} + } + } + }) + ) + ) + + await assertChecksUnavailable() +}) + +test('fails closed when GraphQL commit nodes are missing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve( + unsafeInvalidValue({ + repository: { + pullRequest: { + commits: {}, + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + reviews: {totalCount: 1} + } + } + }) + ) + ) + + await assertChecksUnavailable() +}) + +test('fails closed when the GraphQL commit collection is missing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + reviews: {totalCount: 1} + } + } + }) + ) + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: false + }) + assertCalledWith( + debugMock, + 'could not retrieve PR commit status: Error: The GraphQL response did not include a commit' + ) + assertCalledWith( + warningMock, + 'CI check verification is unavailable; deployment will not proceed' + ) + assertCalledWith(setOutputMock, 'commit_status', 'UNAVAILABLE') +}) + +test('preserves the fallback when raw GraphQL debug output throws', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + reviews: {totalCount: 1} + } + } + }) + ) + debugMock.mock.mockImplementation(message => { + if (typeof message !== 'string') { + throw new TypeError('debug output must be a string') + } + }) + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: false + }) + assertCalledWith( + debugMock, + 'Could not output raw graphql result for debugging - This is bad' + ) +}) + +test('preserves the optional default-branch tree lookup', async () => { + getBranchMock.mock.mockImplementationOnce(() => + Promise.resolve(unsafeInvalidValue(null)) + ) + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) + assertCalledWith(setOutputMock, 'default_branch_tree_sha', undefined) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment', async () => { + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with required checks', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = 'required' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with required checks and some ignored checks', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = 'required' + data.inputs.ignored_checks = ['markdown-lint'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with a few explictly requested checks and a few ignored checks', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: false, + conclusion: 'SUCCESS', + name: 'acceptance-test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = ['test', 'acceptance-test', 'lint'] + data.inputs.ignored_checks = ['lint'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: test' + ) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: acceptance-test' + ) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: lint' + ) + assertCalledWith( + debugMock, + 'filterChecks() - markdown-lint is not in the explicit list of checks to include (test,acceptance-test,lint)' + ) + assertNotCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) + assertCalledWith(debugMock, 'filterChecks() - ignoring ci check: lint') +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with a few explictly requested checks and a few ignored checks but one CI check is missing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: false, + conclusion: 'SUCCESS', + name: 'acceptance-test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = ['test', 'acceptance-test', 'quality-control', 'lint'] + data.inputs.ignored_checks = ['lint'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `MISSING`\n\n> The `checks` input option requires that all of the following checks are passing: `test,acceptance-test,quality-control,lint`. However, the following checks are missing: `quality-control`', + status: false + }) + + assertCalledWith( + warningMock, + `the ${COLORS.info}checks${COLORS.reset} input option requires that all of the following checks are passing: ${COLORS.highlight}${data.inputs.checks.join(', ')}${COLORS.reset} - however, the following checks are missing: ${COLORS.highlight}quality-control${COLORS.reset}` + ) + assertNotCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: test' + ) + assertNotCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: acceptance-test' + ) + assertNotCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: lint' + ) + assertNotCalledWith( + debugMock, + 'filterChecks() - markdown-lint is not in the explicit list of checks to include (test,acceptance-test,lint)' + ) + assertNotCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) + assertNotCalledWith(debugMock, 'filterChecks() - ignoring ci check: lint') +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment but checks and ignore checks cancel eachother out', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: false, + conclusion: 'SUCCESS', + name: 'acceptance-test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = [ + 'test', + 'acceptance-test', + 'lint', + 'markdown-lint', + 'build' + ] + data.inputs.ignored_checks = [ + 'markdown-lint', + 'lint', + 'build', + 'test', + 'acceptance-test' + ] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: test' + ) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: acceptance-test' + ) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: lint' + ) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: markdown-lint' + ) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: build' + ) + assertCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) + assertCalledWith(debugMock, 'filterChecks() - ignoring ci check: lint') + assertCalledWith(debugMock, 'filterChecks() - ignoring ci check: build') + assertCalledWith(debugMock, 'filterChecks() - ignoring ci check: test') + assertCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: acceptance-test' + ) + assertCalledWith( + debugMock, + 'filterChecks() - after filtering, no checks remain - this will result in a SUCCESS state as it is treated as if no checks are defined' + ) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required but the user has provided some checks to ignore', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'NEUTRAL', + name: 'acceptance-test' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = 'all' + data.inputs.ignored_checks = ['markdown-lint', 'build'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith(debugMock, 'filterChecks() - ignoring ci check: build') + assertCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required but the user has provided some checks to ignore', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'NEUTRAL', + name: 'acceptance-test' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = [] // if the array is empty, this essentially says "include all checks" + data.inputs.ignored_checks = [] // if the array is empty, this essentially says "don't ignore any checks" + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) + + assertNotCalledWith(debugMock, 'explicitly including ci check: test') + assertNotCalledWith(debugMock, 'filterChecks() - ignoring ci check: build') + assertNotCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required but the user has provided some checks to ignore but none match', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'NEUTRAL', + name: 'acceptance-test' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = 'all' + data.inputs.ignored_checks = ['xyz', 'abc'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) + + assertNotCalledWith(debugMock, 'filterChecks() - ignoring ci check: build') + assertNotCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment with ALL checks being required and the user did not provided checks to ignore and some are failing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test' + }, + { + isRequired: true, + conclusion: 'SKIPPED', + name: 'lint' + }, + { + isRequired: false, + conclusion: 'NEUTRAL', + name: 'acceptance-test' + }, + { + isRequired: false, + conclusion: 'FAILURE', + name: 'build' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'markdown-lint' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = 'all' + data.inputs.ignored_checks = unsafeInvalidValue(null) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) + + assertNotCalledWith(debugMock, 'filterChecks() - ignoring ci check: build') + assertNotCalledWith( + debugMock, + 'filterChecks() - ignoring ci check: markdown-lint' + ) +}) + +for (const ignoredChecks of [false, 0, ''] as const) { + test(`preserves the empty ignored-check fallback for the malformed value ${String(ignoredChecks)}`, async () => { + data.inputs.ignored_checks = unsafeInvalidValue(ignoredChecks) + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) + }) +} + +test('runs prechecks and finds that the IssueOps command is valid for a rollback deployment', async () => { + getBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, + status: 200 + }) + ) + + data.environmentObj.stable_branch_used = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… deployment to the ${COLORS.highlight}stable${COLORS.reset} branch requested`, + noopMode: false, + ref: 'main', + status: true, + sha: 'deadbeef', + isFork: false + }) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a noop deployment', async () => { + data.environmentObj.noop = true + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: true, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds the commit fetched via the rest call does not match the commit returned from the graphql call', async () => { + graphQLOK.mock.mockImplementationOnce(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + commits: { + nodes: [ + { + commit: { + oid: 'evilcommit123' + } + } + ] + } + } + } + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\nThe commit sha from the PR head does not match the commit sha from the graphql query\n\n- sha: `abc123`\n- commit_oid: `evilcommit123`\n\nThis is unexpected and could be caused by a commit being pushed to the branch after the initial rest call was made. Please review your PR timeline and try again.', + status: false + }) +}) + +test('runs prechecks and finds that the IssueOps command is valid without defined CI checks', async () => { + graphQLOK.mock.mockImplementationOnce(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + commits: baseCommitWithOid + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… CI checks have not been defined but the PR has been approved', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertCalledWith( + infoMock, + '๐Ÿ’ก no CI checks have been defined for this pull request' + ) +}) + +test('runs prechecks and fails due to bad user permissions', async () => { + getCollabOK.mock.mockImplementationOnce(() => + Promise.resolve({data: {permission: 'read'}, status: 200}) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '๐Ÿ‘‹ @monalisa, that command requires the following permission(s): `admin/write`\n\nYour current permissions: `read`', + status: false + }) +}) + +test('runs prechecks and fails due to a bad pull request', async () => { + getPullsOK.mock.mockImplementationOnce(() => Promise.resolve({status: 500})) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'Could not retrieve PR info: 500', + status: false + }) +}) + +// Review checks and CI checks + +for (const [checkSuiteCount, aggregateState, checks] of [ + [0, 'FAILURE', 'all'], + [0, 'PENDING', 'all'], + [1, 'FAILURE', 'all'], + [0, 'FAILURE', 'required'] +] as const) { + test(`rejects an approved deployment with ${checkSuiteCount} CheckSuites, aggregate ${aggregateState}, and checks=${checks}`, async () => { + mockApprovedCi( + { + state: aggregateState, + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + state: aggregateState, + context: 'legacy-ci' + } + ] + } + }, + checkSuiteCount + ) + + data.inputs.checks = checks + const commitStatus = checks === 'required' ? 'FAILURE' : aggregateState + const detail = + commitStatus === 'PENDING' + ? 'CI checks must be passing in order to continue' + : 'Your pull request is approved but CI checks are failing' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: \`APPROVED\`\n- commitStatus: \`${commitStatus}\`\n\n> ${detail}`, + status: false + }) + assert.ok( + graphQLOK.mock.calls[0]?.arguments[0].includes('... on StatusContext') + ) + }) +} + +test('accepts a requested healthy legacy status context without CheckSuites', async () => { + mockApprovedCi( + { + state: 'SUCCESS', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + state: 'SUCCESS', + context: 'legacy-ci' + } + ] + } + }, + 0 + ) + + data.inputs.checks = ['legacy-ci'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + assertCalledWith( + debugMock, + 'filterChecks() - explicitly including ci check: legacy-ci' + ) +}) + +test('allows checks=required when only an optional CI check is failing', async () => { + mockApprovedCi({ + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: false, + conclusion: 'FAILURE', + name: 'optional-ci' + } + ] + } + }) + + data.inputs.checks = 'required' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + assertCalledWith( + debugMock, + 'filterChecks() - after filtering, no checks remain - this will result in a SUCCESS state as it is treated as if no checks are defined' + ) +}) + +test('rejects a required failing check after the first 100 contexts', async () => { + const firstPage = Array.from({length: 100}, (_, index) => ({ + conclusion: 'SUCCESS', + isRequired: true, + name: `healthy-${String(index)}` + })) + mockCheckPages( + initialCheckPage(firstPage, { + endCursor: 'cursor-1', + hasNextPage: true + }), + additionalCheckPage( + [{conclusion: 'FAILURE', isRequired: true, name: 'required-101'}], + LAST_PAGE + ) + ) + data.inputs.checks = 'required' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) + assert.deepStrictEqual(graphQLOK.mock.calls[1]?.arguments[1], { + commitId: 'commit-node', + cursor: 'cursor-1', + number: 123 + }) + assert.ok( + graphQLOK.mock.calls[1]?.arguments[0].includes( + 'contexts(first:100, after:$cursor)' + ) + ) +}) + +test('rejects a failing legacy status context on a later page', async () => { + mockCheckPages( + initialCheckPage( + [{context: 'first-status', isRequired: true, state: 'SUCCESS'}], + {endCursor: 'cursor-1', hasNextPage: true} + ), + additionalCheckPage( + [{context: 'legacy-ci', isRequired: true, state: 'FAILURE'}], + LAST_PAGE + ) + ) + data.inputs.checks = ['legacy-ci'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) +}) + +test('finds an explicitly requested healthy check on a later page', async () => { + mockCheckPages( + initialCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'first-check'}], + {endCursor: 'cursor-1', hasNextPage: true} + ), + additionalCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'security'}], + LAST_PAGE + ) + ) + data.inputs.checks = ['security'] + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) +}) + +test('ignores a failing check discovered on a later page', async () => { + mockCheckPages( + initialCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'first-check'}], + {endCursor: 'cursor-1', hasNextPage: true} + ), + additionalCheckPage( + [{conclusion: 'FAILURE', isRequired: false, name: 'optional-ci'}], + LAST_PAGE + ) + ) + data.inputs.ignored_checks = ['optional-ci'] + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) +}) + +test('accepts healthy required checks spanning several pages', async () => { + mockCheckPages( + initialCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'first-check'}], + {endCursor: 'cursor-1', hasNextPage: true} + ), + additionalCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'second-check'}], + {endCursor: 'cursor-2', hasNextPage: true} + ), + additionalCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'third-check'}], + LAST_PAGE + ) + ) + data.inputs.checks = 'required' + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) + assertCalledTimes(graphQLOK, 3) +}) + +test('paginates checks=all even when the aggregate state is successful', async () => { + mockCheckPages( + initialCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'first-check'}], + {endCursor: 'cursor-1', hasNextPage: true}, + 'SUCCESS' + ), + additionalCheckPage( + [{conclusion: 'SUCCESS', isRequired: true, name: 'second-check'}], + LAST_PAGE + ) + ) + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) + assertCalledTimes(graphQLOK, 2) +}) + +test('skip_ci bypasses malformed paginated check data', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve( + unsafeInvalidValue({ + repository: { + pullRequest: { + commits: {nodes: [{commit: {oid: 'abc123'}}]}, + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + reviews: {totalCount: 1} + } + } + }) + ) + ) + data.inputs.checks = 'required' + data.inputs.skipCi = 'production' + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + status: true + }) + assertCalledTimes(graphQLOK, 1) + assertCalledWith(setOutputMock, 'commit_status', 'skip_ci') +}) + +for (const [name, pageInfo] of [ + ['missing', {endCursor: null, hasNextPage: true}], + ['empty', {endCursor: '', hasNextPage: true}] +] as const) { + test(`fails closed when a next-page cursor is ${name}`, async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve(initialCheckPage([], pageInfo)) + ) + data.inputs.checks = 'required' + + await assertChecksUnavailable() + assertCalledTimes(graphQLOK, 1) + }) +} + +test('fails closed when a pagination cursor repeats', async () => { + mockCheckPages( + initialCheckPage([], {endCursor: 'cursor-1', hasNextPage: true}), + additionalCheckPage([], { + endCursor: 'cursor-1', + hasNextPage: true + }) + ) + data.inputs.checks = 'required' + + await assertChecksUnavailable() + assertCalledTimes(graphQLOK, 2) +}) + +test('fails closed when a later check page cannot be retrieved', async () => { + mockCheckPages( + initialCheckPage([], {endCursor: 'cursor-1', hasNextPage: true}), + new Error('pagination failed') + ) + data.inputs.checks = 'required' + + await assertChecksUnavailable() +}) + +for (const [name, page] of [ + [ + 'commit node ID changes', + additionalCheckPage([], LAST_PAGE, {id: 'different-node'}) + ], + [ + 'commit OID changes', + additionalCheckPage([], LAST_PAGE, {oid: 'different-oid'}) + ], + ['the commit node is absent', {node: null}], + [ + 'the check rollup disappears', + {node: {id: 'commit-node', oid: 'abc123', statusCheckRollup: null}} + ] +] as const satisfies readonly (readonly [ + string, + PrechecksGraphqlContextsPageResult +])[]) { + test(`fails closed when ${name}`, async () => { + mockCheckPages( + initialCheckPage([], {endCursor: 'cursor-1', hasNextPage: true}), + page + ) + data.inputs.checks = 'required' + + await assertChecksUnavailable() + }) +} + +test('fails closed when the initial page omits page information', async () => { + const result = unsafeInvalidValue<{ + repository: { + pullRequest: { + commits: { + nodes: { + commit: {statusCheckRollup: {contexts: {pageInfo?: unknown}}} + }[] + } + } + } + }>(initialCheckPage([], LAST_PAGE, 'FAILURE')) + delete result.repository.pullRequest.commits.nodes[0]?.commit + .statusCheckRollup.contexts.pageInfo + graphQLOK.mock.mockImplementation(() => Promise.resolve(result)) + data.inputs.checks = 'required' + + await assertChecksUnavailable() +}) + +test('fails closed when a paginated response omits page information', async () => { + const page = additionalCheckPage([], LAST_PAGE) + const malformedPage = unsafeInvalidValue<{ + node: {statusCheckRollup: {contexts: {pageInfo?: unknown}}} + }>(page) + delete malformedPage.node.statusCheckRollup.contexts.pageInfo + mockCheckPages( + initialCheckPage([], {endCursor: 'cursor-1', hasNextPage: true}), + unsafeInvalidValue(malformedPage) + ) + data.inputs.checks = 'required' + + await assertChecksUnavailable() +}) + +test('fails closed when pagination is required without a commit node ID', async () => { + const result = initialCheckPage([], { + endCursor: 'cursor-1', + hasNextPage: true + }) + const mutableResult = + unsafeInvalidValue>(result) + delete mutableResult.repository.pullRequest.commits?.nodes?.[0]?.commit.id + graphQLOK.mock.mockImplementation(() => Promise.resolve(mutableResult)) + data.inputs.checks = 'required' + + await assertChecksUnavailable() +}) + +test('rejects explicitly requested checks when the combined CI rollup is absent', async () => { + mockApprovedCi(null) + + data.inputs.checks = ['security'] + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `MISSING`\n\n> The `checks` input option requires that all of the following checks are passing: `security`. However, the following checks are missing: `security`', + status: false + }) +}) + +test('runs prechecks and finds that reviews and CI checks have not been defined', async () => { + graphQLOK.mock.mockImplementationOnce(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + commits: baseCommitWithOid + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertCalledWith( + infoMock, + '๐Ÿ’ก no CI checks have been defined for this pull request' + ) + assertCalledWith( + infoMock, + '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined' + ) +}) + +test('runs prechecks and finds CI checks pass but reviews are not defined', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '๐ŸŽ›๏ธ CI checks have been defined but required reviewers have not been defined', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertLastCalledWith( + infoMock, + '๐ŸŽ›๏ธ CI checks have been defined but required reviewers have not been defined' + ) +}) + +test('fails closed for an unknown review decision', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'UNKNOWN', + reviews: {totalCount: 0}, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `UNKNOWN`\n- commitStatus: `SUCCESS`\n\n> This is usually caused by missing PR approvals or CI checks failing', + status: false + }) +}) + +test('runs prechecks and finds CI is passing and the PR has not been reviewed BUT it is a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… all CI checks passed and ${COLORS.highlight}noop${COLORS.reset} deployment requested`, + status: true, + noopMode: true, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment and is from a forked repository', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + status: true, + noopMode: false, + ref: 'abcde12345', + sha: 'abcde12345', + isFork: true + }) + + assertNotCalledWith(setOutputMock, 'non_default_target_branch_used', 'true') +}) + +for (const fork of [1, '1'] as const) { + test(`preserves loose fork detection for the malformed API value ${fork}`, async () => { + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abc123', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: unsafeInvalidValue(fork), + full_name: 'test-repo/test' + } + }, + base: {ref: 'main'} + }, + status: 200 + }) + ) + + assert.partialDeepStrictEqual(await prechecks(context, octokit, data), { + isFork: true, + ref: 'abc123', + status: true + }) + }) +} + +test('runs prechecks and finds that the PR from a fork is targeting a non-default branch and rejects the deployment', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'some-other-branch' + } + }, + status: 200 + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\nThis pull request is attempting to merge into the \`some-other-branch\` branch which is not the default branch of this repository (\`${data.inputs.stable_branch}\`). This deployment has been rejected since it could be dangerous to proceed.`, + status: false + }) + + assertCalledWith(setOutputMock, 'non_default_target_branch_used', 'true') +}) + +test('runs prechecks and finds that the PR from a fork is targeting a non-default branch and allows it based on the action config', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'some-other-branch' + } + }, + status: 200 + }) + ) + + data.inputs.allow_non_default_target_branch_deployments = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… PR is approved and all CI checks passed`, + status: true, + noopMode: false, + ref: 'abcde12345', + sha: 'abcde12345', + isFork: true + }) + + assertCalledWith(setOutputMock, 'non_default_target_branch_used', 'true') +}) + +test('runs prechecks and finds that the PR is targeting a non-default branch and rejects the deployment', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123', + repo: {fork: false} + }, + base: { + ref: 'not-main' + } + }, + status: 200 + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\nThis pull request is attempting to merge into the \`not-main\` branch which is not the default branch of this repository (\`${data.inputs.stable_branch}\`). This deployment has been rejected since it could be dangerous to proceed.`, + status: false + }) + + assertCalledWith(setOutputMock, 'non_default_target_branch_used', 'true') +}) + +test('runs prechecks and finds that the PR is targeting a non-default branch and allows the deployment based on the action config and logs a warning', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abcde12345', + repo: {fork: false} + }, + base: { + ref: 'not-main' + } + }, + status: 200 + }) + ) + + data.inputs.allow_non_default_target_branch_deployments = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… PR is approved and all CI checks passed`, + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abcde12345', + isFork: false + }) + + assertCalledWith(setOutputMock, 'non_default_target_branch_used', 'true') + + assertCalledWith( + warningMock, + `๐Ÿšจ this pull request is attempting to merge into the \`not-main\` branch which is not the default branch of this repository (\`${data.inputs.stable_branch}\`) - this action is potentially dangerous` + ) +}) + +test('runs prechecks and finds that the IssueOps command is valid for a branch deployment and is from a forked repository and the PR is approved but CI is failing and it is a noop', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 4 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) +}) + +test('runs prechecks and finds that the IssueOps command is a fork and does not require reviews so it proceeds but with a warning', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '๐ŸŽ›๏ธ CI checks have been defined but required reviewers have not been defined', + status: true, + noopMode: false, + ref: 'abcde12345', + sha: 'abcde12345', + isFork: true + }) + + assertCalledWith( + warningMock, + '๐Ÿšจ pull request reviews are not enforced by this repository and this operation is being performed on a fork - this operation is dangerous! You should require reviews via branch protection settings (or rulesets) to ensure that the changes being deployed are the changes that you reviewed.' + ) +}) + +test('runs prechecks and rejects a pull request from a forked repository because it does not have completed reviews', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + // Even admins cannot deploy from a forked repository without reviews + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + // Even with skipReviews set, the PR is from a forked repository and must have reviews out of pure safety + data.environment = 'staging' + data.inputs.skipReviews = 'staging' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n\n> All deployments from forks **must** have the required reviews before they can proceed. Please ensure this PR has been reviewed and approved before trying again.', + status: false + }) + + assertCalledWith( + debugMock, + 'rejecting deployment from fork without required reviews - noopMode: false' + ) +}) + +test('runs prechecks and rejects a pull request from a forked repository because it does not have completed reviews (noop)', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + // Even admins cannot deploy from a forked repository without reviews + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + // Even with skipReviews set, the PR is from a forked repository and must have reviews out of pure safety + data.environment = 'staging' + data.inputs.skipReviews = 'staging' + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n\n> All deployments from forks **must** have the required reviews before they can proceed. Please ensure this PR has been reviewed and approved before trying again.', + status: false + }) + + assertCalledWith( + debugMock, + 'rejecting deployment from fork without required reviews - noopMode: true' + ) +}) + +test('runs prechecks and rejects a pull request from a forked repository because it does not have completed reviews [CHANGES_REQUESTED] (noop)', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'CHANGES_REQUESTED', + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abcde12345', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + label: 'test-repo:test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + // Even admins cannot deploy from a forked repository without reviews + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + // Even with skipReviews set, the PR is from a forked repository and must have reviews out of pure safety + data.environment = 'staging' + data.inputs.skipReviews = 'staging' + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n\n> All deployments from forks **must** have the required reviews before they can proceed. Please ensure this PR has been reviewed and approved before trying again.', + status: false + }) + + assertCalledWith( + debugMock, + 'rejecting deployment from fork without required reviews - noopMode: true' + ) +}) + +test('runs prechecks and rejects a forked pull request by default', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 4 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + sha: 'abcde12345', + ref: 'test-ref', + repo: { + fork: true + } + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + data.inputs.allowForks = createActionInputs().allowForks + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\nThis Action has been explicity configured to prevent deployments from forks. You can change this via this Action's inputs if needed`, + status: false + }) +}) + +test('runs prechecks and finds CI is pending and the PR has not been reviewed BUT it is a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('PENDING') + } + } + ] + } + } + } + }) + ) + + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `PENDING`\n\n> Reviews are not required for a noop deployment but CI checks must be passing in order to continue', + status: false + }) +}) + +test('runs prechecks and finds CI checks are pending, the PR has not been reviewed, and it is not a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('PENDING') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `PENDING`\n\n> CI checks must be passing and the PR must be approved in order to continue', + status: false + }) +}) + +test('runs prechecks and finds CI is pending and reviewers have not been defined', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + reviews: { + totalCount: 0 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('PENDING') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `null`\n- commitStatus: `PENDING`\n\n> CI checks must be passing in order to continue', + status: false + }) +}) + +test('runs prechecks and finds CI checked have not been defined, the PR has not been reviewed, and it IS a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 0 + }, + commits: baseCommitWithOid + } + } + }) + ) + + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… CI checks have not been defined and ${COLORS.highlight}noop${COLORS.reset} requested`, + status: true, + noopMode: true, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and deploys to the stable branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + reviews: { + totalCount: 0 + } + } + } + }) + ) + getBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, + status: 200 + }) + ) + + data.environmentObj.stable_branch_used = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… deployment to the ${COLORS.highlight}stable${COLORS.reset} branch requested`, + status: true, + noopMode: false, + ref: 'main', + sha: 'deadbeef', + isFork: false + }) +}) + +test('runs prechecks and finds the PR has been approved but CI checks are pending and it is not a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('PENDING') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `PENDING`\n\n> CI checks must be passing in order to continue', + status: false + }) +}) + +test('runs prechecks and finds CI is passing but the PR is missing an approval', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `SUCCESS`\n\n> CI checks are passing but an approval is required before you can proceed with deployment', + status: false + }) +}) + +test('runs prechecks and finds CI is passing but the PR is in a CHANGES_REQUESTED state for reviews', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'CHANGES_REQUESTED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n- commitStatus: `SUCCESS`\n\n> CI checks are passing but an approval is required before you can proceed with deployment', + status: false + }) + + // the same request works for a noop as changes requested is treated the same as no approval and approvals are not required for noops + data.environmentObj.noop = true + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… all CI checks passed and ${COLORS.highlight}noop${COLORS.reset} deployment requested`, + status: true, + noopMode: true, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds the PR is approved but CI is failing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) +}) + +test('runs prechecks and finds the PR is in a changes requested state and CI is failing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'CHANGES_REQUESTED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n- commitStatus: `FAILURE`\n\n> Your pull request needs to address the requested changes, get approvals, and have passing CI checks before you can proceed with deployment', + status: false + }) +}) + +test('runs prechecks and finds the PR is in a REVIEW_REQUIRED state and CI is failing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `FAILURE`\n\n> Your pull request needs to get approvals and have passing CI checks before you can proceed with deployment', + status: false + }) +}) + +test('runs prechecks and finds the PR is in a changes requested state and has no CI checks defined', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'CHANGES_REQUESTED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `CHANGES_REQUESTED`\n- commitStatus: `null`\n\n> Your pull request is missing required approvals', + status: false + }) +}) + +test('runs prechecks and finds the PR is approved but CI is failing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: 'FAILURE', + contexts: { + pageInfo: LAST_PAGE, + nodes: [ + { + isRequired: true, + conclusion: 'SUCCESS', + name: 'test-success' + }, + { + isRequired: true, + conclusion: 'FAILURE', + name: 'test-failure' + }, + { + isRequired: false, + conclusion: 'SUCCESS', + name: 'optional-success' + } + ] + } + } + } + } + ] + } + } + } + }) + ) + + data.inputs.checks = 'required' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `APPROVED`\n- commitStatus: `FAILURE`\n\n> Your pull request is approved but CI checks are failing', + status: false + }) +}) + +test('runs prechecks and finds the PR does not require approval but CI is failing', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `null`\n- commitStatus: `FAILURE`\n\n> Your pull request does not require approvals but CI checks are failing', + status: false + }) +}) + +test('runs prechecks and finds the PR is NOT reviewed and CI checks have NOT been defined and NOT a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: baseCommitWithOid + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: `REVIEW_REQUIRED`\n- commitStatus: `null`\n\n> Your pull request is missing required approvals', + status: false + }) +}) + +test('runs prechecks and finds the PR is approved and CI checks have NOT been defined and NOT a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: baseCommitWithOid + } + } + }) + ) + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… CI checks have not been defined but the PR has been approved', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds the PR is behind the stable branch and a noop deploy and force updates the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + updateBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + message: 'Updating pull request branch.', + url: 'https://api.github.com/repos/foo/bar/pulls/123' + }, + status: 202 + }) + ) + + data.inputs.update_branch = 'force' + data.environmentObj.noop = true + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `force`\n\n> I went ahead and updated your branch with `main` - Please try again once this operation is complete', + status: false + }) +}) + +test('runs prechecks and finds the PR is un-mergable and a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'DIRTY', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'warn' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n- mergeStateStatus: `DIRTY`\n\n> A merge commit cannot be cleanly created', + status: false + }) +}) + +test('runs prechecks and finds the PR is BEHIND and a noop deploy and it fails to update the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + updateBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + message: 'merge conflict between base and head', + url: 'https://api.github.com/repos/foo/bar/pulls/123' + }, + status: 422 + }) + ) + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'force' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- update_branch http code: `422`\n- update_branch: `force`\n\n> Failed to update pull request branch with the `main` branch', + status: false + }) +}) + +test('runs prechecks and finds the PR is BEHIND and a noop deploy and it hits an error when force updating the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + updateBranchMock.mock.mockImplementation(() => + Promise.resolve( + unsafeInvalidValue< + Awaited> + >(null) + ) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'force' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + "### โš ๏ธ Cannot proceed with deployment\n\n```text\nCannot read properties of null (reading 'status')\n```", + status: false + }) +}) + +test('runs prechecks and finds the PR is BEHIND and a noop deploy and update_branch is set to warn', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'warn' + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\nYour branch is behind the base branch and will need to be updated before deployments can continue.\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `warn`\n\n> Please ensure your branch is up to date with the `main` branch and try again', + status: false + }) +}) + +test('runs prechecks and finds the PR is a DRAFT PR and a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BLOCKED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123' + }, + base: { + ref: 'main' + }, + draft: true + }, + status: 200 + }) + ) + getBranchMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, + status: 200 + }) + ) + compareCommitsMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: {behind_by: 0}, + status: 200 + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'warn' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n> Your pull request is in a draft state', + status: false + }) + assertCalledWith( + warningMock, + 'deployment requested on a draft PR from a non-allowed environment' + ) +}) + +test('runs prechecks and finds the PR is a DRAFT PR and from an allowed environment for draft deployments', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'CLEAN', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123' + }, + base: { + ref: 'main' + }, + draft: true // telling the test suite that our PR is in a draft state + }, + status: 200 + }) + ) + + data.environment = 'staging' + data.inputs.update_branch = 'warn' + data.inputs.draft_permitted_targets = 'sandbox,staging' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('preserves truthy draft handling for a malformed API value', async () => { + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123', + label: 'corp:test-ref', + repo: {fork: false, full_name: 'corp/test'} + }, + base: {ref: 'main'}, + draft: unsafeInvalidValue('false') + }, + status: 200 + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n> Your pull request is in a draft state', + status: false + }) +}) + +test('runs prechecks and finds the PR is BEHIND and a noop deploy and the commit status is null', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILED') + } + } + ] + } + } + } + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'warn' + + await assertChecksUnavailable() +}) + +test('runs prechecks and finds the PR is BEHIND and a full deploy and update_branch is set to warn', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + data.inputs.update_branch = 'warn' + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\nYour branch is behind the base branch and will need to be updated before deployments can continue.\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `warn`\n\n> Please ensure your branch is up to date with the `main` branch and try again', + status: false + }) +}) + +test('runs prechecks and finds the PR is behind the stable branch and a full deploy and force updates the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + mergeStateStatus: 'BEHIND', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + updateBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + message: 'Updating pull request branch.', + url: 'https://api.github.com/repos/foo/bar/pulls/123' + }, + status: 202 + }) + ) + + data.inputs.update_branch = 'force' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- mergeStateStatus: `BEHIND`\n- update_branch: `force`\n\n> I went ahead and updated your branch with `main` - Please try again once this operation is complete', + status: false + }) +}) + +test('runs prechecks and fails with a non 200 permissionRes.status', async () => { + getCollabOK.mock.mockImplementationOnce(() => + Promise.resolve({data: {permission: 'admin'}, status: 500}) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'Permission check returns non-200 status: 500', + status: false + }) +}) + +test('runs prechecks and finds that the IssueOps commands are valid and from a defined admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… CI is passing and approval is bypassed due to admin rights', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds that the IssueOps commands are valid with parameters and from a defined admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + data.environmentObj.params = 'something something something' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… CI is passing and approval is bypassed due to admin rights', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds that the IssueOps commands are valid with parameters and from a defined admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + data.environmentObj.noop = true + data.environmentObj.params = 'something something something' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `โœ… all CI checks passed and ${COLORS.highlight}noop${COLORS.reset} deployment requested`, + noopMode: true, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) +}) + +test('fails closed for malformed CI data even when the actor is an admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: { + state: unsafeInvalidValue(null) + } + } + } + ] + } + } + } + }) + ) + + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + await assertChecksUnavailable() +}) + +test('runs prechecks and finds that no CI checks exist and reviews are not defined', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + } + } + }) + ) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertLastCalledWith( + infoMock, + '๐ŸŽ›๏ธ CI checks have not been defined and required reviewers have not been defined' + ) +}) + +test('runs prechecks and finds that no CI checks exist but reviews are defined and it is from an admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + } + } + }) + ) + + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI checks have not been defined and approval is bypassed due to admin rights', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertLastCalledWith( + infoMock, + 'โœ… CI checks have not been defined and approval is bypassed due to admin rights' + ) +}) + +test('runs prechecks and finds that no CI checks exist and the PR is not approved, but it is from an admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + } + } + }) + ) + + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI checks have not been defined and approval is bypassed due to admin rights', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertLastCalledWith( + infoMock, + 'โœ… CI checks have not been defined and approval is bypassed due to admin rights' + ) +}) + +test('runs prechecks and finds that skip_ci is set and the PR has been approved', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + reviews: { + totalCount: 1 + }, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + } + } + }) + ) + + data.environment = 'development' + data.inputs.skipCi = 'development' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI requirements have been disabled for this environment and the PR has been approved', + status: true, + noopMode: false, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + assertCalledWith( + infoMock, + 'โœ… CI requirements have been disabled for this environment and the PR has been approved' + ) +}) + +test('runs prechecks and finds that the commit status is success and skip_reviews is set for the environment', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environment = 'staging' + data.inputs.skipReviews = 'staging' + data.inputs.skipCi = 'development' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI checks passed and required reviewers have been disabled for this environment', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + infoMock, + 'โœ… CI checks passed and required reviewers have been disabled for this environment' + ) +}) + +test('runs prechecks and finds that no ci checks are defined and skip_reviews is set for the environment', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: null + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environment = 'staging' + data.inputs.skipReviews = 'staging' + data.inputs.skipCi = 'development' + data.inputs.draft_permitted_targets = 'development' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI checks have not been defined and required reviewers have been disabled for this environment', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + infoMock, + 'โœ… CI checks have not been defined and required reviewers have been disabled for this environment' + ) +}) + +test('runs prechecks on a custom deploy comment with a custom variable at the end', async () => { + data.environment = 'dev' + data.environmentObj.params = 'something' + data.inputs.skipCi = 'dev' + data.inputs.skipReviews = 'dev' + + assert.deepStrictEqual( + await prechecks( + context, // event context + octokit, // octokit instance + data // data object + ), + { + message: + 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + } + ) + + assertCalledWith( + infoMock, + 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment' + ) +}) + +test('runs prechecks when an exact sha is set, but the sha deployment feature is not enabled', async () => { + data.inputs.allow_sha_deployments = false + data.environmentObj.sha = '82c238c277ca3df56fe9418a5913d9188eafe3bc' + + assert.deepStrictEqual( + await prechecks( + context, // event context + octokit, // octokit instance + data // data object + ), + { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- allow_sha_deployments: \`${data.inputs.allow_sha_deployments}\`\n\n> sha deployments have not been enabled`, + status: false + } + ) +}) + +test('runs prechecks when an exact sha is set, and the sha deployment feature is enabled', async () => { + data.inputs.allow_sha_deployments = true + data.environmentObj.sha = '82c238c277ca3df56fe9418a5913d9188eafe3bc' + + assert.deepStrictEqual( + await prechecks( + context, // event context + octokit, // octokit instance + data // data object + ), + { + message: `โœ… deployment requested using an exact ${COLORS.highlight}sha${COLORS.reset}`, + noopMode: false, + ref: data.environmentObj.sha, + status: true, + sha: data.environmentObj.sha, + isFork: false + } + ) + + assertCalledWith( + infoMock, + `โœ… deployment requested using an exact ${COLORS.highlight}sha${COLORS.reset}` + ) + + assertCalledWith( + warningMock, + `โš ๏ธ sha deployments are ${COLORS.warning}unsafe${COLORS.reset} as they bypass all checks - read more here: https://github.com/github/branch-deploy/blob/main/docs/sha-deployments.md` + ) + + assertCalledWith(setOutputMock, 'sha_deployment', data.environmentObj.sha) +}) + +test('runs prechecks and finds that skip_ci is set and now reviews are defined', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environment = 'development' + data.inputs.skipCi = 'development' + data.inputs.skipReviews = 'staging' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '๐ŸŽ›๏ธ CI requirements have been disabled for this environment and required reviewers have not been defined', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + infoMock, + '๐ŸŽ›๏ธ CI requirements have been disabled for this environment and required reviewers have not been defined' + ) +}) + +test('runs prechecks and finds that skip_ci is set, reviews are required, and its a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environment = 'development' + data.environmentObj.noop = true + data.inputs.skipCi = 'development' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI requirements have been disabled for this environment and **noop** requested', + noopMode: true, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + infoMock, + 'โœ… CI requirements have been disabled for this environment and **noop** requested' + ) +}) + +test('runs prechecks and finds that skip_ci is set and skip_reviews is set', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environment = 'development' + data.inputs.skipCi = 'development' + data.inputs.skipReviews = 'development' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + infoMock, + 'โœ… CI requirements have been disabled for this environment and pr reviews have also been disabled for this environment' + ) +}) + +test('runs prechecks and finds that skip_ci is set and the deployer is an admin', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('FAILURE') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(true)) + + data.environment = 'development' + data.inputs.skipCi = 'development' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + 'โœ… CI requirements have been disabled for this environment and approval is bypassed due to admin rights', + noopMode: false, + ref: 'test-ref', + status: true, + sha: 'abc123', + isFork: false + }) + + assertCalledWith( + infoMock, + 'โœ… CI requirements have been disabled for this environment and approval is bypassed due to admin rights' + ) +}) + +test('runs prechecks and finds that CI is pending and reviewers have not been defined and it IS a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: null, + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('PENDING') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: \`null\`\n- commitStatus: \`PENDING\`\n\n> CI checks must be passing in order to continue`, + status: false + }) + + assertCalledWith( + infoMock, + 'note: even noop deploys require CI to finish and be in a passing state' + ) +}) + +test('runs prechecks and finds that the PR is NOT reviewed and CI checks have been disabled and it is NOT a noop deploy', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'REVIEW_REQUIRED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('PENDING') + } + } + ] + } + } + } + }) + ) + isAdminMock.mock.mockImplementation(() => Promise.resolve(false)) + + data.environment = 'staging' + data.inputs.skipCi = 'staging' + data.inputs.skipReviews = 'production' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: `### โš ๏ธ Cannot proceed with deployment\n\n- reviewDecision: \`REVIEW_REQUIRED\`\n- commitStatus: \`skip_ci\`\n\n> Your pull request is missing required approvals`, + status: false + }) + + assertCalledWith( + infoMock, + 'note: CI checks are disabled for this environment so they will not be evaluated' + ) +}) + +test('runs prechecks and finds the PR is behind the stable branch (BLOCKED) and a noop deploy and force updates the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'BLOCKED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123' + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + + isOutdatedMock.mock.mockImplementation(() => + Promise.resolve({ + outdated: true, + branch: 'main' + }) + ) + + updateBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + message: 'Updating pull request branch.', + url: 'https://api.github.com/repos/foo/bar/pulls/123' + }, + status: 202 + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'force' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: + '### โš ๏ธ Cannot proceed with deployment\n\n- mergeStateStatus: `BLOCKED`\n- update_branch: `force`\n\n> I went ahead and updated your branch with `main` - Please try again once this operation is complete', + status: false + }) +}) + +test('runs prechecks and finds the PR is NOT behind the stable branch (BLOCKED) and a noop deploy and does not update the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'BLOCKED', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123' + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + getBranchMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, + status: 200 + }) + ) + + updateBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + message: 'Updating pull request branch.', + url: 'https://api.github.com/repos/foo/bar/pulls/123' + }, + status: 202 + }) + ) + + data.environmentObj.noop = true + data.inputs.update_branch = 'force' + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + status: true, + noopMode: true, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) +}) + +test('runs prechecks and finds the PR is NOT behind the stable branch (HAS_HOOKS) and a noop deploy and does not update the branch', async () => { + graphQLOK.mock.mockImplementation(() => + Promise.resolve({ + repository: { + pullRequest: { + reviewDecision: 'APPROVED', + mergeStateStatus: 'HAS_HOOKS', + commits: { + nodes: [ + { + commit: { + oid: 'abc123', + statusCheckRollup: checkRollup('SUCCESS') + } + } + ] + } + } + } + }) + ) + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123' + }, + base: { + ref: 'main' + } + }, + status: 200 + }) + ) + getBranchMock.mock.mockImplementationOnce(() => + Promise.resolve({ + data: {commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}}, + status: 200 + }) + ) + updateBranchMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + message: 'Updating pull request branch.', + url: 'https://api.github.com/repos/foo/bar/pulls/123' + }, + status: 202 + }) + ) + + data.environmentObj.noop = true + + assert.deepStrictEqual(await prechecks(context, octokit, data), { + message: 'โœ… PR is approved and all CI checks passed', + status: true, + noopMode: true, + ref: 'test-ref', + sha: 'abc123', + isFork: false + }) + + assertCalledWith(setOutputMock, 'default_branch_tree_sha', 'beefdead') +}) + +// Tests for branch existence checks +class NotFoundError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 404 + } +} + +class UnexpectedError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 500 + } +} + +test('fails prechecks when the branch does not exist (deleted branch)', async () => { + // Mock getBranch to throw a 404 error for the PR branch check + queueMockImplementation( + getBranchMock, + // First call: stable branch check (succeeds) + () => + Promise.resolve({ + data: { + commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, + name: 'main' + }, + status: 200 + }), + // Second call: PR branch check (fails with 404) + () => Promise.reject(new NotFoundError('Reference does not exist')) + ) + + const result = await prechecks(context, octokit, data) + + assert.strictEqual(result.status, false) + assert.ok(result.message.includes('Cannot proceed with deployment')) + assert.ok(result.message.includes('ref: `test-ref`')) + assert.ok( + result.message.includes('The branch for this pull request no longer exists') + ) + assertCalledWith(warningMock, 'branch does not exist: test-ref') +}) + +test('passes prechecks when branch exists (normal deployment)', async () => { + // Mock getBranch to succeed for all calls + queueMockImplementation( + getBranchMock, + // First call: stable branch check + () => + Promise.resolve({ + data: { + commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, + name: 'main' + }, + status: 200 + }), + // Second call: base branch check + () => + Promise.resolve({ + data: {commit: {sha: 'deadbeef'}, name: 'main'}, + status: 200 + }), + // Third call: PR branch check (succeeds) + () => + Promise.resolve({ + data: {commit: {sha: 'abc123'}, name: 'test-ref'}, + status: 200 + }) + ) + + const result = await prechecks(context, octokit, data) + + assert.strictEqual(result.status, true) + assert.strictEqual(result.ref, 'test-ref') + assertCalledWith(debugMock, 'checking if branch exists: test-ref') + assertCalledWith(infoMock, 'โœ… branch exists: test-ref') +}) + +test('skips branch existence check when deploying to stable branch', async () => { + data.environmentObj.stable_branch_used = true + + // The stable and base branch lookup is reused. + queueMockImplementation(getBranchMock, () => + Promise.resolve({ + data: { + commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, + name: 'main' + }, + status: 200 + }) + ) + + const result = await prechecks(context, octokit, data) + + assert.strictEqual(result.status, true) + assertCalledTimes(octokit.rest.repos.getBranch, 1) + assertNotCalledWith(debugMock, 'checking if branch exists: test-ref') +}) + +test('skips branch existence check when deploying an exact SHA', async () => { + data.environmentObj.sha = 'abc123def456' + data.inputs.allow_sha_deployments = true + + queueMockImplementation(getBranchMock, () => + Promise.resolve({ + data: { + commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, + name: 'main' + }, + status: 200 + }) + ) + + const result = await prechecks(context, octokit, data) + + assert.strictEqual(result.status, true) + // Verify the branch existence check was skipped + assertCalledTimes(octokit.rest.repos.getBranch, 1) + assertNotCalledWith(debugMock, 'checking if branch exists: test-ref') +}) + +test('skips branch existence check when PR fork deployments are explicitly allowed', async () => { + data.inputs.allowForks = true + + // Mock the PR as a fork + getPullsOK.mock.mockImplementation(() => + Promise.resolve({ + data: { + head: { + ref: 'test-ref', + sha: 'abc123', + repo: { + fork: true + }, + label: 'fork:test-ref' + }, + base: { + ref: 'main' + }, + draft: false + }, + status: 200 + }) + ) + + queueMockImplementation(getBranchMock, () => + Promise.resolve({ + data: { + commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, + name: 'main' + }, + status: 200 + }) + ) + + const result = await prechecks(context, octokit, data) + + assert.partialDeepStrictEqual(result, {status: true, isFork: true}) + // Verify the branch existence check was skipped for forks + assertCalledTimes(octokit.rest.repos.getBranch, 1) + assertNotCalledWith(debugMock, 'checking if branch exists: abc123') +}) + +test('fails prechecks when branch check encounters unexpected error', async () => { + // Mock getBranch to throw a non-404 error + queueMockImplementation( + getBranchMock, + () => + Promise.resolve({ + data: { + commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}}, + name: 'main' + }, + status: 200 + }), + () => Promise.reject(new UnexpectedError('Internal server error')) + ) + + const result = await prechecks(context, octokit, data) + + // Should fail and not continue + assert.strictEqual(result.status, false) + assert.ok(result.message.includes('Cannot proceed with deployment')) + assert.ok(result.message.includes('ref: `test-ref`')) + assert.ok( + result.message.includes( + 'An unexpected error occurred while checking if the branch exists' + ) + ) + assert.ok(result.message.includes('Internal server error')) +}) diff --git a/__tests__/functions/react-emote.test.js b/__tests__/functions/react-emote.test.js deleted file mode 100644 index 6360f24d..00000000 --- a/__tests__/functions/react-emote.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import {reactEmote} from '../../src/functions/react-emote.js' -import {vi, expect, test} from 'vitest' - -const context = { - repo: { - owner: 'corp', - repo: 'test' - }, - payload: { - comment: { - id: '1' - } - } -} - -const octokit = { - rest: { - reactions: { - createForIssueComment: vi.fn().mockReturnValueOnce({ - data: { - id: '1' - } - }) - } - } -} - -test('adds a reaction emote to a comment', async () => { - expect(await reactEmote('eyes', context, octokit)).toStrictEqual({ - data: {id: '1'} - }) -}) - -test('returns if no reaction is specified', async () => { - expect(await reactEmote('', context, octokit)).toBe(undefined) - expect(await reactEmote(null, context, octokit)).toBe(undefined) -}) - -test('throws an error if a bad emote is used', async () => { - try { - await reactEmote('bad', context, octokit) - } catch (e) { - expect(e.message).toBe('Reaction "bad" is not a valid preset') - } -}) diff --git a/__tests__/functions/react-emote.test.ts b/__tests__/functions/react-emote.test.ts new file mode 100644 index 00000000..682c0879 --- /dev/null +++ b/__tests__/functions/react-emote.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import {mock, test} from 'node:test' +import { + reactEmote, + type ReactEmoteOctokit +} from '../../src/functions/react-emote.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' +import {createIssueCommentContext} from '../test-helpers.ts' + +const context = createIssueCommentContext({ + repo: {owner: 'corp', repo: 'test'}, + payload: {comment: {id: 1}} +}) + +const octokit = { + rest: { + reactions: { + createForIssueComment: mock.fn(() => Promise.resolve({data: {id: 1}})) + } + } +} satisfies ReactEmoteOctokit + +test('adds a reaction emote to a comment', async () => { + assert.strictEqual(await reactEmote('eyes', context, octokit), 1) +}) + +test('returns if no reaction is specified', async () => { + assert.strictEqual(await reactEmote('', context, octokit), null) + assert.strictEqual( + await reactEmote( + unsafeInvalidValue[0]>(null), + context, + octokit + ), + null + ) +}) + +test('throws an error if a bad emote is used', async () => { + await assert.rejects(reactEmote('bad', context, octokit), { + message: 'Reaction "bad" is not a valid preset' + }) +}) + +test('warns and continues when a valid decorative reaction cannot be created', async () => { + const failingOctokit = { + rest: { + reactions: { + createForIssueComment: mock.fn(() => + Promise.reject(new Error('reaction unavailable')) + ) + } + } + } satisfies ReactEmoteOctokit + + assert.strictEqual(await reactEmote('eyes', context, failingOctokit), null) +}) diff --git a/__tests__/functions/selected-ref-check.test.ts b/__tests__/functions/selected-ref-check.test.ts new file mode 100644 index 00000000..ae3872d3 --- /dev/null +++ b/__tests__/functions/selected-ref-check.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import {beforeEach, test} from 'node:test' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {selectedRefMatches} from '../../src/functions/selected-ref-check.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + assertNotCalled, + createMock +} from '../node-test-helpers.ts' + +type Octokit = Parameters[0] + +const getPullMock = createMock() +const getBranchMock = createMock() +const octokit: Octokit = { + rest: { + pulls: {get: getPullMock}, + repos: {getBranch: getBranchMock} + } +} +const context = createContext({ + issue: {number: 42}, + repo: {owner: 'corp', repo: 'test'} +}) +const request = { + exactSha: false, + expectedSha: 'expected', + isFork: false, + stableBranch: 'main', + stableBranchUsed: false +} as const + +beforeEach(() => { + getPullMock.mock.resetCalls() + getBranchMock.mock.resetCalls() +}) + +for (const immutable of [ + {...request, exactSha: true}, + {...request, isFork: true} +] as const) { + test(`does not re-fetch immutable ref ${JSON.stringify(immutable)}`, async () => { + assert.strictEqual( + await selectedRefMatches(octokit, context, immutable), + true + ) + assertNotCalled(getPullMock) + assertNotCalled(getBranchMock) + }) +} + +test('re-fetches and accepts an unchanged pull request head', async () => { + getPullMock.mock.mockImplementation(() => + Promise.resolve({data: {head: {sha: 'expected'}}}) + ) + + assert.strictEqual(await selectedRefMatches(octokit, context, request), true) + assertCalledWith(getPullMock, { + owner: 'corp', + repo: 'test', + pull_number: 42, + headers: API_HEADERS + }) +}) + +test('rejects a changed pull request head', async () => { + getPullMock.mock.mockImplementation(() => + Promise.resolve({data: {head: {sha: 'changed'}}}) + ) + + assert.strictEqual(await selectedRefMatches(octokit, context, request), false) +}) + +test('re-fetches the selected stable branch', async () => { + getBranchMock.mock.mockImplementation(() => + Promise.resolve({data: {commit: {sha: 'expected'}}}) + ) + const stableRequest = {...request, stableBranchUsed: true} + + assert.strictEqual( + await selectedRefMatches(octokit, context, stableRequest), + true + ) + assertCalledWith(getBranchMock, { + owner: 'corp', + repo: 'test', + branch: 'main', + headers: API_HEADERS + }) + assertNotCalled(getPullMock) +}) + +test('rejects a changed stable branch', async () => { + getBranchMock.mock.mockImplementation(() => + Promise.resolve({data: {commit: {sha: 'changed'}}}) + ) + + assert.strictEqual( + await selectedRefMatches(octokit, context, { + ...request, + stableBranchUsed: true + }), + false + ) +}) diff --git a/__tests__/functions/string-to-array.test.js b/__tests__/functions/string-to-array.test.js deleted file mode 100644 index e08ee977..00000000 --- a/__tests__/functions/string-to-array.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import {stringToArray} from '../../src/functions/string-to-array.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' - -const debugMock = vi.spyOn(core, 'debug') -const errorMock = vi.spyOn(core, 'error') - -beforeEach(() => { - vi.clearAllMocks() -}) - -test('successfully converts a string to an array', async () => { - expect(stringToArray('production,staging,development')).toStrictEqual([ - 'production', - 'staging', - 'development' - ]) -}) - -test('successfully converts a single string item string to an array', async () => { - expect(stringToArray('production,')).toStrictEqual(['production']) - - expect(stringToArray('production')).toStrictEqual(['production']) -}) - -test('successfully converts an empty string to an empty array', async () => { - expect(stringToArray('')).toStrictEqual([]) - - expect(debugMock).toHaveBeenCalledWith( - 'in stringToArray(), an empty String was found so an empty Array was returned' - ) -}) - -test('successfully converts garbage to an empty array', async () => { - expect(stringToArray(',,,')).toStrictEqual([]) -}) - -test('throws an error when string processing fails', async () => { - // Pass a non-string value to trigger the error - expect(() => stringToArray(null)).toThrow('could not convert String to Array') - expect(errorMock).toHaveBeenCalled() -}) diff --git a/__tests__/functions/string-to-array.test.ts b/__tests__/functions/string-to-array.test.ts new file mode 100644 index 00000000..fbd1378a --- /dev/null +++ b/__tests__/functions/string-to-array.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {installModuleMock} from '../node-test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const debugMock = mock.fn() +const errorMock = mock.fn() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: errorMock +}) + +const {stringToArray} = await import('../../src/functions/string-to-array.ts') + +beforeEach(() => { + debugMock.mock.resetCalls() + errorMock.mock.resetCalls() +}) + +test('successfully converts a string to an array', () => { + assert.deepStrictEqual(stringToArray('production,staging,development'), [ + 'production', + 'staging', + 'development' + ]) +}) + +test('successfully converts a single string item string to an array', () => { + assert.deepStrictEqual(stringToArray('production,'), ['production']) + + assert.deepStrictEqual(stringToArray('production'), ['production']) +}) + +test('successfully converts an empty string to an empty array', () => { + assert.deepStrictEqual(stringToArray(''), []) + + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [ + 'in stringToArray(), an empty String was found so an empty Array was returned' + ] + ] + ) +}) + +test('successfully converts garbage to an empty array', () => { + assert.deepStrictEqual(stringToArray(',,,'), []) +}) + +test('trims surrounding whitespace and filters empty comma-separated items', () => { + assert.deepStrictEqual( + stringToArray(' \tproduction , , staging,\n development,\t '), + ['production', 'staging', 'development'] + ) +}) + +test('treats whitespace-only input as an empty array', () => { + assert.deepStrictEqual(stringToArray(' \t\r\n '), []) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [ + 'in stringToArray(), an empty String was found so an empty Array was returned' + ] + ] + ) +}) + +test('throws an error when string processing fails', () => { + // Pass a non-string value to trigger the error + assert.throws( + () => + stringToArray( + unsafeInvalidValue[0]>(null) + ), + /could not convert String to Array/ + ) + assert.strictEqual(errorMock.mock.callCount(), 1) +}) diff --git a/__tests__/functions/time-diff.test.js b/__tests__/functions/time-diff.test.js deleted file mode 100644 index 48c89da3..00000000 --- a/__tests__/functions/time-diff.test.js +++ /dev/null @@ -1,14 +0,0 @@ -import {timeDiff} from '../../src/functions/time-diff.js' -import {expect, test} from 'vitest' - -test('checks the time elapsed between two dates - days apart', async () => { - expect( - await timeDiff('2022-06-08T14:28:50.149Z', '2022-06-10T20:55:18.356Z') - ).toStrictEqual('2d:6h:26m:28s') -}) - -test('checks the time elapsed between two dates - seconds apart', async () => { - expect( - await timeDiff('2022-06-10T20:55:20.999Z', '2022-06-10T20:55:50.356Z') - ).toStrictEqual('0d:0h:0m:29s') -}) diff --git a/__tests__/functions/time-diff.test.ts b/__tests__/functions/time-diff.test.ts new file mode 100644 index 00000000..60d1aae5 --- /dev/null +++ b/__tests__/functions/time-diff.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {timeDiff} from '../../src/functions/time-diff.ts' + +test('checks the time elapsed between two dates - days apart', () => { + assert.strictEqual( + timeDiff('2022-06-08T14:28:50.149Z', '2022-06-10T20:55:18.356Z'), + '2d:6h:26m:28s' + ) +}) + +test('checks the time elapsed between two dates - seconds apart', () => { + assert.strictEqual( + timeDiff('2022-06-10T20:55:20.999Z', '2022-06-10T20:55:50.356Z'), + '0d:0h:0m:29s' + ) +}) diff --git a/__tests__/functions/timestamp.test.js b/__tests__/functions/timestamp.test.js deleted file mode 100644 index 18cbbad4..00000000 --- a/__tests__/functions/timestamp.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import {timestamp} from '../../src/functions/timestamp.js' -import {vi, expect, test, beforeEach, afterEach} from 'vitest' - -beforeEach(() => { - vi.clearAllMocks() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -test('should return the current date in ISO 8601 format', () => { - const mockDate = new Date('2025-01-01T00:00:00.000Z') - vi.setSystemTime(mockDate) - - const result = timestamp() - - expect(result).toBe('2025-01-01T00:00:00.000Z') -}) diff --git a/__tests__/functions/timestamp.test.ts b/__tests__/functions/timestamp.test.ts new file mode 100644 index 00000000..b17aa122 --- /dev/null +++ b/__tests__/functions/timestamp.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {timestamp} from '../../src/functions/timestamp.ts' + +test('should return the current date in ISO 8601 format', context => { + const mockDate = new Date('2025-01-01T00:00:00.000Z') + context.mock.timers.enable({apis: ['Date'], now: mockDate}) + + const result = timestamp() + + assert.strictEqual(result, '2025-01-01T00:00:00.000Z') +}) diff --git a/__tests__/functions/trigger-check.test.js b/__tests__/functions/trigger-check.test.js deleted file mode 100644 index 23a1b284..00000000 --- a/__tests__/functions/trigger-check.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import {triggerCheck} from '../../src/functions/trigger-check.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' -import {COLORS} from '../../src/functions/colors.js' - -const color = COLORS.highlight -const colorReset = COLORS.reset -const infoMock = vi.spyOn(core, 'info') -const debugMock = vi.spyOn(core, 'debug') - -beforeEach(() => { - vi.clearAllMocks() -}) - -test('checks a message and finds a standard trigger', async () => { - const body = '.deploy' - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(true) - expect(infoMock).toHaveBeenCalledWith( - `โœ… comment body starts with trigger: ${color}.deploy${colorReset}` - ) -}) - -test('checks a message and does not find trigger', async () => { - const body = '.bad' - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(false) - expect(debugMock).toHaveBeenCalledWith( - `comment body does not start with trigger: ${color}.deploy${colorReset}` - ) -}) - -test('checks a message and finds a global trigger', async () => { - const body = 'I want to .deploy' - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(false) - expect(debugMock).toHaveBeenCalledWith( - `comment body does not start with trigger: ${color}.deploy${colorReset}` - ) -}) - -test('checks a message and finds a trigger with an environment and a variable', async () => { - const trigger = '.deploy' - expect(await triggerCheck('.deploy dev something', trigger)).toBe(true) - expect(await triggerCheck('.deploy something', trigger)).toBe(true) - expect(await triggerCheck('.deploy dev something', trigger)).toBe(true) - expect(await triggerCheck('.deploy dev something', trigger)).toBe(true) -}) - -test('checks a message and does not find global trigger', async () => { - const body = 'I want to .ping a website' - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(false) - expect(debugMock).toHaveBeenCalledWith( - `comment body does not start with trigger: ${color}.deploy${colorReset}` - ) -}) - -test('does not match when body starts with a longer command sharing prefix', async () => { - const body = '.deploy-two to prod' - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(false) - expect(debugMock).toHaveBeenCalledWith( - `comment body starts with trigger but is not complete: ${color}.deploy${colorReset}` - ) -}) - -test('does not match when immediately followed by alphanumeric', async () => { - const body = '.deploy1' - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(false) - expect(debugMock).toHaveBeenCalledWith( - `comment body starts with trigger but is not complete: ${color}.deploy${colorReset}` - ) -}) - -test('matches when followed by a newline (whitespace)', async () => { - const body = `.deploy\ndev` - const trigger = '.deploy' - expect(await triggerCheck(body, trigger)).toBe(true) -}) diff --git a/__tests__/functions/trigger-check.test.ts b/__tests__/functions/trigger-check.test.ts new file mode 100644 index 00000000..eb3c92bf --- /dev/null +++ b/__tests__/functions/trigger-check.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {installModuleMock} from '../node-test-helpers.ts' +import {COLORS} from '../../src/functions/colors.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const color = COLORS.highlight +const colorReset = COLORS.reset +const infoMock = mock.fn() +const debugMock = mock.fn() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + info: infoMock, + debug: debugMock +}) + +const {triggerCheck} = await import('../../src/functions/trigger-check.ts') + +beforeEach(() => { + infoMock.mock.resetCalls() + debugMock.mock.resetCalls() +}) + +test('checks a message and finds a standard trigger', () => { + const body = '.deploy' + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), true) + assert.deepStrictEqual( + infoMock.mock.calls.map(call => call.arguments), + [[`โœ… comment body starts with trigger: ${color}.deploy${colorReset}`]] + ) +}) + +test('checks a message and does not find trigger', () => { + const body = '.bad' + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), false) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [[`comment body does not start with trigger: ${color}.deploy${colorReset}`]] + ) +}) + +test('checks a message and finds a global trigger', () => { + const body = 'I want to .deploy' + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), false) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [[`comment body does not start with trigger: ${color}.deploy${colorReset}`]] + ) +}) + +test('checks a message and finds a trigger with an environment and a variable', () => { + const trigger = '.deploy' + assert.strictEqual(triggerCheck('.deploy dev something', trigger), true) + assert.strictEqual(triggerCheck('.deploy something', trigger), true) + assert.strictEqual(triggerCheck('.deploy dev something', trigger), true) + assert.strictEqual(triggerCheck('.deploy dev something', trigger), true) +}) + +test('checks a message and does not find global trigger', () => { + const body = 'I want to .ping a website' + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), false) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [[`comment body does not start with trigger: ${color}.deploy${colorReset}`]] + ) +}) + +test('does not match when body starts with a longer command sharing prefix', () => { + const body = '.deploy-two to prod' + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), false) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [ + `comment body starts with trigger but is not complete: ${color}.deploy${colorReset}` + ] + ] + ) +}) + +test('does not match when immediately followed by alphanumeric', () => { + const body = '.deploy1' + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), false) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [ + `comment body starts with trigger but is not complete: ${color}.deploy${colorReset}` + ] + ] + ) +}) + +test('matches when followed by a newline (whitespace)', () => { + const body = `.deploy\ndev` + const trigger = '.deploy' + assert.strictEqual(triggerCheck(body, trigger), true) +}) + +for (const whitespace of ['\t', '\r\n', '\u00a0']) { + test(`matches when followed by ${JSON.stringify(whitespace)} whitespace`, () => { + assert.strictEqual( + triggerCheck(`.deploy${whitespace}production`, '.deploy'), + true + ) + }) +} + +test('treats regex-significant characters in custom triggers literally', () => { + const trigger = '.deploy[prod]+?' + + assert.strictEqual(triggerCheck(`${trigger} production`, trigger), true) + assert.strictEqual(triggerCheck('.deployprod production', trigger), false) + assert.strictEqual(triggerCheck(`${trigger}-canary`, trigger), false) +}) diff --git a/__tests__/functions/truncate-comment-body.test.js b/__tests__/functions/truncate-comment-body.test.js deleted file mode 100644 index 878e7b92..00000000 --- a/__tests__/functions/truncate-comment-body.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import {truncateCommentBody} from '../../src/functions/truncate-comment-body.js' - -beforeEach(() => { - vi.clearAllMocks() -}) - -test('truncates a long message', () => { - const message = 'a'.repeat(65537) - const got = truncateCommentBody(message) - expect(got).toContain('The message is too large to be posted as a comment.') - expect(got.length).toBeLessThanOrEqual(65536) -}) - -test('does not truncate a short message', () => { - const message = 'a'.repeat(65536) - const got = truncateCommentBody(message) - expect(got).toEqual(message) -}) diff --git a/__tests__/functions/truncate-comment-body.test.ts b/__tests__/functions/truncate-comment-body.test.ts new file mode 100644 index 00000000..064ffb22 --- /dev/null +++ b/__tests__/functions/truncate-comment-body.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {truncateCommentBody} from '../../src/functions/truncate-comment-body.ts' + +test('truncates a long message', () => { + const message = 'a'.repeat(65537) + const got = truncateCommentBody(message) + assert.ok(got.includes('The message is too large to be posted as a comment.')) + assert.ok(got.length <= 65536) +}) + +test('does not truncate a short message', () => { + const message = 'a'.repeat(65536) + const got = truncateCommentBody(message) + assert.strictEqual(got, message) +}) + +for (const length of [0, 65535, 65536]) { + test(`preserves a comment at the ${length}-character boundary`, () => { + const message = 'a'.repeat(length) + + assert.strictEqual(truncateCommentBody(message), message) + }) +} + +for (const length of [65537, 131072]) { + test(`wraps and caps a ${length}-character comment`, () => { + const rendered = truncateCommentBody('a'.repeat(length)) + + assert.strictEqual(rendered.length, 65536) + assert.ok( + rendered.startsWith( + 'The message is too large to be posted as a comment.\n
Click to see the truncated message\n' + ) + ) + assert.ok(rendered.endsWith('\n
')) + }) +} diff --git a/__tests__/functions/trusted-deployment-template.test.ts b/__tests__/functions/trusted-deployment-template.test.ts new file mode 100644 index 00000000..977b600c --- /dev/null +++ b/__tests__/functions/trusted-deployment-template.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict' +import {beforeEach, test} from 'node:test' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import { + loadTrustedDeploymentTemplate, + type TrustedTemplateOctokit +} from '../../src/functions/trusted-deployment-template.ts' +import {createContext} from '../test-helpers.ts' +import {assertNotCalled, createMock} from '../node-test-helpers.ts' + +type GetContent = TrustedTemplateOctokit['rest']['repos']['getContent'] + +const getContentMock = createMock() +const octokit: TrustedTemplateOctokit = { + rest: {repos: {getContent: getContentMock}} +} +const context = createContext({repo: {owner: 'corp', repo: 'test'}}) +const trustedSha = '0123456789abcdef0123456789abcdef01234567' + +beforeEach(() => { + getContentMock.mock.resetCalls() +}) + +test('loads and decodes a repository-relative template at the exact trusted SHA', async () => { + const template = '# Deployment\n\n{{ results }}\n' + getContentMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + type: 'file', + encoding: 'base64', + content: Buffer.from(template, 'utf8').toString('base64') + } + }) + ) + + assert.strictEqual( + await loadTrustedDeploymentTemplate( + octokit, + context, + '.github/deployment_message.md', + trustedSha + ), + template + ) + assert.deepStrictEqual( + getContentMock.mock.calls.map(call => call.arguments), + [ + [ + { + owner: 'corp', + repo: 'test', + path: '.github/deployment_message.md', + ref: trustedSha, + headers: API_HEADERS + } + ] + ] + ) +}) + +test('accepts a 64-character immutable trusted SHA', async () => { + const sha = 'a'.repeat(64) + getContentMock.mock.mockImplementation(() => + Promise.resolve({ + data: {type: 'file', encoding: 'base64', content: ''} + }) + ) + + assert.strictEqual( + await loadTrustedDeploymentTemplate(octokit, context, 'message.md', sha), + '' + ) + assert.strictEqual(getContentMock.mock.calls[0]?.arguments[0]?.ref, sha) +}) + +test('accepts an uppercase immutable SHA and preserves a UTF-8 path and template', async () => { + const sha = 'ABCDEF0123456789'.repeat(4) + const path = 'docs/dรฉploiement ๐Ÿš€.md' + const template = '# Dรฉploiement ๐Ÿš€\n\nRรฉsultat: {{ results }}\n' + getContentMock.mock.mockImplementation(() => + Promise.resolve({ + data: { + type: 'file', + encoding: 'base64', + content: Buffer.from(template, 'utf8').toString('base64') + } + }) + ) + + assert.strictEqual( + await loadTrustedDeploymentTemplate(octokit, context, path, sha), + template + ) + assert.deepStrictEqual(getContentMock.mock.calls[0]?.arguments[0], { + owner: 'corp', + repo: 'test', + path, + ref: sha, + headers: API_HEADERS + }) +}) + +test('returns null when the trusted SHA does not contain the template', async () => { + getContentMock.mock.mockImplementation(() => + Promise.reject(Object.assign(new Error('Not Found'), {status: 404})) + ) + + assert.strictEqual( + await loadTrustedDeploymentTemplate( + octokit, + context, + '.github/missing.md', + trustedSha + ), + null + ) +}) + +const invalidPaths = [ + '', + '/', + '/etc/passwd', + '.', + '..', + './message.md', + '../message.md', + 'docs/../message.md', + 'docs/./message.md', + 'docs//message.md', + 'docs/', + 'docs\\message.md', + 'C:\\message.md' +] as const + +for (const path of invalidPaths) { + test(`rejects invalid repository path ${JSON.stringify(path)}`, async () => { + await assert.rejects( + loadTrustedDeploymentTemplate(octokit, context, path, trustedSha), + new Error( + 'deploy_message_path must be a repository-relative path without traversal segments' + ) + ) + assertNotCalled(getContentMock) + }) +} + +const invalidShas = [ + '', + 'main', + '0123456789abcdef0123456789abcdef0123456', + '0123456789abcdef0123456789abcdef012345678', + 'g'.repeat(40), + `${trustedSha}\n` +] as const + +for (const sha of invalidShas) { + test(`rejects invalid trusted SHA ${JSON.stringify(sha)}`, async () => { + await assert.rejects( + loadTrustedDeploymentTemplate(octokit, context, 'message.md', sha), + new Error('Trusted deployment template SHA is invalid') + ) + assertNotCalled(getContentMock) + }) +} + +const invalidResponses: readonly unknown[] = [ + null, + 'file', + [], + {}, + {type: 'dir', encoding: 'base64', content: ''}, + {type: 'file'}, + {type: 'file', encoding: 'utf-8', content: ''}, + {type: 'file', encoding: 'base64'}, + {type: 'file', encoding: 'base64', content: 123} +] + +for (const [index, data] of invalidResponses.entries()) { + test(`rejects non-file Contents API response ${index + 1}`, async () => { + getContentMock.mock.mockImplementation(() => Promise.resolve({data})) + + await assert.rejects( + loadTrustedDeploymentTemplate(octokit, context, 'message.md', trustedSha), + new Error('Trusted deployment template response is not a file') + ) + }) +} + +test('propagates non-404 Contents API failures', async () => { + const failure = Object.assign(new Error('Forbidden'), {status: 403}) + getContentMock.mock.mockImplementation(() => Promise.reject(failure)) + + await assert.rejects( + loadTrustedDeploymentTemplate(octokit, context, 'message.md', trustedSha), + failure + ) +}) diff --git a/__tests__/functions/unlock-if-unchanged.test.ts b/__tests__/functions/unlock-if-unchanged.test.ts new file mode 100644 index 00000000..c7b4b45a --- /dev/null +++ b/__tests__/functions/unlock-if-unchanged.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test, type Mock} from 'node:test' +import type {ConditionalUnlockOctokit} from '../../src/functions/unlock-if-unchanged.ts' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const actualCore = await import('../../src/actions-core.ts') +const infoMock = createMock() +const warningMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + ...actualCore, + info: infoMock, + warning: warningMock +}) + +const {unlockIfUnchanged} = + await import('../../src/functions/unlock-if-unchanged.ts') + +const context = createIssueCommentContext({ + repo: {owner: 'corp', repo: 'test'} +}) +const expectedSha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + +let getRepositoryMock: Mock +let graphqlMock: Mock +let octokit: ConditionalUnlockOctokit + +beforeEach(() => { + infoMock.mock.resetCalls() + warningMock.mock.resetCalls() + getRepositoryMock = createMock< + ConditionalUnlockOctokit['rest']['repos']['get'] + >(() => Promise.resolve({data: {node_id: 'R_test'}})) + graphqlMock = createMock(() => + Promise.resolve({updateRefs: {clientMutationId: null}}) + ) + octokit = { + graphql: graphqlMock, + rest: {repos: {get: getRepositoryMock}} + } +}) + +test('removes the original lock with an atomic ref precondition', async () => { + assert.strictEqual( + await unlockIfUnchanged(octokit, context, 'Production West', expectedSha), + true + ) + + assertCalledWith(getRepositoryMock, { + owner: 'corp', + repo: 'test', + headers: API_HEADERS + }) + const call = graphqlMock.mock.calls[0] + assert.ok(call) + assert.match(call.arguments[0], /updateRefs\(input: \$input\)/u) + assert.deepStrictEqual(call.arguments[1], { + input: { + repositoryId: 'R_test', + refUpdates: [ + { + name: 'refs/heads/Production-West-branch-deploy-lock', + beforeOid: expectedSha, + afterOid: '0000000000000000000000000000000000000000' + } + ] + } + }) + assertCalledWith( + infoMock, + '๐Ÿ”“ successfully removed the original deployment lock' + ) +}) + +test('leaves a replacement lock in place when its ref no longer matches', async () => { + graphqlMock.mock.mockImplementation(() => + Promise.reject(new Error('reference no longer points to the expected OID')) + ) + + assert.strictEqual( + await unlockIfUnchanged(octokit, context, 'production', expectedSha), + false + ) + assertCalledWith( + warningMock, + 'could not remove the original deployment lock; leaving the current lock in place: reference no longer points to the expected OID' + ) +}) + +test('leaves the lock in place when the repository identity cannot be read', async () => { + getRepositoryMock.mock.mockImplementation(() => + Promise.reject(new Error('repository unavailable')) + ) + + assert.strictEqual( + await unlockIfUnchanged(octokit, context, 'production', expectedSha), + false + ) + assertCalledWith( + warningMock, + 'could not remove the original deployment lock; leaving the current lock in place: repository unavailable' + ) +}) diff --git a/__tests__/functions/unlock-on-merge.test.js b/__tests__/functions/unlock-on-merge.test.js deleted file mode 100644 index ee81ce27..00000000 --- a/__tests__/functions/unlock-on-merge.test.js +++ /dev/null @@ -1,168 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import * as unlock from '../../src/functions/unlock.js' -import * as checkLockFile from '../../src/functions/check-lock-file.js' -import * as checkBranch from '../../src/functions/lock.js' -import {unlockOnMerge} from '../../src/functions/unlock-on-merge.js' -import {COLORS} from '../../src/functions/colors.js' - -const setOutputMock = vi.spyOn(core, 'setOutput') -const infoMock = vi.spyOn(core, 'info') -const warningMock = vi.spyOn(core, 'warning') -const debugMock = vi.spyOn(core, 'debug') - -const environment_targets = 'production,development,staging' - -var context -var octokit -beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(unlock, 'unlock').mockImplementation(() => { - return 'removed lock - silent' - }) - vi.spyOn(checkLockFile, 'checkLockFile').mockImplementation(() => { - return { - link: 'https://github.com/corp/test/pull/123#issuecomment-123456789' - } - }) - vi.spyOn(checkBranch, 'checkBranch').mockImplementation(() => { - return true - }) - - context = { - eventName: 'pull_request', - repo: { - owner: 'corp', - repo: 'test' - }, - payload: { - action: 'closed', - pull_request: { - merged: true, - number: 123, - head: { - ref: 'deadbeef' - } - } - } - } - - octokit = {} -}) - -test('successfully unlocks all environments on a pull request merge', async () => { - expect( - await unlockOnMerge(octokit, context, environment_targets) - ).toStrictEqual(true) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}staging${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}development${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}production${COLORS.reset}` - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'unlocked_environments', - 'production,development,staging' - ) -}) - -test('finds that no deployment lock is set so none are removed', async () => { - vi.spyOn(unlock, 'unlock').mockImplementation(() => { - return 'no deployment lock currently set - silent' - }) - - expect( - await unlockOnMerge(octokit, context, environment_targets) - ).toStrictEqual(true) - expect(debugMock).toHaveBeenCalledWith( - 'unlock result for unlock-on-merge: no deployment lock currently set - silent' - ) - expect(setOutputMock).toHaveBeenCalledWith('unlocked_environments', '') -}) - -test('only unlocks one environment because the other has no lock and the other is not associated with the pull request', async () => { - checkLockFile.checkLockFile.mockImplementationOnce(() => { - return { - link: 'https://github.com/corp/test/pull/111#issuecomment-123456789' - } - }) - checkLockFile.checkLockFile.mockImplementationOnce(() => { - return false - }) - - expect( - await unlockOnMerge(octokit, context, environment_targets) - ).toStrictEqual(true) - expect(infoMock).toHaveBeenCalledWith( - `โฉ lock for PR ${COLORS.info}111${COLORS.reset} (env: ${COLORS.highlight}production${COLORS.reset}) is not associated with PR ${COLORS.info}123${COLORS.reset} - skipping...` - ) - expect(infoMock).toHaveBeenCalledWith( - `โฉ no lock file found for environment ${COLORS.highlight}development${COLORS.reset} - skipping...` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}staging${COLORS.reset}` - ) -}) - -test('only unlocks one environment because the other is not associated with the pull request and the other has no lock branch', async () => { - checkLockFile.checkLockFile.mockImplementationOnce(() => { - return { - link: 'https://github.com/corp/test/pull/111#issuecomment-123456789' - } - }) - checkBranch.checkBranch.mockImplementationOnce(() => { - return true - }) - checkBranch.checkBranch.mockImplementationOnce(() => { - return false - }) - - expect( - await unlockOnMerge(octokit, context, environment_targets) - ).toStrictEqual(true) - expect(infoMock).toHaveBeenCalledWith( - `โฉ lock for PR ${COLORS.info}111${COLORS.reset} (env: ${COLORS.highlight}production${COLORS.reset}) is not associated with PR ${COLORS.info}123${COLORS.reset} - skipping...` - ) - expect(infoMock).toHaveBeenCalledWith( - `โฉ no lock branch found for environment ${COLORS.highlight}development${COLORS.reset} - skipping...` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}staging${COLORS.reset}` - ) -}) - -test('fails due to the context not being a PR merge', async () => { - context.payload.action = 'opened' - context.payload.pull_request.merged = false - context.payload.eventName = 'pull_request' - expect( - await unlockOnMerge(octokit, context, environment_targets) - ).toStrictEqual(false) - expect(infoMock).toHaveBeenCalledWith( - 'event name: pull_request, action: opened, merged: false' - ) - expect(warningMock).toHaveBeenCalledWith( - `this workflow can only run in the context of a ${COLORS.highlight}merged${COLORS.reset} pull request` - ) -}) - -test('fails due to the context being a PR closed event but not a merge', async () => { - context.payload.action = 'closed' - context.payload.pull_request.merged = false - context.payload.eventName = 'pull_request' - expect( - await unlockOnMerge(octokit, context, environment_targets) - ).toStrictEqual(false) - expect(warningMock).toHaveBeenCalledWith( - `this workflow can only run in the context of a ${COLORS.highlight}merged${COLORS.reset} pull request` - ) - expect(infoMock).toHaveBeenCalledWith( - 'event name: pull_request, action: closed, merged: false' - ) - expect(infoMock).toHaveBeenCalledWith( - 'pull request was closed but not merged so this workflow will not run - OK' - ) -}) diff --git a/__tests__/functions/unlock-on-merge.test.ts b/__tests__/functions/unlock-on-merge.test.ts new file mode 100644 index 00000000..6671e254 --- /dev/null +++ b/__tests__/functions/unlock-on-merge.test.ts @@ -0,0 +1,322 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import type { + SilentUnlockRequest, + SilentUnlockResult +} from '../../src/functions/unlock.ts' +import type { + BranchDeployContext, + BranchDeployOctokit, + LockData, + PullRequestContext +} from '../../src/types.ts' +import {createContext, createOctokit} from '../test-helpers.ts' +import { + assertCalledWith, + assertNotCalled, + createMock, + queueMockImplementation, + installModuleMock +} from '../node-test-helpers.ts' +import {unsafeInvalidValue} from '../unsafe-fixtures.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type CheckLockFile = typeof import('../../src/functions/check-lock-file.ts') +type Lock = typeof import('../../src/functions/lock.ts') + +const debugMock = createMock() +const infoMock = createMock() +const setOutputMock = createMock() +const warningMock = createMock() +const checkBranchMock = createMock() +const checkLockFileMock = createMock() +const unlockMock = + createMock<(request: SilentUnlockRequest) => Promise>() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + info: infoMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../../src/functions/check-lock-file.ts', import.meta.url), + {checkLockFile: checkLockFileMock} +) +installModuleMock( + mock, + new URL('../../src/functions/lock.ts', import.meta.url), + {checkBranch: checkBranchMock} +) +installModuleMock( + mock, + new URL('../../src/functions/unlock.ts', import.meta.url), + {unlock: unlockMock} +) + +const {unlockOnMerge} = await import('../../src/functions/unlock-on-merge.ts') + +const environmentTargets = 'production,development,staging' +const matchingLock = { + branch: 'acceptance-branch', + created_at: '2025-01-01T00:00:00Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/corp/test/pull/123#issuecomment-123456789', + reason: null, + sticky: true, + unlock_command: '.unlock production' +} satisfies LockData + +let context: PullRequestContext +let octokit: BranchDeployOctokit +let silentUnlockResult: SilentUnlockResult + +function pullRequestContext( + action: string, + merged: boolean +): PullRequestContext { + return { + ...createContext({ + eventName: 'pull_request', + issue: {number: 123}, + repo: {owner: 'corp', repo: 'test'} + }), + payload: { + action, + pull_request: {merged, number: 123} + } + } +} + +beforeEach(() => { + for (const mockFunction of [ + debugMock, + infoMock, + setOutputMock, + warningMock, + checkBranchMock, + checkLockFileMock, + unlockMock + ]) { + mockFunction.mock.resetCalls() + } + + silentUnlockResult = 'removed lock - silent' + unlockMock.mock.mockImplementation(() => Promise.resolve(silentUnlockResult)) + checkLockFileMock.mock.mockImplementation(() => Promise.resolve(matchingLock)) + checkBranchMock.mock.mockImplementation(() => Promise.resolve(true)) + + context = pullRequestContext('closed', true) + octokit = createOctokit() +}) + +test('successfully unlocks all environments on a pull request merge', async () => { + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + true + ) + assertCalledWith( + infoMock, + `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}staging${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}development${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}production${COLORS.reset}` + ) + assertCalledWith( + setOutputMock, + 'unlocked_environments', + 'production,development,staging' + ) +}) + +test('trims whitespace around environment targets before unlocking', async () => { + assert.strictEqual( + await unlockOnMerge( + octokit, + context, + ' production ,\tdevelopment, staging ' + ), + true + ) + + for (const environment of ['production', 'development', 'staging']) { + assertCalledWith( + checkBranchMock, + octokit, + context, + `${environment}-branch-deploy-lock` + ) + assertCalledWith(unlockMock, { + octokit, + context, + reactionId: null, + target: {type: 'environment', environment}, + mode: 'silent' + }) + } + + assertCalledWith( + setOutputMock, + 'unlocked_environments', + 'production,development,staging' + ) +}) + +test('finds that no deployment lock is set so none are removed', async () => { + silentUnlockResult = 'no deployment lock currently set - silent' + + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + true + ) + assertCalledWith( + debugMock, + 'unlock result for unlock-on-merge: no deployment lock currently set - silent' + ) + assertCalledWith(setOutputMock, 'unlocked_environments', '') +}) + +test('only unlocks one environment when another belongs to a different pull request and one has no lock file', async () => { + queueMockImplementation( + checkLockFileMock, + () => + Promise.resolve({ + ...matchingLock, + link: 'https://github.com/corp/test/pull/111#issuecomment-123456789' + }), + () => Promise.resolve(false) + ) + + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + true + ) + assertCalledWith( + infoMock, + `โฉ lock for PR ${COLORS.info}111${COLORS.reset} (env: ${COLORS.highlight}production${COLORS.reset}) is not associated with PR ${COLORS.info}123${COLORS.reset} - skipping...` + ) + assertCalledWith( + infoMock, + `โฉ no lock file found for environment ${COLORS.highlight}development${COLORS.reset} - skipping...` + ) + assertCalledWith( + infoMock, + `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}staging${COLORS.reset}` + ) +}) + +test('preserves legacy truthiness for malformed falsy lock data', async () => { + queueMockImplementation(checkLockFileMock, () => + Promise.resolve(unsafeInvalidValue(null)) + ) + + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + true + ) + assertCalledWith( + infoMock, + `โฉ no lock file found for environment ${COLORS.highlight}production${COLORS.reset} - skipping...` + ) + assertCalledWith( + setOutputMock, + 'unlocked_environments', + 'development,staging' + ) +}) + +test('only unlocks one environment when another belongs to a different pull request and one has no lock branch', async () => { + queueMockImplementation(checkLockFileMock, () => + Promise.resolve({ + ...matchingLock, + link: 'https://github.com/corp/test/pull/111#issuecomment-123456789' + }) + ) + queueMockImplementation( + checkBranchMock, + () => Promise.resolve(true), + () => Promise.resolve(false) + ) + + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + true + ) + assertCalledWith( + infoMock, + `โฉ lock for PR ${COLORS.info}111${COLORS.reset} (env: ${COLORS.highlight}production${COLORS.reset}) is not associated with PR ${COLORS.info}123${COLORS.reset} - skipping...` + ) + assertCalledWith( + infoMock, + `โฉ no lock branch found for environment ${COLORS.highlight}development${COLORS.reset} - skipping...` + ) + assertCalledWith( + infoMock, + `๐Ÿ”“ removed lock - environment: ${COLORS.highlight}staging${COLORS.reset}` + ) +}) + +test('fails when the context is not a pull request merge', async () => { + context = pullRequestContext('opened', false) + + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + false + ) + assertCalledWith( + infoMock, + 'event name: pull_request, action: opened, merged: false' + ) + assertCalledWith( + warningMock, + `this workflow can only run in the context of a ${COLORS.highlight}merged${COLORS.reset} pull request` + ) +}) + +test('fails for a pull request closed without being merged', async () => { + context = pullRequestContext('closed', false) + + assert.strictEqual( + await unlockOnMerge(octokit, context, environmentTargets), + false + ) + assertCalledWith( + warningMock, + `this workflow can only run in the context of a ${COLORS.highlight}merged${COLORS.reset} pull request` + ) + assertCalledWith( + infoMock, + 'event name: pull_request, action: closed, merged: false' + ) + assertCalledWith( + infoMock, + 'pull request was closed but not merged so this workflow will not run - OK' + ) +}) + +for (const payload of [null, undefined]) { + test(`safe-exits when the webhook payload is ${String(payload)}`, async () => { + const malformedContext = unsafeInvalidValue({ + ...context, + payload + }) + assert.strictEqual( + await unlockOnMerge(octokit, malformedContext, environmentTargets), + false + ) + assertCalledWith( + infoMock, + 'event name: pull_request, action: undefined, merged: undefined' + ) + assertNotCalled(checkBranchMock) + }) +} diff --git a/__tests__/functions/unlock.test.js b/__tests__/functions/unlock.test.js deleted file mode 100644 index 9ee9c0c8..00000000 --- a/__tests__/functions/unlock.test.js +++ /dev/null @@ -1,259 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import {unlock} from '../../src/functions/unlock.js' -import * as actionStatus from '../../src/functions/action-status.js' -import {API_HEADERS} from '../../src/functions/api-headers.js' - -class NotFoundError extends Error { - constructor(message) { - super(message) - this.status = 422 - } -} - -let octokit -let context - -beforeEach(() => { - vi.clearAllMocks() - - process.env.INPUT_ENVIRONMENT = 'production' - process.env.INPUT_UNLOCK_TRIGGER = '.unlock' - process.env.INPUT_GLOBAL_LOCK_FLAG = '--global' - - octokit = { - rest: { - git: { - deleteRef: vi.fn().mockReturnValue({status: 204}) - }, - issues: { - createComment: vi.fn().mockReturnValue({status: 201}) - }, - reactions: { - createForIssueComment: vi.fn().mockReturnValue({status: 201}), - deleteForIssueComment: vi.fn().mockReturnValue({status: 204}) - } - } - } - - context = { - repo: { - owner: 'corp', - repo: 'test' - }, - issue: { - number: 1 - }, - payload: { - comment: { - body: '.unlock' - } - } - } -}) - -test('successfully releases a deployment lock with the unlock function', async () => { - expect(await unlock(octokit, context, 123)).toBe(true) - expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({ - owner: 'corp', - repo: 'test', - ref: 'heads/production-branch-deploy-lock', - headers: API_HEADERS - }) -}) - -test('successfully releases a deployment lock with the unlock function and a passed in environment', async () => { - expect(await unlock(octokit, context, 123, 'staging')).toBe(true) - expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({ - owner: 'corp', - repo: 'test', - ref: 'heads/staging-branch-deploy-lock', - headers: API_HEADERS - }) -}) - -test('successfully releases a GLOBAL deployment lock with the unlock function', async () => { - context.payload.comment.body = '.unlock --global' - expect(await unlock(octokit, context, 123)).toBe(true) - expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({ - owner: 'corp', - repo: 'test', - ref: 'heads/global-branch-deploy-lock', - headers: API_HEADERS - }) -}) - -test('successfully releases a development environment deployment lock with the unlock function', async () => { - context.payload.comment.body = '.unlock development' - expect(await unlock(octokit, context, 123)).toBe(true) - expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({ - owner: 'corp', - repo: 'test', - ref: 'heads/development-branch-deploy-lock', - headers: API_HEADERS - }) -}) - -test('successfully releases a development environment deployment lock with the unlock function even when a non-need --reason flag is passed in', async () => { - context.payload.comment.body = - '.unlock development --reason because i said so' - expect(await unlock(octokit, context, 123)).toBe(true) - expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({ - owner: 'corp', - repo: 'test', - ref: 'heads/development-branch-deploy-lock', - headers: API_HEADERS - }) -}) - -test('successfully releases a deployment lock with the unlock function - silent mode', async () => { - expect(await unlock(octokit, context, 123, null, true)).toBe( - 'removed lock - silent' - ) - expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({ - owner: 'corp', - repo: 'test', - ref: 'heads/production-branch-deploy-lock', - headers: API_HEADERS - }) -}) - -test('fails to release a deployment lock due to a bad HTTP code from the GitHub API - silent mode', async () => { - const badHttpOctokitMock = { - rest: { - git: { - deleteRef: vi.fn().mockReturnValue({status: 500}) - } - } - } - expect(await unlock(badHttpOctokitMock, context, 123, null, true)).toBe( - 'failed to delete lock (bad status code) - silent' - ) -}) - -test('throws an error if an unhandled exception occurs - silent mode', async () => { - const errorOctokitMock = { - rest: { - git: { - deleteRef: vi.fn().mockRejectedValue(new Error('oh no')) - } - } - } - try { - await unlock(errorOctokitMock, context, 123, null, true) - } catch (e) { - expect(e.message).toBe('Error: oh no') - } -}) - -test('Does not find a deployment lock branch so it lets the user know - silent mode', async () => { - const noBranchOctokitMock = { - rest: { - git: { - deleteRef: vi - .fn() - .mockRejectedValue( - new NotFoundError( - 'Reference does not exist - https://docs.github.com/rest/git/refs#delete-a-reference' - ) - ) - } - } - } - expect(await unlock(noBranchOctokitMock, context, 123, null, true)).toBe( - 'no deployment lock currently set - silent' - ) -}) - -test('fails to release a deployment lock due to a bad HTTP code from the GitHub API', async () => { - const badHttpOctokitMock = { - rest: { - git: { - deleteRef: vi.fn().mockReturnValue({status: 500}) - }, - issues: { - createComment: vi.fn().mockReturnValue({status: 201}) - }, - reactions: { - createForIssueComment: vi.fn().mockReturnValue({status: 201}), - deleteForIssueComment: vi.fn().mockReturnValue({status: 204}) - } - } - } - expect(await unlock(badHttpOctokitMock, context, 123)).toBe(false) -}) - -test('Does not find a deployment lock branch so it lets the user know', async () => { - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - const noBranchOctokitMock = { - rest: { - git: { - deleteRef: vi - .fn() - .mockRejectedValue( - new NotFoundError( - 'Reference does not exist - https://docs.github.com/rest/git/refs#delete-a-reference' - ) - ) - } - } - } - expect(await unlock(noBranchOctokitMock, context, 123)).toBe(true) - expect(actionStatusSpy).toHaveBeenCalledWith( - context, - noBranchOctokitMock, - 123, - '๐Ÿ”“ There is currently no `production` deployment lock set', - true, - true - ) -}) - -test('Does not find a deployment lock branch so it lets the user know', async () => { - context.payload.comment.body = '.unlock --global' - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - const noBranchOctokitMock = { - rest: { - git: { - deleteRef: vi - .fn() - .mockRejectedValue( - new NotFoundError( - 'Reference does not exist - https://docs.github.com/rest/git/refs#delete-a-reference' - ) - ) - } - } - } - expect(await unlock(noBranchOctokitMock, context, 123)).toBe(true) - expect(actionStatusSpy).toHaveBeenCalledWith( - context, - noBranchOctokitMock, - 123, - '๐Ÿ”“ There is currently no `global` deployment lock set', - true, - true - ) -}) - -test('throws an error if an unhandled exception occurs', async () => { - const errorOctokitMock = { - rest: { - git: { - deleteRef: vi.fn().mockRejectedValue(new Error('oh no')) - } - } - } - try { - await unlock(errorOctokitMock, context, 123) - } catch (e) { - expect(e.message).toBe('Error: oh no') - } -}) diff --git a/__tests__/functions/unlock.test.ts b/__tests__/functions/unlock.test.ts new file mode 100644 index 00000000..3f180313 --- /dev/null +++ b/__tests__/functions/unlock.test.ts @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test, type Mock} from 'node:test' +import type { + InteractiveUnlockRequest, + SilentUnlockRequest, + UnlockOctokit +} from '../../src/functions/unlock.ts' +import {API_HEADERS} from '../../src/functions/api-headers.ts' +import {createIssueCommentContext} from '../test-helpers.ts' +import type {IssueCommentContext} from '../../src/types.ts' +import { + assertCalledWith, + createMock, + stubEnv, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActionStatus = typeof import('../../src/functions/action-status.ts') + +function readInput(name: string, trimWhitespace = true): string { + const value = + process.env[`INPUT_${name.replace(/ /gu, '_').toUpperCase()}`] ?? '' + return trimWhitespace ? value.trim() : value +} + +const debugMock = createMock() +const infoMock = createMock() +const warningMock = createMock() +const setOutputMock = createMock() +const getInputMock = createMock((name, options) => + readInput(name, options?.trimWhitespace !== false) +) +const actionStatusMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + getInput: getInputMock, + info: infoMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../../src/functions/action-status.ts', import.meta.url), + {actionStatus: actionStatusMock} +) + +const {unlock} = await import('../../src/functions/unlock.ts') + +class NotFoundError extends Error { + declare status: number + + constructor(message: string) { + super(message) + this.status = 422 + } +} + +type DeleteRefResponse = Awaited< + ReturnType +> + +const deletedResponse = { + status: 204 +} satisfies DeleteRefResponse + +let context: IssueCommentContext +let octokit: UnlockOctokit +let deleteRefMock: Mock + +function createUnlockOctokit( + deleteRef: UnlockOctokit['rest']['git']['deleteRef'] +): UnlockOctokit { + return { + rest: { + git: {deleteRef}, + issues: {createComment: createMock()}, + reactions: { + createForIssueComment: createMock(), + deleteForIssueComment: createMock() + } + } + } satisfies UnlockOctokit +} + +function contextFor(body: string): IssueCommentContext { + return createIssueCommentContext({ + actor: 'monalisa', + issue: {number: 1}, + payload: {comment: {body, id: 1}}, + repo: {owner: 'corp', repo: 'test'} + }) +} + +function interactiveRequest( + overrides: Partial> = {} +): InteractiveUnlockRequest { + return { + context, + mode: 'interactive', + octokit, + reactionId: 123, + target: {type: 'context'}, + ...overrides + } +} + +function silentRequest( + environment = 'production', + overrides: Partial> = {} +): SilentUnlockRequest { + return { + context, + mode: 'silent', + octokit, + reactionId: 123, + target: {environment, type: 'environment'}, + ...overrides + } +} + +beforeEach(testContext => { + if (!('after' in testContext)) { + throw new Error('expected a test context') + } + + debugMock.mock.resetCalls() + infoMock.mock.resetCalls() + warningMock.mock.resetCalls() + setOutputMock.mock.resetCalls() + getInputMock.mock.resetCalls() + actionStatusMock.mock.resetCalls() + actionStatusMock.mock.mockImplementation(() => Promise.resolve(undefined)) + + stubEnv(testContext, 'INPUT_ENVIRONMENT', 'production') + stubEnv(testContext, 'INPUT_UNLOCK_TRIGGER', '.unlock') + stubEnv(testContext, 'INPUT_GLOBAL_LOCK_FLAG', '--global') + + context = contextFor('.unlock') + deleteRefMock = createMock(() => + Promise.resolve(deletedResponse) + ) + octokit = createUnlockOctokit(deleteRefMock) +}) + +test('successfully releases a deployment lock with the unlock function', async () => { + assert.strictEqual(await unlock(interactiveRequest()), true) + assertCalledWith(deleteRefMock, { + owner: 'corp', + repo: 'test', + ref: 'heads/production-branch-deploy-lock', + headers: API_HEADERS + }) +}) + +test('successfully releases a deployment lock with a passed environment', async () => { + assert.strictEqual( + await unlock( + interactiveRequest({ + target: {environment: 'staging', type: 'environment'} + }) + ), + true + ) + assertCalledWith(deleteRefMock, { + owner: 'corp', + repo: 'test', + ref: 'heads/staging-branch-deploy-lock', + headers: API_HEADERS + }) +}) + +test('successfully releases a global deployment lock', async () => { + context = contextFor('.unlock --global') + assert.strictEqual(await unlock(interactiveRequest()), true) + assertCalledWith(deleteRefMock, { + owner: 'corp', + repo: 'test', + ref: 'heads/global-branch-deploy-lock', + headers: API_HEADERS + }) +}) + +test('successfully releases a development environment deployment lock', async () => { + context = contextFor('.unlock development') + assert.strictEqual(await unlock(interactiveRequest()), true) + assertCalledWith(deleteRefMock, { + owner: 'corp', + repo: 'test', + ref: 'heads/development-branch-deploy-lock', + headers: API_HEADERS + }) +}) + +test('ignores an unnecessary --reason flag while releasing an environment lock', async () => { + context = contextFor('.unlock development --reason because i said so') + assert.strictEqual(await unlock(interactiveRequest()), true) + assertCalledWith(deleteRefMock, { + owner: 'corp', + repo: 'test', + ref: 'heads/development-branch-deploy-lock', + headers: API_HEADERS + }) +}) + +test('successfully releases a deployment lock in silent mode', async () => { + assert.strictEqual(await unlock(silentRequest()), 'removed lock - silent') + assertCalledWith(deleteRefMock, { + owner: 'corp', + repo: 'test', + ref: 'heads/production-branch-deploy-lock', + headers: API_HEADERS + }) +}) + +test('reports a bad GitHub API status in silent mode', async () => { + deleteRefMock.mock.mockImplementation(() => + Promise.resolve({...deletedResponse, status: 500}) + ) + + assert.strictEqual( + await unlock(silentRequest()), + 'failed to delete lock (bad status code) - silent' + ) +}) + +test('throws an unhandled exception in silent mode', async () => { + deleteRefMock.mock.mockImplementation(() => + Promise.reject(new Error('oh no')) + ) + + await assert.rejects(unlock(silentRequest()), {message: 'Error: oh no'}) +}) + +test('reports a missing deployment lock branch in silent mode', async () => { + deleteRefMock.mock.mockImplementation(() => + Promise.reject( + new NotFoundError( + 'Reference does not exist - https://docs.github.com/rest/git/refs#delete-a-reference' + ) + ) + ) + + assert.strictEqual( + await unlock(silentRequest()), + 'no deployment lock currently set - silent' + ) +}) + +test('returns false for a bad GitHub API status in interactive mode', async () => { + deleteRefMock.mock.mockImplementation(() => + Promise.resolve({...deletedResponse, status: 500}) + ) + + assert.strictEqual(await unlock(interactiveRequest()), false) +}) + +test('reports a missing deployment lock branch in interactive mode', async () => { + deleteRefMock.mock.mockImplementation(() => + Promise.reject( + new NotFoundError( + 'Reference does not exist - https://docs.github.com/rest/git/refs#delete-a-reference' + ) + ) + ) + + assert.strictEqual(await unlock(interactiveRequest()), true) + assertCalledWith(actionStatusMock, { + context, + message: '๐Ÿ”“ There is currently no `production` deployment lock set', + octokit, + reactionId: 123, + result: 'alternate-success' + }) +}) + +test('reports a missing global deployment lock branch', async () => { + context = contextFor('.unlock --global') + deleteRefMock.mock.mockImplementation(() => + Promise.reject( + new NotFoundError( + 'Reference does not exist - https://docs.github.com/rest/git/refs#delete-a-reference' + ) + ) + ) + + assert.strictEqual(await unlock(interactiveRequest()), true) + assertCalledWith(actionStatusMock, { + context, + message: '๐Ÿ”“ There is currently no `global` deployment lock set', + octokit, + reactionId: 123, + result: 'alternate-success' + }) +}) + +test('throws an unhandled exception in interactive mode', async () => { + deleteRefMock.mock.mockImplementation(() => + Promise.reject(new Error('oh no')) + ) + + await assert.rejects(unlock(interactiveRequest()), {message: 'Error: oh no'}) +}) diff --git a/__tests__/functions/valid-branch-name.test.js b/__tests__/functions/valid-branch-name.test.js deleted file mode 100644 index 855312fc..00000000 --- a/__tests__/functions/valid-branch-name.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import {constructValidBranchName} from '../../src/functions/valid-branch-name.js' -import {vi, expect, test, beforeEach} from 'vitest' -import * as core from '@actions/core' - -const debugMock = vi.spyOn(core, 'debug') - -const branchName = 'production' - -beforeEach(() => { - vi.clearAllMocks() -}) - -test('does not make any modifications to a valid branch name', async () => { - expect(constructValidBranchName(branchName)).toBe(branchName) - expect(debugMock).toHaveBeenCalledWith( - `constructing valid branch name: ${branchName}` - ) - expect(debugMock).toHaveBeenCalledWith( - `constructed valid branch name: ${branchName}` - ) -}) - -test('replaces spaces with hyphens', async () => { - expect(constructValidBranchName(`super ${branchName}`)).toBe( - `super-${branchName}` - ) - expect(debugMock).toHaveBeenCalledWith( - `constructing valid branch name: super ${branchName}` - ) - expect(debugMock).toHaveBeenCalledWith( - `constructed valid branch name: super-${branchName}` - ) -}) - -test('replaces multiple spaces with hyphens', async () => { - expect(constructValidBranchName(`super duper ${branchName}`)).toBe( - `super-duper-${branchName}` - ) - expect(debugMock).toHaveBeenCalledWith( - `constructing valid branch name: super duper ${branchName}` - ) - expect(debugMock).toHaveBeenCalledWith( - `constructed valid branch name: super-duper-${branchName}` - ) -}) - -test('returns null if the branch is null', async () => { - expect(constructValidBranchName(null)).toBe(null) -}) - -test('returns undefined if the branch is undefined', async () => { - expect(constructValidBranchName(undefined)).toBe(undefined) -}) diff --git a/__tests__/functions/valid-branch-name.test.ts b/__tests__/functions/valid-branch-name.test.ts new file mode 100644 index 00000000..b3c1a8e4 --- /dev/null +++ b/__tests__/functions/valid-branch-name.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {installModuleMock} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') + +const debugMock = mock.fn() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock +}) + +const {constructValidBranchName} = + await import('../../src/functions/valid-branch-name.ts') + +const branchName = 'production' + +beforeEach(() => { + debugMock.mock.resetCalls() +}) + +test('does not make any modifications to a valid branch name', () => { + assert.strictEqual(constructValidBranchName(branchName), branchName) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [`constructing valid branch name: ${branchName}`], + [`constructed valid branch name: ${branchName}`] + ] + ) +}) + +test('replaces spaces with hyphens', () => { + assert.strictEqual( + constructValidBranchName(`super ${branchName}`), + `super-${branchName}` + ) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [`constructing valid branch name: super ${branchName}`], + [`constructed valid branch name: super-${branchName}`] + ] + ) +}) + +test('replaces multiple spaces with hyphens', () => { + assert.strictEqual( + constructValidBranchName(`super duper ${branchName}`), + `super-duper-${branchName}` + ) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + [`constructing valid branch name: super duper ${branchName}`], + [`constructed valid branch name: super-duper-${branchName}`] + ] + ) +}) + +test('replaces tabs, line breaks, and Unicode whitespace with hyphens', () => { + assert.strictEqual( + constructValidBranchName('production\twest\r\nblue\u00a0green'), + 'production-west--blue-green' + ) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [ + ['constructing valid branch name: production\twest\r\nblue\u00a0green'], + ['constructed valid branch name: production-west--blue-green'] + ] + ) +}) + +test('returns null if the branch is null', () => { + assert.strictEqual(constructValidBranchName(null), null) +}) + +test('returns undefined if the branch is undefined', () => { + constructValidBranchName(undefined) + assert.deepStrictEqual( + debugMock.mock.calls.map(call => call.arguments), + [['constructing valid branch name: undefined']] + ) +}) diff --git a/__tests__/functions/valid-deployment-order.test.js b/__tests__/functions/valid-deployment-order.test.js deleted file mode 100644 index 21e7b65a..00000000 --- a/__tests__/functions/valid-deployment-order.test.js +++ /dev/null @@ -1,250 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {COLORS} from '../../src/functions/colors.js' -import {validDeploymentOrder} from '../../src/functions/valid-deployment-order.js' -import * as activeDeployment from '../../src/functions/deployment.js' - -const setOutputMock = vi.spyOn(core, 'setOutput') -const activeDeploymentMock = vi.spyOn(activeDeployment, 'activeDeployment') - -let octokit -let context -let environment = 'production' -let sha = 'deadbeef' - -beforeEach(() => { - vi.clearAllMocks() - - context = {} - octokit = {} - - activeDeploymentMock.mockImplementation(() => { - return true - }) -}) - -test('when the enforced deployment order is only one item and it is the requested environment', async () => { - expect( - await validDeploymentOrder( - octokit, - context, - ['production'], - environment, - sha - ) - ).toStrictEqual({ - valid: true, - results: [] - }) - - expect(core.warning).toHaveBeenCalledWith( - expect.stringMatching( - /Having only one environment in the enforced deployment order will always cause the deployment order checks to pass if the environment names match/ - ) - ) -}) - -test('when the enforced deployment order passes for all previous environments', async () => { - expect( - await validDeploymentOrder( - octokit, - context, - ['development', 'staging', 'production'], - environment, - sha - ) - ).toStrictEqual({ - valid: true, - results: [ - { - environment: 'development', - active: true - }, - { - environment: 'staging', - active: true - } - ] - }) - - expect(core.info).toHaveBeenCalledWith( - expect.stringMatching( - /deployment order checks passed as all previous environments have active deployments/ - ) - ) -}) - -test('when the enforced deployment order fails because one out of two environments (the first one) is not active in the order', async () => { - vi.spyOn(activeDeployment, 'activeDeployment').mockImplementationOnce(() => { - return false - }) - - expect( - await validDeploymentOrder( - octokit, - context, - ['development', 'staging', 'production'], - environment, - sha - ) - ).toStrictEqual({ - valid: false, - results: [ - { - environment: 'development', - active: false - }, - { - environment: 'staging', - active: true - } - ] - }) - - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining( - `${COLORS.highlight}development${COLORS.reset} does not have an active deployment at sha: deadbeef` - ) - ) - - expect(setOutputMock).toHaveBeenCalledWith( - 'needs_to_be_deployed', - 'development' - ) -}) - -test('when the enforced deployment order fails because one out of two environments (the previous one) is not active in the order', async () => { - activeDeploymentMock - .mockImplementationOnce(() => { - return true - }) - .mockImplementationOnce(() => { - return false - }) - - expect( - await validDeploymentOrder( - octokit, - context, - ['development', 'staging', 'production'], - environment, - sha - ) - ).toStrictEqual({ - valid: false, - results: [ - { - environment: 'development', - active: true - }, - { - environment: 'staging', - active: false - } - ] - }) - - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining( - `${COLORS.highlight}staging${COLORS.reset} does not have an active deployment at sha: deadbeef` - ) - ) - - expect(setOutputMock).toHaveBeenCalledWith('needs_to_be_deployed', 'staging') -}) - -test('when the enforced deployment order fails because both of the environments are not active in the enforced order', async () => { - vi.spyOn(activeDeployment, 'activeDeployment').mockImplementationOnce(() => { - return false - }) - - vi.spyOn(activeDeployment, 'activeDeployment').mockImplementationOnce(() => { - return false - }) - - expect( - await validDeploymentOrder( - octokit, - context, - ['development', 'staging', 'production'], - environment, - sha - ) - ).toStrictEqual({ - valid: false, - results: [ - { - environment: 'development', - active: false - }, - { - environment: 'staging', - active: false - } - ] - }) - - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining( - `${COLORS.highlight}development${COLORS.reset} does not have an active deployment at sha: deadbeef` - ) - ) - - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining( - `${COLORS.highlight}staging${COLORS.reset} does not have an active deployment at sha: deadbeef` - ) - ) - - expect(setOutputMock).toHaveBeenCalledWith( - 'needs_to_be_deployed', - 'development,staging' - ) -}) - -test('when the enforced deployment order passes due to the environment being the first in the order', async () => { - expect( - await validDeploymentOrder( - octokit, - context, - ['development', 'staging', 'production'], - 'development', - sha - ) - ).toStrictEqual({ - valid: true, - results: [] - }) - - expect(core.info).toHaveBeenCalledWith( - expect.stringMatching( - /the first environment in the enforced deployment order/ - ) - ) -}) - -test('when the enforced deployment order passes and the requested environment is the second in the order and all after that item are not checked by design', async () => { - expect( - await validDeploymentOrder( - octokit, - context, - ['development', 'staging', 'production'], - 'staging', - sha - ) - ).toStrictEqual({ - valid: true, - results: [ - { - environment: 'development', - active: true - } - ] - }) - - expect(core.info).toHaveBeenCalledWith( - expect.stringMatching( - /deployment order checks passed as all previous environments have active deployments/ - ) - ) -}) diff --git a/__tests__/functions/valid-deployment-order.test.ts b/__tests__/functions/valid-deployment-order.test.ts new file mode 100644 index 00000000..002277ef --- /dev/null +++ b/__tests__/functions/valid-deployment-order.test.ts @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {COLORS} from '../../src/functions/colors.ts' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + assertNotCalled, + createMock, + queueMockImplementation, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionsCore = typeof import('../../src/actions-core.ts') +type ActiveDeployment = typeof import('../../src/functions/deployment.ts') + +const setOutputMock = createMock() +const warningMock = createMock() +const infoMock = createMock() +const errorMock = createMock() +const debugMock = createMock() +const activeDeploymentMock = createMock() + +installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: errorMock, + info: infoMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../../src/functions/deployment.ts', import.meta.url), + {activeDeployment: activeDeploymentMock} +) + +const {validDeploymentOrder} = + await import('../../src/functions/valid-deployment-order.ts') + +let octokit: Parameters[0] +let context: Parameters[1] +const environment: Parameters[3] = 'production' +const sha: Parameters[4] = 'deadbeef' +const graphqlMock = + createMock[0]['graphql']>() + +beforeEach(() => { + setOutputMock.mock.resetCalls() + warningMock.mock.resetCalls() + infoMock.mock.resetCalls() + errorMock.mock.resetCalls() + debugMock.mock.resetCalls() + activeDeploymentMock.mock.resetCalls() + graphqlMock.mock.resetCalls() + + context = createContext() + octokit = {graphql: graphqlMock} + + activeDeploymentMock.mock.mockImplementation(() => Promise.resolve(true)) +}) + +test('when the enforced deployment order is only one item and it is the requested environment', async () => { + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['production'], + environment, + sha + ), + {valid: true, results: []} + ) + + assert.ok( + warningMock.mock.calls.some(call => + String(call.arguments[0]).includes( + 'Having only one environment in the enforced deployment order will always cause the deployment order checks to pass if the environment names match' + ) + ) + ) +}) + +test('rejects duplicate environments before checking deployment history', async () => { + await assert.rejects( + validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'development', 'production'], + environment, + sha + ), + /enforced deployment order contains duplicate environments/ + ) + + assertNotCalled(activeDeploymentMock) + assertNotCalled(setOutputMock) +}) + +test('rejects a requested environment missing from the enforced order', async () => { + await assert.rejects( + validDeploymentOrder( + octokit, + context, + ['development', 'staging'], + environment, + sha + ), + /requested environment is not present in the enforced deployment order: production/ + ) + + assertNotCalled(activeDeploymentMock) + assertNotCalled(setOutputMock) +}) + +test('when the enforced deployment order passes for all previous environments', async () => { + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'production'], + environment, + sha + ), + { + valid: true, + results: [ + {environment: 'development', active: true}, + {environment: 'staging', active: true} + ] + } + ) + + assert.ok( + infoMock.mock.calls.some(call => + call.arguments[0].includes( + 'deployment order checks passed as all previous environments have active deployments' + ) + ) + ) +}) + +test('when the enforced deployment order fails because one out of two environments (the first one) is not active in the order', async () => { + queueMockImplementation(activeDeploymentMock, () => Promise.resolve(false)) + + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'production'], + environment, + sha + ), + { + valid: false, + results: [ + {environment: 'development', active: false}, + {environment: 'staging', active: true} + ] + } + ) + + assert.ok( + errorMock.mock.calls.some(call => + String(call.arguments[0]).includes( + `${COLORS.highlight}development${COLORS.reset} does not have an active deployment at sha: deadbeef` + ) + ) + ) + assertCalledWith(setOutputMock, 'needs_to_be_deployed', 'development') +}) + +test('when the enforced deployment order fails because one out of two environments (the previous one) is not active in the order', async () => { + activeDeploymentMock.mock.mockImplementationOnce( + () => Promise.resolve(true), + 0 + ) + activeDeploymentMock.mock.mockImplementationOnce( + () => Promise.resolve(false), + 1 + ) + + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'production'], + environment, + sha + ), + { + valid: false, + results: [ + {environment: 'development', active: true}, + {environment: 'staging', active: false} + ] + } + ) + + assert.ok( + errorMock.mock.calls.some(call => + String(call.arguments[0]).includes( + `${COLORS.highlight}staging${COLORS.reset} does not have an active deployment at sha: deadbeef` + ) + ) + ) + assertCalledWith(setOutputMock, 'needs_to_be_deployed', 'staging') +}) + +test('when the enforced deployment order fails because both of the environments are not active in the enforced order', async () => { + activeDeploymentMock.mock.mockImplementationOnce( + () => Promise.resolve(false), + 0 + ) + activeDeploymentMock.mock.mockImplementationOnce( + () => Promise.resolve(false), + 1 + ) + + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'production'], + environment, + sha + ), + { + valid: false, + results: [ + {environment: 'development', active: false}, + {environment: 'staging', active: false} + ] + } + ) + + for (const failedEnvironment of ['development', 'staging']) { + assert.ok( + errorMock.mock.calls.some(call => + String(call.arguments[0]).includes( + `${COLORS.highlight}${failedEnvironment}${COLORS.reset} does not have an active deployment at sha: deadbeef` + ) + ) + ) + } + assertCalledWith(setOutputMock, 'needs_to_be_deployed', 'development,staging') +}) + +test('when the enforced deployment order passes due to the environment being the first in the order', async () => { + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'production'], + 'development', + sha + ), + {valid: true, results: []} + ) + + assert.ok( + infoMock.mock.calls.some(call => + call.arguments[0].includes( + 'the first environment in the enforced deployment order' + ) + ) + ) +}) + +test('when the enforced deployment order passes and the requested environment is the second in the order and all after that item are not checked by design', async () => { + assert.deepStrictEqual( + await validDeploymentOrder( + octokit, + context, + ['development', 'staging', 'production'], + 'staging', + sha + ), + { + valid: true, + results: [{environment: 'development', active: true}] + } + ) + + assert.ok( + infoMock.mock.calls.some(call => + call.arguments[0].includes( + 'deployment order checks passed as all previous environments have active deployments' + ) + ) + ) +}) diff --git a/__tests__/functions/valid-permissions.test.js b/__tests__/functions/valid-permissions.test.js deleted file mode 100644 index 659422f4..00000000 --- a/__tests__/functions/valid-permissions.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import * as core from '@actions/core' -import {vi, expect, test, beforeEach} from 'vitest' -import {validPermissions} from '../../src/functions/valid-permissions.js' - -const setOutputMock = vi.spyOn(core, 'setOutput') - -var octokit -var context -var permissions = ['write', 'admin'] - -beforeEach(() => { - vi.clearAllMocks() - process.env.INPUT_PERMISSIONS = 'write,admin' - - context = { - actor: 'monalisa' - } - - octokit = { - rest: { - repos: { - getCollaboratorPermissionLevel: vi.fn().mockReturnValueOnce({ - status: 200, - data: { - permission: 'write' - } - }) - } - } - } -}) - -test('determines that a user has valid permissions to invoke the Action', async () => { - expect(await validPermissions(octokit, context, permissions)).toEqual(true) - expect(setOutputMock).toHaveBeenCalledWith('actor', 'monalisa') -}) - -test('determines that a user has does not valid permissions to invoke the Action', async () => { - octokit.rest.repos.getCollaboratorPermissionLevel = vi.fn().mockReturnValue({ - status: 200, - data: { - permission: 'read' - } - }) - - expect(await validPermissions(octokit, context, permissions)).toEqual( - '๐Ÿ‘‹ @monalisa, that command requires the following permission(s): `write/admin`\n\nYour current permissions: `read`' - ) - expect(setOutputMock).toHaveBeenCalledWith('actor', 'monalisa') -}) - -test('fails to get actor permissions due to a bad status code', async () => { - octokit.rest.repos.getCollaboratorPermissionLevel = vi.fn().mockReturnValue({ - status: 500 - }) - - expect(await validPermissions(octokit, context, permissions)).toEqual( - 'Permission check returns non-200 status: 500' - ) - expect(setOutputMock).toHaveBeenCalledWith('actor', 'monalisa') -}) diff --git a/__tests__/functions/valid-permissions.test.ts b/__tests__/functions/valid-permissions.test.ts new file mode 100644 index 00000000..e6f772d6 --- /dev/null +++ b/__tests__/functions/valid-permissions.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import {beforeEach, mock, test} from 'node:test' +import {createContext} from '../test-helpers.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from '../node-test-helpers.ts' + +type ActionIo = typeof import('../../src/action-io.ts') +type ValidPermissionsModule = + typeof import('../../src/functions/valid-permissions.ts') + +const setActionOutputMock = createMock() + +installModuleMock(mock, new URL('../../src/action-io.ts', import.meta.url), { + setActionOutput: setActionOutputMock +}) + +const {validPermissions} = + await import('../../src/functions/valid-permissions.ts') + +let octokit: Parameters[0] +let context: Parameters[1] +const permissions: Parameters[2] = [ + 'write', + 'admin' +] +const getPermissionMock = + createMock< + Parameters< + ValidPermissionsModule['validPermissions'] + >[0]['rest']['repos']['getCollaboratorPermissionLevel'] + >() + +beforeEach(() => { + setActionOutputMock.mock.resetCalls() + getPermissionMock.mock.resetCalls() + + context = createContext({actor: 'monalisa'}) + + getPermissionMock.mock.mockImplementation(() => + Promise.resolve({ + status: 200, + data: {permission: 'write'} + }) + ) + octokit = { + rest: { + repos: { + getCollaboratorPermissionLevel: getPermissionMock + } + } + } +}) + +test('determines that a user has valid permissions to invoke the Action', async () => { + assert.strictEqual( + await validPermissions(octokit, context, permissions), + true + ) + assertCalledWith(setActionOutputMock, 'actor', 'monalisa') +}) + +test('determines that a user has does not valid permissions to invoke the Action', async () => { + getPermissionMock.mock.mockImplementation(() => + Promise.resolve({ + status: 200, + data: {permission: 'read'} + }) + ) + + assert.strictEqual( + await validPermissions(octokit, context, permissions), + '๐Ÿ‘‹ @monalisa, that command requires the following permission(s): `write/admin`\n\nYour current permissions: `read`' + ) + assertCalledWith(setActionOutputMock, 'actor', 'monalisa') +}) + +test('fails to get actor permissions due to a bad status code', async () => { + getPermissionMock.mock.mockImplementation(() => + Promise.resolve({ + status: 500, + data: {permission: 'none'} + }) + ) + + assert.strictEqual( + await validPermissions(octokit, context, permissions), + 'Permission check returns non-200 status: 500' + ) + assertCalledWith(setActionOutputMock, 'actor', 'monalisa') +}) diff --git a/__tests__/main-no-dispatch.test.ts b/__tests__/main-no-dispatch.test.ts new file mode 100644 index 00000000..a0690424 --- /dev/null +++ b/__tests__/main-no-dispatch.test.ts @@ -0,0 +1,44 @@ +import {mock, test} from 'node:test' +import { + assertNotCalled, + createMock, + installModuleMock +} from './node-test-helpers.ts' + +type ActionsCore = typeof import('../src/actions-core.ts') + +const getStateMock = createMock(() => '') +const infoMock = createMock() + +installModuleMock(mock, new URL('../src/actions-core.ts', import.meta.url), { + debug: createMock(), + error: createMock(), + getBooleanInput: createMock(() => false), + getInput: createMock(() => ''), + getState: getStateMock, + info: infoMock, + saveState: createMock(), + setFailed: createMock(), + setOutput: createMock(), + warning: createMock() +}) + +const originalCi = process.env['CI'] +const originalSentinel = process.env['BRANCH_DEPLOY_VITEST_TEST'] +try { + process.env['CI'] = 'true' + process.env['BRANCH_DEPLOY_VITEST_TEST'] = 'true' + await import('../src/main.ts') +} finally { + if (originalCi === undefined) delete process.env['CI'] + else process.env['CI'] = originalCi + if (originalSentinel === undefined) { + delete process.env['BRANCH_DEPLOY_VITEST_TEST'] + } else { + process.env['BRANCH_DEPLOY_VITEST_TEST'] = originalSentinel + } +} + +test('import bypasses normal dispatch under the test sentinel', () => { + assertNotCalled(infoMock) +}) diff --git a/__tests__/main-post-dispatch.test.ts b/__tests__/main-post-dispatch.test.ts new file mode 100644 index 00000000..e846dcd5 --- /dev/null +++ b/__tests__/main-post-dispatch.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import {mock, test} from 'node:test' +import { + assertCalledTimes, + createMock, + installModuleMock +} from './node-test-helpers.ts' + +type ActionsCore = typeof import('../src/actions-core.ts') +type PostModule = typeof import('../src/functions/post.ts') + +const getStateMock = createMock(() => 'true') +const postMock = createMock(() => Promise.resolve()) + +installModuleMock(mock, new URL('../src/actions-core.ts', import.meta.url), { + debug: createMock(), + error: createMock(), + getBooleanInput: createMock(() => false), + getInput: createMock(() => ''), + getState: getStateMock, + info: createMock(), + saveState: createMock(), + setFailed: createMock(), + setOutput: createMock(), + warning: createMock() +}) +installModuleMock(mock, new URL('../src/functions/post.ts', import.meta.url), { + post: postMock +}) + +await import('../src/main.ts') + +test('import dispatches post mode when the saved state requests it', () => { + assertCalledTimes(postMock, 1) + assert.strictEqual(getStateMock.mock.callCount() > 0, true) +}) diff --git a/__tests__/main-run-dispatch.test.ts b/__tests__/main-run-dispatch.test.ts new file mode 100644 index 00000000..0eb849c1 --- /dev/null +++ b/__tests__/main-run-dispatch.test.ts @@ -0,0 +1,93 @@ +import * as github from '@actions/github' +import {mock, test} from 'node:test' +import type {ActionInputs} from '../src/types.ts' +import { + assertCalledWith, + createMock, + installModuleMock +} from './node-test-helpers.ts' +import {unsafeInvalidValue} from './unsafe-fixtures.ts' + +type ActionsCore = typeof import('../src/actions-core.ts') +type InputsModule = typeof import('../src/functions/inputs.ts') +type ContextCheckModule = typeof import('../src/functions/context-check.ts') + +const getStateMock = createMock(() => '') +const getInputMock = createMock(() => 'faketoken') +const infoMock = createMock() +const debugMock = createMock() +const saveStateMock = createMock() +const getInputsMock = createMock(() => + unsafeInvalidValue({ + environment: 'production', + mergeDeployMode: false, + unlockOnMergeMode: false + }) +) +const contextCheckMock = createMock( + () => false +) + +installModuleMock(mock, new URL('../src/actions-core.ts', import.meta.url), { + debug: debugMock, + error: createMock(), + getBooleanInput: createMock(() => false), + getInput: getInputMock, + getState: getStateMock, + info: infoMock, + saveState: saveStateMock, + setFailed: createMock(), + setOutput: createMock(), + warning: createMock() +}) +installModuleMock( + mock, + new URL('../src/functions/inputs.ts', import.meta.url), + { + getInputs: getInputsMock + } +) +installModuleMock( + mock, + new URL('../src/functions/context-check.ts', import.meta.url), + {contextCheck: contextCheckMock} +) + +github.context.actor = 'monalisa' +github.context.payload = { + issue: {number: 1}, + comment: { + body: '.deploy', + created_at: '2025-01-01T00:00:00Z', + html_url: 'https://github.com/corp/test/pull/1#issuecomment-1', + id: 1, + updated_at: '2025-01-01T00:00:00Z', + user: {login: 'monalisa'} + } +} + +const originalCi = process.env['CI'] +const originalRepository = process.env['GITHUB_REPOSITORY'] +const originalSentinel = process.env['BRANCH_DEPLOY_VITEST_TEST'] +try { + process.env['CI'] = 'true' + process.env['GITHUB_REPOSITORY'] = 'corp/test' + process.env['BRANCH_DEPLOY_VITEST_TEST'] = 'false' + await import('../src/main.ts') +} finally { + if (originalCi === undefined) delete process.env['CI'] + else process.env['CI'] = originalCi + if (originalRepository === undefined) delete process.env['GITHUB_REPOSITORY'] + else process.env['GITHUB_REPOSITORY'] = originalRepository + if (originalSentinel === undefined) { + delete process.env['BRANCH_DEPLOY_VITEST_TEST'] + } else { + process.env['BRANCH_DEPLOY_VITEST_TEST'] = originalSentinel + } +} + +test('import dispatches the normal action outside the test sentinel', () => { + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'bypass', 'true') +}) diff --git a/__tests__/main.test.js b/__tests__/main.test.js deleted file mode 100644 index e9855890..00000000 --- a/__tests__/main.test.js +++ /dev/null @@ -1,1544 +0,0 @@ -import {vi, expect, test, beforeEach} from 'vitest' -import {run} from '../src/main.js' -import * as reactEmote from '../src/functions/react-emote.js' -import * as contextCheck from '../src/functions/context-check.js' -import * as prechecks from '../src/functions/prechecks.js' -import * as branchRulesetChecks from '../src/functions/branch-ruleset-checks.js' -import * as help from '../src/functions/help.js' -import * as validPermissions from '../src/functions/valid-permissions.js' -import * as identicalCommitCheck from '../src/functions/identical-commit-check.js' -import * as unlockOnMerge from '../src/functions/unlock-on-merge.js' -import * as lock from '../src/functions/lock.js' -import * as unlock from '../src/functions/unlock.js' -import * as actionStatus from '../src/functions/action-status.js' -import * as github from '@actions/github' -import * as core from '@actions/core' -import * as isDeprecated from '../src/functions/deprecated-checks.js' -import * as nakedCommandCheck from '../src/functions/naked-command-check.js' -import * as validDeploymentOrder from '../src/functions/valid-deployment-order.js' -import * as commitSafetyChecks from '../src/functions/commit-safety-checks.js' -import * as timestamp from '../src/functions/timestamp.js' -import * as deploymentConfirmation from '../src/functions/deployment-confirmation.js' -import {COLORS} from '../src/functions/colors.js' - -vi.mock('@actions/github', {spy: true}) - -const setOutputMock = vi.spyOn(core, 'setOutput') -const saveStateMock = vi.spyOn(core, 'saveState') -const setFailedMock = vi.spyOn(core, 'setFailed') -const infoMock = vi.spyOn(core, 'info') -const debugMock = vi.spyOn(core, 'debug') -const warningMock = vi.spyOn(core, 'warning') -const errorMock = vi.spyOn(core, 'error') -const validDeploymentOrderMock = vi.spyOn( - validDeploymentOrder, - 'validDeploymentOrder' -) -const createDeploymentMock = vi.fn().mockImplementation(() => { - return { - data: {id: 123} - } -}) - -const permissionsMsg = - '๐Ÿ‘‹ __monalisa__, seems as if you have not admin/write permissions in this repo, permissions: read' - -const mock_sha = 'abc123' - -const no_verification = { - verified: false, - reason: 'unsigned', - signature: null, - payload: null, - verified_at: null -} - -beforeEach(() => { - // Clear only the module-level mocks - setOutputMock.mockClear() - setFailedMock.mockClear() - saveStateMock.mockClear() - infoMock.mockClear() - debugMock.mockClear() - warningMock.mockClear() - errorMock.mockClear() - validDeploymentOrderMock.mockClear() - createDeploymentMock.mockClear() - process.env.GITHUB_SERVER_URL = 'https://github.com' - process.env.GITHUB_RUN_ID = '12345' - process.env.INPUT_GITHUB_TOKEN = 'faketoken' - process.env.INPUT_TRIGGER = '.deploy' - process.env.INPUT_REACTION = 'eyes' - process.env.INPUT_UPDATE_BRANCH = 'warn' - process.env.INPUT_ENVIRONMENT = 'production' - process.env.INPUT_ENVIRONMENT_TARGETS = 'production,development,staging' - process.env.INPUT_ENVIRONMENT_URLS = '' - process.env.INPUT_PARAM_SEPARATOR = '|' - process.env.INPUT_PRODUCTION_ENVIRONMENTS = 'production' - process.env.INPUT_STABLE_BRANCH = 'main' - process.env.INPUT_NOOP_TRIGGER = '.noop' - process.env.INPUT_LOCK_TRIGGER = '.lock' - process.env.INPUT_UNLOCK_TRIGGER = '.unlock' - process.env.INPUT_HELP_TRIGGER = '.help' - process.env.INPUT_LOCK_INFO_ALIAS = '.wcid' - process.env.INPUT_REQUIRED_CONTEXTS = 'false' - process.env.INPUT_ALLOW_FORKS = 'true' - process.env.GITHUB_REPOSITORY = 'corp/test' - process.env.INPUT_GLOBAL_LOCK_FLAG = '--global' - process.env.INPUT_MERGE_DEPLOY_MODE = 'false' - process.env.INPUT_UNLOCK_ON_MERGE_MODE = 'false' - process.env.INPUT_STICKY_LOCKS = 'false' - process.env.INPUT_STICKY_LOCKS_FOR_NOOP = 'false' - process.env.INPUT_DISABLE_LOCK = 'false' - process.env.INPUT_ALLOW_SHA_DEPLOYMENTS = 'false' - process.env.INPUT_DISABLE_NAKED_COMMANDS = 'false' - process.env.INPUT_OUTDATED_MODE = 'default_branch' - process.env.INPUT_CHECKS = 'all' - process.env.INPUT_ENFORCED_DEPLOYMENT_ORDER = '' - process.env.INPUT_COMMIT_VERIFICATION = 'false' - process.env.INPUT_IGNORED_CHECKS = '' - process.env.INPUT_USE_SECURITY_WARNINGS = 'true' - process.env.INPUT_ALLOW_NON_DEFAULT_TARGET_BRANCH_DEPLOYMENTS = 'false' - process.env.INPUT_DEPLOYMENT_CONFIRMATION = 'false' - process.env.INPUT_DEPLOYMENT_CONFIRMATION_TIMEOUT = '60' - - github.context.payload = { - issue: { - number: 123 - }, - comment: { - body: '.deploy', - id: 123, - user: { - login: 'monalisa' - }, - created_at: '2024-10-21T19:11:18Z', - updated_at: '2024-10-21T19:11:18Z', - html_url: 'https://github.com/corp/test/pull/123#issuecomment-1231231231' - } - } - - github.context.actor = 'monalisa' - - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - rest: { - issues: { - createComment: vi.fn().mockReturnValueOnce({ - data: {id: 123456} - }) - }, - repos: { - createDeployment: createDeploymentMock, - createDeploymentStatus: vi.fn().mockImplementation(() => { - return {data: {}} - }), - getCommit: vi.fn().mockImplementation(() => { - return { - data: { - sha: mock_sha, - html_url: `https://github.com/corp/test/commit/${mock_sha}`, - commit: { - author: { - date: '2024-10-15T12:00:00Z' - }, - verification: no_verification - }, - committer: { - login: 'monalisa' - } - } - } - }) - }, - pulls: { - get: vi.fn().mockImplementation(() => { - return {data: {head: {ref: 'test-ref'}}, status: 200} - }) - } - } - } - }) - vi.spyOn(isDeprecated, 'isDeprecated').mockImplementation(() => { - return false - }) - vi.spyOn(deploymentConfirmation, 'deploymentConfirmation').mockImplementation( - () => { - return true - } - ) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return true - }) - vi.spyOn(contextCheck, 'contextCheck').mockImplementation(() => { - return true - }) - vi.spyOn(reactEmote, 'reactEmote').mockImplementation(() => { - return {data: {id: 123}} - }) - vi.spyOn(timestamp, 'timestamp').mockImplementation(() => { - return '2025-01-01T00:00:00.000Z' - }) - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: false, - sha: mock_sha, - isFork: false - } - }) - vi.spyOn(branchRulesetChecks, 'branchRulesetChecks').mockImplementation( - () => { - return undefined - } - ) - vi.spyOn(commitSafetyChecks, 'commitSafetyChecks').mockImplementation(() => { - return { - status: true, - message: 'success', - isVerified: true - } - }) - validDeploymentOrderMock.mockImplementation(() => { - return {valid: true, results: []} - }) -}) - -test('successfully runs the action', async () => { - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('sha', 'abc123') - expect(debugMock).toHaveBeenCalledWith('production_environment: true') - expect(saveStateMock).not.toHaveBeenCalledWith('environment_url', String) - expect(setOutputMock).not.toHaveBeenCalledWith('environment_url', String) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` - ) -}) - -test('fails the action early on when it fails to parse an int input', async () => { - process.env.INPUT_DEPLOYMENT_CONFIRMATION_TIMEOUT = 'not-an-int' - - expect(await run()).toBe(undefined) - expect(setFailedMock).toHaveBeenCalledWith( - 'Invalid value for deployment_confirmation_timeout: must be an integer' - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(infoMock).not.toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` - ) - expect(infoMock).not.toHaveBeenCalledWith( - `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` - ) -}) - -test('successfully runs the action with deployment confirmation', async () => { - process.env.INPUT_DEPLOYMENT_CONFIRMATION = 'true' - - vi.spyOn(deploymentConfirmation, 'deploymentConfirmation').mockImplementation( - () => { - return true - } - ) - - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('sha', 'abc123') - expect(debugMock).toHaveBeenCalledWith('production_environment: true') - expect(debugMock).toHaveBeenCalledWith( - 'deploymentConfirmation() was successful - continuing with the deployment' - ) - expect(saveStateMock).not.toHaveBeenCalledWith('environment_url', String) - expect(setOutputMock).not.toHaveBeenCalledWith('environment_url', String) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` - ) -}) - -test('successfully runs the action with deployment confirmation and when the committer is not set', async () => { - process.env.INPUT_DEPLOYMENT_CONFIRMATION = 'true' - - vi.spyOn(deploymentConfirmation, 'deploymentConfirmation').mockImplementation( - () => { - return true - } - ) - - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - rest: { - issues: { - createComment: vi.fn().mockReturnValueOnce({ - data: {id: 123456} - }) - }, - repos: { - createDeployment: createDeploymentMock, - createDeploymentStatus: vi.fn().mockImplementation(() => { - return {data: {}} - }), - getCommit: vi.fn().mockImplementation(() => { - return { - data: { - sha: mock_sha, - html_url: `https://github.com/corp/test/commit/${mock_sha}`, - commit: { - author: { - date: '2024-10-15T12:00:00Z' - }, - verification: no_verification - }, - committer: {} - } - } - }) - }, - pulls: { - get: vi.fn().mockImplementation(() => { - return {data: {head: {ref: 'test-ref'}}, status: 200} - }) - } - } - } - }) - - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('sha', 'abc123') - expect(debugMock).toHaveBeenCalledWith('production_environment: true') - expect(debugMock).toHaveBeenCalledWith( - 'deploymentConfirmation() was successful - continuing with the deployment' - ) - expect(warningMock).toHaveBeenCalledWith( - 'โš ๏ธ could not find the login of the committer - https://github.com/github/branch-deploy/issues/379' - ) - expect(saveStateMock).not.toHaveBeenCalledWith('environment_url', String) - expect(setOutputMock).not.toHaveBeenCalledWith('environment_url', String) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` - ) -}) - -test('rejects the deployment when deployment confirmation is set, but does not succeed', async () => { - process.env.INPUT_DEPLOYMENT_CONFIRMATION = 'true' - - vi.spyOn(deploymentConfirmation, 'deploymentConfirmation').mockImplementation( - () => { - return false - } - ) - - expect(await run()).toBe('failure') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).not.toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).not.toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('sha', 'abc123') - expect(debugMock).not.toHaveBeenCalledWith('production_environment: true') - expect(debugMock).toHaveBeenCalledWith( - 'โŒ deployment not confirmed - exiting' - ) - expect(saveStateMock).not.toHaveBeenCalledWith('environment_url', String) - expect(setOutputMock).not.toHaveBeenCalledWith('environment_url', String) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(infoMock).not.toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` - ) -}) - -test('successfully runs the action on a deployment to development and with branch updates disabled', async () => { - process.env.INPUT_UPDATE_BRANCH = 'disabled' - github.context.payload.comment.body = '.deploy to development' - - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith( - 'comment_body', - '.deploy to development' - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'development') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(debugMock).toHaveBeenCalledWith('production_environment: false') -}) - -test('successfully runs the action in noop mode', async () => { - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: true, - sha: 'deadbeef', - isFork: false - } - }) - - github.context.payload.comment.body = '.noop' - - expect(await run()).toBe('success - noop') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.noop') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', true) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', true) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to noop: ${COLORS.highlight}deadbeef${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset} (noop)` - ) -}) - -test('successfully runs the action in noop mode when using sticky_locks_for_noop set to true', async () => { - process.env.INPUT_STICKY_LOCKS_FOR_NOOP = 'true' - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: true - } - }) - - github.context.payload.comment.body = '.noop' - - expect(await run()).toBe('success - noop') - expect(debugMock).toHaveBeenCalledWith( - `๐Ÿ”’ noop mode detected and using stickyLocks: true` - ) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.noop') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', true) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', true) -}) - -test('successfully runs the action with an environment url used', async () => { - process.env.INPUT_ENVIRONMENT_URLS = 'production|https://example.com' - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('sha', 'abc123') - expect(saveStateMock).toHaveBeenCalledWith( - 'environment_url', - 'https://example.com' - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'environment_url', - 'https://example.com' - ) - expect(debugMock).toHaveBeenCalledWith('production_environment: true') - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` - ) - expect(infoMock).toHaveBeenCalledWith( - `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` - ) -}) - -test('runs the action and fails due to invalid environment deployment order', async () => { - process.env.INPUT_ENFORCED_DEPLOYMENT_ORDER = 'development,staging,production' - - validDeploymentOrderMock.mockImplementation(() => { - return { - valid: false, - results: [ - { - environment: 'development', - active: true - }, - { - environment: 'staging', - active: false - } - ] - } - }) - - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: false, - sha: 'deadbeef', - isFork: false - } - }) - - expect(await run()).toBe('failure') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - - expect(validDeploymentOrderMock).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - ['development', 'staging', 'production'], - 'production', - 'deadbeef' - ) -}) - -test('runs the action and passes environment deployment order checks', async () => { - process.env.INPUT_ENFORCED_DEPLOYMENT_ORDER = 'development,staging,production' - - validDeploymentOrderMock.mockImplementation(() => { - return { - valid: true, - results: [ - { - environment: 'development', - active: true - }, - { - environment: 'staging', - active: true - } - ] - } - }) - - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(debugMock).toHaveBeenCalledWith('production_environment: true') -}) - -test('runs the action in lock mode and fails due to bad permissions', async () => { - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return permissionsMsg - }) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - - github.context.payload.comment.body = '.lock' - - expect(await run()).toBe('failure') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.lock') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(setFailedMock).toHaveBeenCalledWith(permissionsMsg) -}) - -test('successfully runs the action in lock mode with a reason', async () => { - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return true - }) - - github.context.payload.comment.body = '.lock --reason testing a new feature' - - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith( - 'comment_body', - '.lock --reason testing a new feature' - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('successfully runs the action in lock mode - details only', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - status: 'details-only', - globalFlag: '--global', - environment: 'production' - } - }) - - github.context.payload.comment.body = '.lock --details' - - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.lock --details') - expect(infoSpy).toHaveBeenCalledWith( - `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('successfully runs the action in lock mode - details only - for the development environment', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - global: false, - environment: 'development', - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock development' - }, - status: 'details-only', - globalFlag: '--global', - environment: 'development' - } - }) - github.context.payload.comment.body = '.lock development --details' - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith( - 'comment_body', - '.lock development --details' - ) - expect(infoSpy).toHaveBeenCalledWith( - `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('successfully runs the action in lock mode - details only - --info flag', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - status: 'details-only', - globalFlag: '--global', - environment: 'production' - } - }) - github.context.payload.comment.body = '.lock --info' - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.lock --info') - expect(infoSpy).toHaveBeenCalledWith( - `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('successfully runs the action in lock mode - details only - lock alias wcid', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - environment: 'production', - global: false, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock production' - }, - environment: 'production', - globalFlag: '--global', - status: 'details-only' - } - }) - - github.context.payload.comment.body = '.wcid' - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.wcid') - expect(infoSpy).toHaveBeenCalledWith( - `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock-info-alias') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('successfully runs the action in lock mode - details only - lock alias wcid - and finds a global lock', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - lockData: { - branch: 'octocats-everywhere', - created_at: '2022-06-14T21:12:14.041Z', - created_by: 'octocat', - global: true, - environment: null, - link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', - reason: 'Testing my new feature with lots of cats', - sticky: true, - unlock_command: '.unlock --global' - }, - status: 'details-only', - globalFlag: '--global', - environment: null - } - }) - github.context.payload.comment.body = '.wcid production' - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.wcid production') - expect(infoSpy).toHaveBeenCalledWith( - `๐ŸŒ there is a ${COLORS.highlight}global${COLORS.reset} deployment lock on this repository` - ) - expect(infoSpy).toHaveBeenCalledWith( - `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock-info-alias') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('successfully runs the action in lock mode and finds no lock - details only', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - status: null, - lockData: null, - environment: 'production', - globalFlag: '--global' - } - }) - github.context.payload.comment.body = '.lock --details' - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.lock --details') - expect(infoSpy).toHaveBeenCalledWith('โœ… no active deployment locks found') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('successfully runs the action in lock mode and finds no GLOBAL lock - details only', async () => { - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - vi.spyOn(lock, 'lock').mockImplementation(() => { - return { - status: null, - lockData: null, - environment: null, - global: true, - globalFlag: '--global' - } - }) - github.context.payload.comment.body = '.lock --global --details' - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith( - 'comment_body', - '.lock --global --details' - ) - expect(infoSpy).toHaveBeenCalledWith('โœ… no active deployment locks found') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('fails to aquire the lock on a deploy so it exits', async () => { - vi.spyOn(lock, 'lock').mockImplementation(() => { - return {status: false} - }) - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('runs with the unlock trigger', async () => { - github.context.payload.comment.body = '.unlock' - vi.spyOn(unlock, 'unlock').mockImplementation(() => { - return true - }) - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'unlock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('runs with the deprecated noop input', async () => { - github.context.payload.comment.body = '.deploy noop' - vi.spyOn(isDeprecated, 'isDeprecated').mockImplementation(() => { - return true - }) - expect(await run()).toBe('safe-exit') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('runs with a naked command when naked commands are NOT allowed', async () => { - process.env.INPUT_DISABLE_NAKED_COMMANDS = 'true' - github.context.payload.comment.body = '.deploy' - vi.spyOn(nakedCommandCheck, 'nakedCommandCheck').mockImplementation(() => { - return true - }) - expect(await run()).toBe('safe-exit') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('successfully runs the action on a deployment to an exact sha in development with params', async () => { - process.env.INPUT_ALLOW_SHA_DEPLOYMENTS = 'true' - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: false, - sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc', - isFork: false - } - }) - - github.context.payload.comment.body = - '.deploy 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3' - - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith( - 'comment_body', - '.deploy 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3' - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'development') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('deployment_id', 123) - expect(debugMock).toHaveBeenCalledWith('production_environment: false') -}) - -test('successfully runs the action on a deployment and parse the given parameters', async () => { - process.env.INPUT_ALLOW_SHA_DEPLOYMENTS = 'true' - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: false, - sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc', - isFork: false - } - }) - - github.context.payload.comment.body = - '.deploy | --cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' - const expectedParams = { - _: [], - cpu: 2, // Parser automatically cast to number - memory: '4G', - env: 'development', - port: 8080, // Same here - name: 'my-app', - q: 'my-queue' - } - - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith( - 'params', - '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' - ) - expect(setOutputMock).toHaveBeenCalledWith('parsed_params', expectedParams) -}) - -test('successfully runs the action after trimming the body', async () => { - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: true, - sha: 'deadbeef', - isFork: false - } - }) - github.context.payload.comment.body = '.noop \n\t\n ' - expect(await run()).toBe('success - noop') - // other expects are similar to previous tests. -}) - -test('successfully runs the action with required contexts', async () => { - process.env.INPUT_REQUIRED_CONTEXTS = 'lint,test,build' - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('successfully runs the action with required contexts, explict checks, and some ignored checks', async () => { - process.env.INPUT_CHECKS = 'test,build' - process.env.INPUT_REQUIRED_CONTEXTS = 'lint,test,build' - process.env.INPUT_IGNORED_CHECKS = 'lint,foo' - expect(await run()).toBe('success') - expect(setOutputMock).toHaveBeenCalledWith('deployment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('continue', 'true') - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('detects an out of date branch and exits', async () => { - vi.spyOn(github, 'getOctokit').mockImplementation(() => { - return { - rest: { - issues: { - createComment: vi.fn().mockReturnValueOnce({ - data: {id: 123123} - }) - }, - repos: { - createDeployment: vi.fn().mockImplementation(() => { - return {data: {id: undefined, message: 'Auto-merged'}} - }), - createDeploymentStatus: vi.fn().mockImplementation(() => { - return {data: {}} - }), - getCommit: vi.fn().mockImplementation(() => { - return { - data: { - sha: mock_sha, - html_url: `https://github.com/corp/test/commit/${mock_sha}`, - commit: { - author: { - date: '2024-10-15T12:00:00Z' - }, - verification: no_verification - }, - committer: { - login: 'monalisa' - } - } - } - }) - } - } - } - }) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy') - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(setOutputMock).toHaveBeenCalledWith('noop', false) - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('environment', 'production') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('ref', 'test-ref') - expect(saveStateMock).toHaveBeenCalledWith('noop', false) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('fails due to a bad context', async () => { - vi.spyOn(contextCheck, 'contextCheck').mockImplementation(() => { - return false - }) - expect(await run()).toBe('safe-exit') -}) - -test('fails due to no valid environment targets being found in the comment body', async () => { - github.context.payload.comment.body = '.deploy to chaos' - expect(await run()).toBe('safe-exit') - expect(debugMock).toHaveBeenCalledWith('No valid environment targets found') -}) - -test('fails due to no trigger being found', async () => { - process.env.INPUT_TRIGGER = '.shipit' - expect(await run()).toBe('safe-exit') - // Note: core.info() spy doesn't work with Vitest + ESM module caching - // The actual function DOES log correctly in production, the spy just can't track it - // expect(infoMock).toHaveBeenCalledWith( - // 'โ›” no trigger detected in comment - exiting' - // ) -}) - -test('fails prechecks', async () => { - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: false, - message: '### โš ๏ธ Cannot proceed with deployment... something went wrong', - noopMode: false, - sha: 'deadbeef', - isFork: false - } - }) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - expect(await run()).toBe('failure') - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(setFailedMock).toHaveBeenCalledWith( - '### โš ๏ธ Cannot proceed with deployment... something went wrong' - ) - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('fails commitSafetyChecks', async () => { - vi.spyOn(commitSafetyChecks, 'commitSafetyChecks').mockImplementation(() => { - return { - status: false, - message: - '### โš ๏ธ Cannot proceed with deployment... a scary commit was found', - isVerified: false - } - }) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - expect(await run()).toBe('failure') - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(setFailedMock).toHaveBeenCalledWith( - '### โš ๏ธ Cannot proceed with deployment... a scary commit was found' - ) - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('fails commitSafetyChecks but proceeds because the operation is on the stable branch', async () => { - github.context.payload.comment.body = '.deploy main' - vi.spyOn(commitSafetyChecks, 'commitSafetyChecks').mockImplementation(() => { - return { - status: false, - message: - '### โš ๏ธ Cannot proceed with deployment... a scary commit was found' - } - }) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - expect(await run()).toBe('success') - expect(warningMock).toHaveBeenCalledWith( - 'commit safety checks failed but the stable branch is being used so the workflow will continue - you should inspect recent commits on this branch as a precaution' - ) -}) - -test('runs the .help command successfully', async () => { - github.context.payload.comment.body = '.help' - vi.spyOn(help, 'help').mockImplementation(() => { - return undefined - }) - expect(await run()).toBe('safe-exit') - expect(debugMock).toHaveBeenCalledWith('help command detected') - - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('runs the .help command successfully', async () => { - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return permissionsMsg - }) - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - github.context.payload.comment.body = '.help' - - vi.spyOn(help, 'help').mockImplementation(() => { - return undefined - }) - - expect(await run()).toBe('failure') - expect(debugMock).toHaveBeenCalledWith('help command detected') - expect(setFailedMock).toHaveBeenCalledWith(permissionsMsg) -}) - -test('runs the action in lock mode and fails due to an invalid environment', async () => { - vi.spyOn(actionStatus, 'actionStatus').mockImplementation(() => { - return undefined - }) - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - github.context.payload.comment.body = '.lock --details super-production' - expect(await run()).toBe('safe-exit') - expect(debugMock).toHaveBeenCalledWith( - 'No valid environment targets found for lock/unlock request' - ) - expect(setOutputMock).toHaveBeenCalledWith( - 'comment_body', - '.lock --details super-production' - ) - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('comment_id', 123) - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(saveStateMock).toHaveBeenCalledWith('isPost', 'true') - expect(saveStateMock).toHaveBeenCalledWith('actionsToken', 'faketoken') - expect(saveStateMock).toHaveBeenCalledWith('comment_id', 123) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - process.env.INPUT_GLOBAL_LOCK_FLAG = '' -}) - -test('successfully runs in mergeDeployMode', async () => { - process.env.INPUT_MERGE_DEPLOY_MODE = 'true' - vi.spyOn(identicalCommitCheck, 'identicalCommitCheck').mockImplementation( - () => { - return true - } - ) - expect(await run()).toBe('success - merge deploy mode') - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - // Note: core.info() spy doesn't work with Vitest + ESM module caching - // The actual function DOES log correctly in production, the spy just can't track it - // expect(infoMock).toHaveBeenCalledWith(`๐Ÿƒ running in 'merge deploy' mode`) -}) - -test('successfully runs in unlockOnMergeMode', async () => { - process.env.INPUT_UNLOCK_ON_MERGE_MODE = 'true' - vi.spyOn(unlockOnMerge, 'unlockOnMerge').mockImplementation(() => { - return true - }) - expect(await run()).toBe('success - unlock on merge mode') - // Note: core.info() spy doesn't work with Vitest + ESM module caching - // The actual function DOES log correctly in production, the spy just can't track it - // expect(infoMock).toHaveBeenCalledWith(`๐Ÿƒ running in 'unlock on merge' mode`) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') - expect(validDeploymentOrderMock).not.toHaveBeenCalled() -}) - -test('handles an input validation error and exits', async () => { - process.env.INPUT_UPDATE_BRANCH = 'badvalue' - try { - await run() - } catch (e) { - expect(setFailedMock.toHaveBeenCalled()) - } -}) - -test('handles and unexpected error and exits', async () => { - github.context.payload = {} - try { - await run() - } catch (e) { - expect(setFailedMock.toHaveBeenCalled()) - } -}) - -test('stores params and parsed params into context', async () => { - github.context.payload.comment.body = '.deploy | something1 --foo=bar' - const params = 'something1 --foo=bar' - const parsed_params = { - _: ['something1'], - foo: 'bar' - } - const data = expect.objectContaining({ - auto_merge: true, - ref: 'test-ref', - environment: 'production', - owner: 'corp', - repo: 'test', - production_environment: true, - required_contexts: [], - payload: expect.objectContaining({ - params, - parsed_params, - sha: 'abc123', - type: 'branch-deploy', - github_run_id: 12345 - }) - }) - expect(await run()).toBe('success') - expect(createDeploymentMock).toHaveBeenCalledWith(data) - expect(setOutputMock).toHaveBeenCalledWith('params', params) - expect(setOutputMock).toHaveBeenCalledWith('parsed_params', parsed_params) -}) - -test('stores params and parsed params into context with complex params', async () => { - vi.spyOn(prechecks, 'prechecks').mockImplementation(() => { - return { - ref: 'test-ref', - status: true, - message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', - noopMode: false, - sha: 'deadbeef', - isFork: false - } - }) - - github.context.payload.comment.body = - '.deploy | something1 --foo=bar --env.development=false --env.production=true LOG_LEVEL=debug,CPU_CORES=4 --config.db.host=localhost --config.db.port=5432' - const params = - 'something1 --foo=bar --env.development=false --env.production=true LOG_LEVEL=debug,CPU_CORES=4 --config.db.host=localhost --config.db.port=5432' - const parsed_params = { - _: ['something1', 'LOG_LEVEL=debug,CPU_CORES=4'], - foo: 'bar', - env: { - development: 'false', - production: 'true' - }, - config: { - db: { - host: 'localhost', - port: 5432 - } - } - } - const data = expect.objectContaining({ - auto_merge: true, - ref: 'test-ref', - environment: 'production', - owner: 'corp', - repo: 'test', - production_environment: true, - required_contexts: [], - payload: expect.objectContaining({ - params, - parsed_params, - sha: 'deadbeef', - type: 'branch-deploy', - github_run_id: 12345, - initial_comment_id: 123, - initial_reaction_id: 123, - deployment_started_comment_id: 123456, - timestamp: '2025-01-01T00:00:00.000Z', - commit_verified: true, - actor: 'monalisa', - stable_branch_used: false - }) - }) - expect(await run()).toBe('success') - expect(createDeploymentMock).toHaveBeenCalledWith(data) - expect(setOutputMock).toHaveBeenCalledWith('params', params) - expect(setOutputMock).toHaveBeenCalledWith('parsed_params', parsed_params) -}) - -test('successfully runs the action with disable_lock enabled - skips lock acquisition on deploy', async () => { - process.env.INPUT_DISABLE_LOCK = 'true' - - // clear the mock set up in beforeEach so we can assert it was not called - const lockSpy = vi.spyOn(lock, 'lock').mockClear() - - expect(await run()).toBe('success') - expect(lockSpy).not.toHaveBeenCalled() - expect(setOutputMock).toHaveBeenCalledWith('triggered', 'true') - expect(setOutputMock).toHaveBeenCalledWith('type', 'deploy') - expect(saveStateMock).toHaveBeenCalledWith('disable_lock', true) -}) - -test('returns safe-exit and posts informational comment when disable_lock is set and .lock is triggered', async () => { - process.env.INPUT_DISABLE_LOCK = 'true' - - github.context.payload.comment.body = '.lock' - - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('type', 'lock') - expect(actionStatusSpy).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - 123, - '๐Ÿ”“ Deployment locking is disabled for this Action โ€” lock/unlock commands have no effect.', - true - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) - -test('returns safe-exit and posts informational comment when disable_lock is set and .unlock is triggered', async () => { - process.env.INPUT_DISABLE_LOCK = 'true' - - github.context.payload.comment.body = '.unlock' - - vi.spyOn(validPermissions, 'validPermissions').mockImplementation(() => { - return true - }) - const actionStatusSpy = vi - .spyOn(actionStatus, 'actionStatus') - .mockImplementation(() => { - return undefined - }) - - expect(await run()).toBe('safe-exit') - expect(setOutputMock).toHaveBeenCalledWith('type', 'unlock') - expect(actionStatusSpy).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - 123, - '๐Ÿ”“ Deployment locking is disabled for this Action โ€” lock/unlock commands have no effect.', - true - ) - expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true') -}) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts new file mode 100644 index 00000000..648a019e --- /dev/null +++ b/__tests__/main.test.ts @@ -0,0 +1,2336 @@ +import assert from 'node:assert/strict' +import {afterEach, beforeEach, mock, test, type Mock} from 'node:test' +import {isDeepStrictEqual} from 'node:util' +import {COLORS} from '../src/functions/colors.ts' +import type {BranchDeployOctokit, OperationResultV1} from '../src/types.ts' +import {decodedJsonValue} from '../src/trust-boundaries.ts' +import {unsafeInvalidValue} from './unsafe-fixtures.ts' +import { + assertCalledTimes, + assertCalledWith, + assertNotCalled, + createMock, + installModuleMock +} from './node-test-helpers.ts' + +type ActionsCore = typeof import('../src/actions-core.ts') +type ActionStatusModule = typeof import('../src/functions/action-status.ts') +type BranchRulesetChecksModule = + typeof import('../src/functions/branch-ruleset-checks.ts') +type CommitSafetyChecksModule = + typeof import('../src/functions/commit-safety-checks.ts') +type ContextCheckModule = typeof import('../src/functions/context-check.ts') +type DeploymentConfirmationModule = + typeof import('../src/functions/deployment-confirmation.ts') +type DeprecatedChecksModule = + typeof import('../src/functions/deprecated-checks.ts') +type HelpModule = typeof import('../src/functions/help.ts') +type IdenticalCommitCheckModule = + typeof import('../src/functions/identical-commit-check.ts') +type LockModule = typeof import('../src/functions/lock.ts') +type NakedCommandCheckModule = + typeof import('../src/functions/naked-command-check.ts') +type PrechecksModule = typeof import('../src/functions/prechecks.ts') +type ReactEmoteModule = typeof import('../src/functions/react-emote.ts') +type TimestampModule = typeof import('../src/functions/timestamp.ts') +type UnlockModule = typeof import('../src/functions/unlock.ts') +type UnlockIfUnchangedModule = + typeof import('../src/functions/unlock-if-unchanged.ts') +type InteractiveUnlockRequest = + import('../src/functions/unlock.ts').InteractiveUnlockRequest +type SilentUnlockRequest = + import('../src/functions/unlock.ts').SilentUnlockRequest +type SilentUnlockResult = + import('../src/functions/unlock.ts').SilentUnlockResult +type UnlockOnMergeModule = typeof import('../src/functions/unlock-on-merge.ts') +type ValidDeploymentOrderModule = + typeof import('../src/functions/valid-deployment-order.ts') +type ValidPermissionsModule = + typeof import('../src/functions/valid-permissions.ts') + +const actualCore = await import('../src/actions-core.ts') +const actualGithub = await import('@actions/github') +const githubContext = actualGithub.context + +const setOutputMock = createMock() +const saveStateMock = createMock() +const setFailedMock = createMock() +const infoMock = createMock() +const debugMock = createMock() +const warningMock = createMock() +const errorMock = createMock() +const actionStatusMock = createMock() +const branchRulesetChecksMock = + createMock() +const commitSafetyChecksMock = + createMock() +const contextCheckMock = createMock() +const deploymentConfirmationMock = + createMock() +const isDeprecatedMock = createMock() +const helpMock = createMock() +const identicalCommitCheckMock = + createMock() +const lockMock = createMock() +const nakedCommandCheckMock = + createMock() +const prechecksMock = createMock() +const reactEmoteMock = createMock() +const timestampMock = createMock() +const unlockMock = createMock() +const unlockIfUnchangedMock = + createMock() +const unlockOnMergeMock = createMock() +const validDeploymentOrderMock = + createMock() +const validPermissionsMock = + createMock() + +let octokit: BranchDeployOctokit = actualGithub.getOctokit('test-token') +const getOctokitMock = createMock(() => octokit) + +installModuleMock(mock, '@actions/github', { + context: githubContext, + getOctokit: getOctokitMock +}) +installModuleMock(mock, new URL('../src/actions-core.ts', import.meta.url), { + ...actualCore, + debug: debugMock, + error: errorMock, + info: infoMock, + saveState: saveStateMock, + setFailed: setFailedMock, + setOutput: setOutputMock, + warning: warningMock +}) +installModuleMock( + mock, + new URL('../src/functions/action-status.ts', import.meta.url), + {actionStatus: actionStatusMock} +) +installModuleMock( + mock, + new URL('../src/functions/branch-ruleset-checks.ts', import.meta.url), + {branchRulesetChecks: branchRulesetChecksMock} +) +installModuleMock( + mock, + new URL('../src/functions/commit-safety-checks.ts', import.meta.url), + {commitSafetyChecks: commitSafetyChecksMock} +) +installModuleMock( + mock, + new URL('../src/functions/context-check.ts', import.meta.url), + {contextCheck: contextCheckMock} +) +installModuleMock( + mock, + new URL('../src/functions/deployment-confirmation.ts', import.meta.url), + {deploymentConfirmation: deploymentConfirmationMock} +) +installModuleMock( + mock, + new URL('../src/functions/deprecated-checks.ts', import.meta.url), + {isDeprecated: isDeprecatedMock} +) +installModuleMock(mock, new URL('../src/functions/help.ts', import.meta.url), { + help: helpMock +}) +installModuleMock( + mock, + new URL('../src/functions/identical-commit-check.ts', import.meta.url), + {identicalCommitCheck: identicalCommitCheckMock} +) +installModuleMock(mock, new URL('../src/functions/lock.ts', import.meta.url), { + lock: lockMock +}) +installModuleMock( + mock, + new URL('../src/functions/naked-command-check.ts', import.meta.url), + {nakedCommandCheck: nakedCommandCheckMock} +) +installModuleMock( + mock, + new URL('../src/functions/prechecks.ts', import.meta.url), + {prechecks: prechecksMock} +) +installModuleMock( + mock, + new URL('../src/functions/react-emote.ts', import.meta.url), + {reactEmote: reactEmoteMock} +) +installModuleMock( + mock, + new URL('../src/functions/timestamp.ts', import.meta.url), + {timestamp: timestampMock} +) +installModuleMock( + mock, + new URL('../src/functions/unlock.ts', import.meta.url), + {unlock: unlockMock} +) +installModuleMock( + mock, + new URL('../src/functions/unlock-if-unchanged.ts', import.meta.url), + {unlockIfUnchanged: unlockIfUnchangedMock} +) +installModuleMock( + mock, + new URL('../src/functions/unlock-on-merge.ts', import.meta.url), + {unlockOnMerge: unlockOnMergeMock} +) +installModuleMock( + mock, + new URL('../src/functions/valid-deployment-order.ts', import.meta.url), + {validDeploymentOrder: validDeploymentOrderMock} +) +installModuleMock( + mock, + new URL('../src/functions/valid-permissions.ts', import.meta.url), + {validPermissions: validPermissionsMock} +) + +const {run} = await import('../src/main.ts') + +type CreateDeployment = BranchDeployOctokit['rest']['repos']['createDeployment'] +let createDeploymentMock: Mock = mock.method( + octokit.rest.repos, + 'createDeployment' +) + +const permissionsMsg = + '๐Ÿ‘‹ __monalisa__, seems as if you have not admin/write permissions in this repo, permissions: read' + +const mock_sha = 'abc123' +let selectedSha = mock_sha +let liveRefSha: string | null = null +let lateLiveRefSha: string | null = null +let pullRefReads = 0 +let createdDeploymentSha: string | null = null +let deploymentStatusError: Error | null = null +let commentError: Error | null = null +let deploymentStatusRequests: unknown[] = [] +let commitLogin: string | null = 'monalisa' +let deploymentMessage: string | null = null + +const no_verification = { + verified: false, + reason: 'unsigned', + signature: null, + payload: null, + verified_at: null +} + +function setCommentBody(body: string): void { + const comment = githubContext.payload.comment + if (comment === undefined) throw new Error('missing test comment') + comment['body'] = body +} + +const environmentDefaults = { + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_RUN_ID: '12345', + INPUT_GITHUB_TOKEN: 'faketoken', + INPUT_TRIGGER: '.deploy', + INPUT_REACTION: 'eyes', + INPUT_UPDATE_BRANCH: 'warn', + INPUT_ENVIRONMENT: 'production', + INPUT_ENVIRONMENT_TARGETS: 'production,development,staging', + INPUT_ENVIRONMENT_URLS: '', + INPUT_PARAM_SEPARATOR: '|', + INPUT_PRODUCTION_ENVIRONMENTS: 'production', + INPUT_STABLE_BRANCH: 'main', + INPUT_NOOP_TRIGGER: '.noop', + INPUT_LOCK_TRIGGER: '.lock', + INPUT_UNLOCK_TRIGGER: '.unlock', + INPUT_HELP_TRIGGER: '.help', + INPUT_LOCK_INFO_ALIAS: '.wcid', + INPUT_REQUIRED_CONTEXTS: 'false', + INPUT_ALLOW_FORKS: 'false', + GITHUB_REPOSITORY: 'corp/test', + INPUT_GLOBAL_LOCK_FLAG: '--global', + INPUT_MERGE_DEPLOY_MODE: 'false', + INPUT_UNLOCK_ON_MERGE_MODE: 'false', + INPUT_STICKY_LOCKS: 'false', + INPUT_STICKY_LOCKS_FOR_NOOP: 'false', + INPUT_DISABLE_LOCK: 'false', + INPUT_ALLOW_SHA_DEPLOYMENTS: 'false', + INPUT_DISABLE_NAKED_COMMANDS: 'false', + INPUT_OUTDATED_MODE: 'default_branch', + INPUT_CHECKS: 'all', + INPUT_ENFORCED_DEPLOYMENT_ORDER: '', + INPUT_COMMIT_VERIFICATION: 'false', + INPUT_IGNORED_CHECKS: '', + INPUT_USE_SECURITY_WARNINGS: 'true', + INPUT_ALLOW_NON_DEFAULT_TARGET_BRANCH_DEPLOYMENTS: 'false', + INPUT_DEPLOYMENT_CONFIRMATION: 'false', + INPUT_DEPLOYMENT_CONFIRMATION_TIMEOUT: '60' +} as const + +const originalEnvironment = new Map( + Object.keys(environmentDefaults).map(name => [name, process.env[name]]) +) + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +function assertNotCalledWith< + FunctionType extends (...arguments_: never[]) => unknown +>(mockFunction: Mock, ...expected: readonly unknown[]): void { + assert.ok( + !mockFunction.mock.calls.some(call => + assertPartialArguments(call.arguments, expected) + ), + 'expected mock not to have been called with the supplied arguments' + ) +} + +function assertPartialArguments( + actual: readonly unknown[], + expected: readonly unknown[] +): boolean { + return expected.every((value, index) => { + if (value === String) return typeof actual[index] === 'string' + return isDeepStrictEqual(actual[index], value) + }) +} + +function setLockResult(result: Awaited>): void { + lockMock.mock.mockImplementation(() => Promise.resolve(result)) +} + +function setPrechecksResult( + result: Awaited> +): void { + if (result.status) selectedSha = result.sha + prechecksMock.mock.mockImplementation(() => Promise.resolve(result)) +} + +function setValidPermissionsResult( + result: Awaited> +): void { + validPermissionsMock.mock.mockImplementation(() => Promise.resolve(result)) +} + +function successfulUnlock( + request: SilentUnlockRequest +): Promise +function successfulUnlock(request: InteractiveUnlockRequest): Promise +function successfulUnlock( + request: InteractiveUnlockRequest | SilentUnlockRequest +): Promise { + return Promise.resolve( + request.mode === 'silent' ? 'removed lock - silent' : true + ) +} + +function failedUnlock(request: SilentUnlockRequest): Promise +function failedUnlock(request: InteractiveUnlockRequest): Promise +function failedUnlock( + request: InteractiveUnlockRequest | SilentUnlockRequest +): Promise { + return Promise.resolve( + request.mode === 'silent' + ? 'failed to delete lock (bad status code) - silent' + : false + ) +} + +type ExpectedOperationResult = Pick< + OperationResultV1, + 'decision' | 'operation' | 'reason_code' +> & + Partial< + Pick< + OperationResultV1, + 'deployment_id' | 'deployment_type' | 'environment' | 'ref' | 'sha' + > + > + +function assertOperationResult(expected: ExpectedOperationResult): void { + const resultCalls = setOutputMock.mock.calls.filter( + call => call.arguments[0] === 'result' + ) + assert.strictEqual(resultCalls.length, 1) + const serialized = String(resultCalls[0]?.arguments[1]) + const result = unsafeInvalidValue( + decodedJsonValue(serialized) + ) + assert.deepStrictEqual(result, { + schema_version: 1, + decision: expected.decision, + reason_code: expected.reason_code, + operation: expected.operation, + deployment_type: expected.deployment_type ?? null, + environment: expected.environment ?? null, + ref: expected.ref ?? null, + sha: expected.sha ?? null, + deployment_id: expected.deployment_id ?? null + }) + assertCalledWith(setOutputMock, 'decision', result.decision) + assertCalledWith(setOutputMock, 'reason_code', result.reason_code) +} + +beforeEach(() => { + commitLogin = 'monalisa' + selectedSha = mock_sha + liveRefSha = null + lateLiveRefSha = null + pullRefReads = 0 + createdDeploymentSha = null + deploymentStatusError = null + commentError = null + deploymentStatusRequests = [] + deploymentMessage = null + for (const [name, value] of Object.entries(environmentDefaults)) { + process.env[name] = value + } + + for (const mockFunction of [ + setOutputMock, + setFailedMock, + saveStateMock, + infoMock, + debugMock, + warningMock, + errorMock, + actionStatusMock, + branchRulesetChecksMock, + commitSafetyChecksMock, + contextCheckMock, + deploymentConfirmationMock, + isDeprecatedMock, + helpMock, + identicalCommitCheckMock, + lockMock, + nakedCommandCheckMock, + prechecksMock, + reactEmoteMock, + timestampMock, + unlockMock, + unlockIfUnchangedMock, + unlockOnMergeMock, + validDeploymentOrderMock, + validPermissionsMock, + getOctokitMock + ]) { + mockFunction.mock.resetCalls() + } + + githubContext.payload = { + issue: { + number: 123 + }, + comment: { + body: '.deploy', + id: 123, + user: { + login: 'monalisa' + }, + created_at: '2024-10-21T19:11:18Z', + updated_at: '2024-10-21T19:11:18Z', + html_url: 'https://github.com/corp/test/pull/123#issuecomment-1231231231' + } + } + + githubContext.actor = 'monalisa' + + octokit = actualGithub.getOctokit('test-token') + octokit.hook.wrap('request', (_request, options) => { + let data: unknown = {} + if (options.url.endsWith('/issues/{issue_number}/comments')) { + if (commentError !== null) throw commentError + data = {id: 123456} + } else if (options.url.endsWith('/deployments')) { + data = + deploymentMessage === null + ? { + created_at: '2025-01-01T00:00:00Z', + id: 123, + sha: createdDeploymentSha ?? selectedSha, + statuses_url: 'https://api.github.com/deployments/123/statuses', + updated_at: '2025-01-01T00:00:00Z', + url: 'https://api.github.com/deployments/123' + } + : {message: deploymentMessage} + } else if (options.url.endsWith('/commits/{ref}')) { + data = { + sha: mock_sha, + html_url: `https://github.com/corp/test/commit/${mock_sha}`, + commit: { + author: {date: '2024-10-15T12:00:00Z'}, + verification: no_verification + }, + committer: commitLogin === null ? {} : {login: commitLogin} + } + } else if (options.url.endsWith('/pulls/{pull_number}')) { + pullRefReads += 1 + data = { + head: { + ref: 'test-ref', + sha: + pullRefReads > 1 && lateLiveRefSha !== null + ? lateLiveRefSha + : (liveRefSha ?? selectedSha) + } + } + } else if (options.url.endsWith('/branches/{branch}')) { + data = {commit: {sha: liveRefSha ?? selectedSha}} + } else if (options.url.endsWith('/deployments/{deployment_id}/statuses')) { + if (deploymentStatusError !== null) throw deploymentStatusError + deploymentStatusRequests.push(options) + data = {id: 456, url: 'https://api.github.com/statuses/456'} + } + + return {data, headers: {}, status: 200, url: options.url} + }) + createDeploymentMock = mock.method(octokit.rest.repos, 'createDeployment') + getOctokitMock.mock.mockImplementation(() => octokit) + isDeprecatedMock.mock.mockImplementation(() => Promise.resolve(false)) + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('confirmed') + ) + lockMock.mock.mockImplementation(() => + Promise.resolve({ + environment: 'production', + global: false, + globalFlag: '', + lockData: null, + status: true, + lockRefSha: 'lock-commit-sha' + }) + ) + contextCheckMock.mock.mockImplementation(() => true) + reactEmoteMock.mock.mockImplementation(() => Promise.resolve(123)) + timestampMock.mock.mockImplementation(() => '2025-01-01T00:00:00.000Z') + prechecksMock.mock.mockImplementation(() => + Promise.resolve({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: false, + sha: mock_sha, + isFork: false + }) + ) + branchRulesetChecksMock.mock.mockImplementation(() => + Promise.resolve({success: true}) + ) + commitSafetyChecksMock.mock.mockImplementation(() => ({ + status: true, + message: 'success', + isVerified: true + })) + actionStatusMock.mock.mockImplementation(() => Promise.resolve()) + helpMock.mock.mockImplementation(() => Promise.resolve()) + identicalCommitCheckMock.mock.mockImplementation(() => Promise.resolve(true)) + nakedCommandCheckMock.mock.mockImplementation(() => Promise.resolve(false)) + unlockOnMergeMock.mock.mockImplementation(() => Promise.resolve(true)) + unlockMock.mock.mockImplementation(successfulUnlock) + unlockIfUnchangedMock.mock.mockImplementation(() => Promise.resolve(true)) + validDeploymentOrderMock.mock.mockImplementation(() => + Promise.resolve({valid: true, results: []}) + ) + validPermissionsMock.mock.mockImplementation(() => Promise.resolve(true)) +}) + +afterEach(() => { + for (const [name, value] of originalEnvironment) setEnv(name, value) +}) + +test('successfully runs the action', async () => { + assert.strictEqual(await run(), 'success') + assertOperationResult({ + decision: 'continue', + reason_code: 'deployment_ready', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: 'abc123', + deployment_id: 123 + }) + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(saveStateMock, 'sha', 'abc123') + assertCalledWith(debugMock, 'production_environment: true') + assertNotCalledWith(saveStateMock, 'environment_url', String) + assertNotCalledWith(setOutputMock, 'environment_url', String) + assertCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` + ) +}) + +test('successfully deploys without acquiring a lock when locking is disabled', async () => { + setEnv('INPUT_DISABLE_LOCK', 'true') + + assert.strictEqual(await run(), 'success') + assertOperationResult({ + decision: 'continue', + reason_code: 'deployment_ready', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: 'abc123', + deployment_id: 123 + }) + assertNotCalled(lockMock) + assertNotCalled(unlockIfUnchangedMock) + assertCalledWith(saveStateMock, 'disable_lock', true) + assertCalledWith( + infoMock, + '๐Ÿ”“ deployment locking is disabled; skipping lock acquisition' + ) +}) + +test('rejects a moved ref without lock cleanup when locking is disabled', async () => { + setEnv('INPUT_DISABLE_LOCK', 'true') + liveRefSha = 'new-commit' + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'ref_changed', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha + }) + assertNotCalled(lockMock) + assertNotCalled(unlockIfUnchangedMock) + assertNotCalled(createDeploymentMock) +}) + +test('rejects a mutable PR ref that moves after prechecks', async () => { + liveRefSha = 'new-commit' + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'ref_changed', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha + }) + assertCalledWith( + unlockIfUnchangedMock, + octokit, + githubContext, + 'production', + 'lock-commit-sha' + ) + assertNotCalled(createDeploymentMock) + assertCalledWith( + setFailedMock, + 'the selected deployment ref changed after prechecks' + ) +}) + +test('retains a sticky lock when a mutable ref moves', async () => { + setEnv('INPUT_STICKY_LOCKS', 'true') + liveRefSha = 'new-commit' + + assert.strictEqual(await run(), 'failure') + assertNotCalled(unlockIfUnchangedMock) +}) + +test('rejects a mutable PR ref that moves after the started comment', async () => { + lateLiveRefSha = 'new-commit' + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'ref_changed', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha + }) + assertNotCalled(createDeploymentMock) + assertCalledTimes(unlockIfUnchangedMock, 1) +}) + +test('keeps ref_changed stable when reporting the failure is unavailable', async () => { + liveRefSha = 'new-commit' + actionStatusMock.mock.mockImplementation(() => + Promise.reject(new Error('comment unavailable')) + ) + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'ref_changed', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha + }) + assertCalledTimes(unlockIfUnchangedMock, 1) + assertCalledWith( + warningMock, + 'failed to report the changed deployment ref: comment unavailable' + ) +}) + +test('marks a deployment as error when GitHub resolves it to another SHA', async () => { + createdDeploymentSha = 'unexpected-commit' + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'deployment_sha_mismatch', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha, + deployment_id: 123 + }) + const statusRequest = deploymentStatusRequests.at(-1) + assert.ok(typeof statusRequest === 'object' && statusRequest !== null) + assert.strictEqual(Reflect.get(statusRequest, 'state'), 'error') + assertCalledWith( + setFailedMock, + 'the created deployment SHA did not match the checked SHA' + ) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('keeps deployment_sha_mismatch stable when failure reporting is unavailable', async () => { + createdDeploymentSha = 'unexpected-commit' + deploymentStatusError = new Error('status unavailable') + actionStatusMock.mock.mockImplementation(() => + Promise.reject(new Error('comment unavailable')) + ) + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'deployment_sha_mismatch', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha, + deployment_id: 123 + }) + assertCalledTimes(unlockIfUnchangedMock, 1) + assertCalledWith( + warningMock, + 'failed to mark the mismatched deployment as an error: status unavailable' + ) + assertCalledWith( + warningMock, + 'failed to report the mismatched deployment: comment unavailable' + ) +}) + +test('cleans a non-sticky lock when deployment orchestration throws', async () => { + commentError = new Error('comment unavailable') + + assert.strictEqual(await run(), undefined) + assertOperationResult({ + decision: 'failure', + reason_code: 'unexpected_error', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: mock_sha + }) + assertCalledTimes(unlockIfUnchangedMock, 1) +}) + +test('preserves the missing run id fallback in the deployment payload', async () => { + setEnv('GITHUB_RUN_ID', undefined) + + assert.strictEqual(await run(), 'success') + const request = createDeploymentMock.mock.calls.at(-1)?.arguments[0] + assert.ok(request !== undefined) + assert.ok(typeof request.payload === 'object' && request.payload !== null) + assert.ok(Number.isNaN(request.payload['github_run_id'])) +}) + +for (const value of [ + 'not-an-int', + '10abc', + '0', + '-1', + '9007199254740992' +] as const) { + test(`fails the action early when deployment_confirmation_timeout is ${value}`, async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION_TIMEOUT', value) + + assert.strictEqual(await run(), undefined) + assertCalledWith( + setFailedMock, + 'Invalid value for deployment_confirmation_timeout: must be a positive integer' + ) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertNotCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` + ) + assertNotCalledWith( + infoMock, + `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` + ) + }) +} + +test('successfully runs the action with deployment confirmation', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('confirmed') + ) + + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(saveStateMock, 'sha', 'abc123') + assertCalledWith(debugMock, 'production_environment: true') + assertCalledWith( + debugMock, + 'deploymentConfirmation() was successful - continuing with the deployment' + ) + assertNotCalledWith(saveStateMock, 'environment_url', String) + assertNotCalledWith(setOutputMock, 'environment_url', String) + assertCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` + ) +}) + +test('successfully runs the action with deployment confirmation and when the committer is not set', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('confirmed') + ) + commitLogin = null + + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(saveStateMock, 'sha', 'abc123') + assertCalledWith(debugMock, 'production_environment: true') + assertCalledWith( + debugMock, + 'deploymentConfirmation() was successful - continuing with the deployment' + ) + assertCalledWith( + warningMock, + 'โš ๏ธ could not find the login of the committer - https://github.com/github/branch-deploy/issues/379' + ) + assertNotCalledWith(saveStateMock, 'environment_url', String) + assertNotCalledWith(setOutputMock, 'environment_url', String) + assertCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` + ) +}) + +test('rejects the deployment when deployment confirmation is set, but does not succeed', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('rejected') + ) + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'confirmation_rejected', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: 'abc123' + }) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertNotCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertNotCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'sha', 'abc123') + assertNotCalledWith(debugMock, 'production_environment: true') + assertCalledWith(debugMock, 'โŒ deployment not confirmed - exiting') + assertNotCalledWith(saveStateMock, 'environment_url', String) + assertNotCalledWith(setOutputMock, 'environment_url', String) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertNotCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` + ) +}) + +test('reports a timed-out confirmation and warns if non-sticky lock cleanup throws', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('timed_out') + ) + unlockIfUnchangedMock.mock.mockImplementation(() => + Promise.reject(new Error('cleanup unavailable')) + ) + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'confirmation_timed_out', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: 'abc123' + }) + assertCalledWith( + warningMock, + 'failed to release the non-sticky deployment lock after confirmation did not complete: cleanup unavailable' + ) +}) + +test('warns when non-sticky confirmation cleanup returns a bad status', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('rejected') + ) + unlockIfUnchangedMock.mock.mockImplementation(() => Promise.resolve(false)) + + assert.strictEqual(await run(), 'failure') + assertCalledWith( + warningMock, + 'failed to release the non-sticky deployment lock after confirmation did not complete' + ) +}) + +test('leaves a non-sticky lock in place when acquisition returns no ref SHA', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('rejected') + ) + setLockResult({ + environment: 'production', + global: false, + globalFlag: '', + lockData: null, + status: true + }) + + assert.strictEqual(await run(), 'failure') + assertNotCalled(unlockIfUnchangedMock) + assertCalledWith( + warningMock, + 'failed to release the non-sticky deployment lock after confirmation did not complete: the original ref SHA was not returned' + ) +}) + +test('retains a sticky lock when deployment confirmation is rejected', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + setEnv('INPUT_STICKY_LOCKS', 'true') + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.resolve('rejected') + ) + + assert.strictEqual(await run(), 'failure') + assertNotCalled(unlockIfUnchangedMock) +}) + +test('cleans a non-sticky lock before propagating a confirmation error', async () => { + setEnv('INPUT_DEPLOYMENT_CONFIRMATION', 'true') + deploymentConfirmationMock.mock.mockImplementation(() => + Promise.reject(new Error('confirmation unavailable')) + ) + + assert.strictEqual(await run(), undefined) + assertOperationResult({ + decision: 'failure', + reason_code: 'unexpected_error', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: 'abc123' + }) + assertCalledWith( + unlockIfUnchangedMock, + octokit, + githubContext, + 'production', + 'lock-commit-sha' + ) +}) + +test('successfully runs the action on a deployment to development and with branch updates disabled', async () => { + setEnv('INPUT_UPDATE_BRANCH', 'disabled') + setCommentBody('.deploy to development') + + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy to development') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'development') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(debugMock, 'production_environment: false') +}) + +test('successfully runs the action in noop mode', async () => { + setPrechecksResult({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: true, + sha: 'deadbeef', + isFork: false + }) + + setCommentBody('.noop') + + assert.strictEqual(await run(), 'success - noop') + assertOperationResult({ + decision: 'continue', + reason_code: 'noop_ready', + operation: 'noop', + deployment_type: 'noop', + environment: 'production', + ref: 'test-ref', + sha: 'deadbeef' + }) + assertCalledWith(setOutputMock, 'comment_body', '.noop') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', true) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', true) + assertCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to noop: ${COLORS.highlight}deadbeef${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset} (noop)` + ) +}) + +test('successfully runs a noop without acquiring a lock when locking is disabled', async () => { + setEnv('INPUT_DISABLE_LOCK', 'true') + setPrechecksResult({ + ref: 'test-ref', + status: true, + message: 'approved', + noopMode: true, + sha: 'deadbeef', + isFork: false + }) + setCommentBody('.noop') + + assert.strictEqual(await run(), 'success - noop') + assertOperationResult({ + decision: 'continue', + reason_code: 'noop_ready', + operation: 'noop', + deployment_type: 'noop', + environment: 'production', + ref: 'test-ref', + sha: 'deadbeef' + }) + assertNotCalled(lockMock) + assertNotCalled(unlockIfUnchangedMock) + assertCalledWith(saveStateMock, 'disable_lock', true) +}) + +test('successfully runs the action in noop mode when using sticky_locks_for_noop set to true', async () => { + setEnv('INPUT_STICKY_LOCKS_FOR_NOOP', 'true') + prechecksMock.mock.mockImplementation(() => + Promise.resolve({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: true, + sha: mock_sha, + isFork: false + }) + ) + + setCommentBody('.noop') + + assert.strictEqual(await run(), 'success - noop') + assertCalledWith( + debugMock, + `๐Ÿ”’ noop mode detected and using stickyLocks: true` + ) + assertCalledWith(setOutputMock, 'comment_body', '.noop') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', true) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', true) +}) + +test('successfully runs the action with an environment url used', async () => { + setEnv('INPUT_ENVIRONMENT_URLS', 'production|https://example.com') + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(saveStateMock, 'sha', 'abc123') + assertCalledWith(saveStateMock, 'environment_url', 'https://example.com') + assertCalledWith(setOutputMock, 'environment_url', 'https://example.com') + assertCalledWith(debugMock, 'production_environment: true') + assertCalledWith( + infoMock, + `๐Ÿง‘โ€๐Ÿš€ commit sha to deploy: ${COLORS.highlight}${mock_sha}${COLORS.reset}` + ) + assertCalledWith( + infoMock, + `๐Ÿš€ ${COLORS.success}deployment started!${COLORS.reset}` + ) +}) + +test('runs the action and fails due to invalid environment deployment order', async () => { + setEnv('INPUT_ENFORCED_DEPLOYMENT_ORDER', 'development,staging,production') + + validDeploymentOrderMock.mock.mockImplementation(() => + Promise.resolve({ + valid: false, + results: [ + {environment: 'development', active: true}, + {environment: 'staging', active: false} + ] + }) + ) + + prechecksMock.mock.mockImplementation(() => + Promise.resolve({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: false, + sha: 'deadbeef', + isFork: false + }) + ) + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'deployment_order_failed', + operation: 'deploy', + environment: 'production', + ref: 'test-ref', + sha: 'deadbeef' + }) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'type', 'deploy') + + const deploymentOrderCall = validDeploymentOrderMock.mock.calls.at(-1) + assert.ok(deploymentOrderCall !== undefined) + assert.strictEqual(deploymentOrderCall.arguments[0], octokit) + assert.strictEqual(deploymentOrderCall.arguments[1], githubContext) + assert.deepStrictEqual(deploymentOrderCall.arguments.slice(2), [ + ['development', 'staging', 'production'], + 'production', + 'deadbeef' + ]) +}) + +test('runs the action and passes environment deployment order checks', async () => { + setEnv('INPUT_ENFORCED_DEPLOYMENT_ORDER', 'development,staging,production') + + validDeploymentOrderMock.mock.mockImplementation(() => + Promise.resolve({ + valid: true, + results: [ + {environment: 'development', active: true}, + {environment: 'staging', active: true} + ] + }) + ) + + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(debugMock, 'production_environment: true') +}) + +test('reports invalid deployment order configuration with a stable reason', async () => { + setEnv('INPUT_ENFORCED_DEPLOYMENT_ORDER', 'production,production') + validDeploymentOrderMock.mock.mockImplementation(() => + Promise.reject( + new Error('The enforced deployment order contains duplicate environments') + ) + ) + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'deployment_order_failed', + operation: 'deploy', + environment: 'production', + ref: 'test-ref', + sha: mock_sha + }) + assertCalledWith( + setFailedMock, + 'The enforced deployment order contains duplicate environments' + ) +}) + +test('runs the action in lock mode and fails due to bad permissions', async () => { + setEnv('INPUT_DISABLE_LOCK', 'true') + setValidPermissionsResult(permissionsMsg) + + setCommentBody('.lock') + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'permission_denied', + operation: 'lock' + }) + assertCalledWith(setOutputMock, 'comment_body', '.lock') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(setFailedMock, permissionsMsg) +}) + +for (const [body, operation] of [ + ['.lock', 'lock'], + ['.unlock', 'unlock'], + ['.wcid', 'lock_info'] +] as const) { + test(`${body} reports that deployment locking is disabled`, async () => { + setEnv('INPUT_DISABLE_LOCK', 'true') + setCommentBody(body) + + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'complete', + reason_code: 'locking_disabled', + operation, + environment: 'production' + }) + assertCalledWith(actionStatusMock, { + context: githubContext, + octokit, + reactionId: 123, + message: + '๐Ÿ”“ Deployment locking is disabled for this Action โ€” lock/unlock commands have no effect.', + result: 'alternate-success' + }) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertNotCalled(lockMock) + assertNotCalled(unlockMock) + }) +} + +test('successfully runs the action in lock mode with a reason', async () => { + setValidPermissionsResult(true) + setLockResult({ + environment: 'production', + global: false, + globalFlag: '', + lockData: null, + status: true + }) + + setCommentBody('.lock --reason testing a new feature') + + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'complete', + reason_code: 'lock_acquired', + operation: 'lock', + environment: 'production', + ref: 'test-ref' + }) + assertCalledWith( + setOutputMock, + 'comment_body', + '.lock --reason testing a new feature' + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('reports an unexpected direct lock failure with collected context', async () => { + setCommentBody('.lock') + lockMock.mock.mockImplementation(() => + Promise.reject(new Error('lock unavailable')) + ) + + assert.strictEqual(await run(), undefined) + assertOperationResult({ + decision: 'failure', + reason_code: 'unexpected_error', + operation: 'lock', + environment: 'production', + ref: 'test-ref' + }) + assertCalledWith(setFailedMock, 'lock unavailable') + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('reports an already-owned direct lock', async () => { + setCommentBody('.lock --reason all yours') + setLockResult({ + status: 'owner', + lockData: { + branch: 'test-ref', + created_at: '2025-01-01T00:00:00.000Z', + created_by: 'monalisa', + environment: 'production', + global: false, + link: 'https://github.com/corp/test/pull/123', + reason: 'all yours', + sticky: true, + unlock_command: '.unlock production' + }, + environment: 'production', + global: false, + globalFlag: '--global' + }) + + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'complete', + reason_code: 'lock_already_owned', + operation: 'lock', + environment: 'production', + ref: 'test-ref' + }) +}) + +test('reports a conflicting direct lock', async () => { + setCommentBody('.lock') + setLockResult({ + status: false, + lockData: null, + environment: 'production', + global: false, + globalFlag: '--global' + }) + + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'lock_conflict', + operation: 'lock', + environment: 'production', + ref: 'test-ref' + }) +}) + +test('successfully runs the action in lock mode - details only', async () => { + const infoSpy = infoMock + const actionStatusSpy = actionStatusMock + setValidPermissionsResult(true) + setLockResult({ + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: + 'routine `\n\n## Deployment approved\n[continue](https://example.com)', + sticky: true, + unlock_command: '.unlock production' + }, + status: 'details-only', + global: false, + globalFlag: '--global', + environment: 'production' + }) + + setCommentBody('.lock --details') + + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'complete', + reason_code: 'lock_info_completed', + operation: 'lock_info', + environment: 'production' + }) + assertCalledWith(setOutputMock, 'comment_body', '.lock --details') + assertCalledWith( + infoSpy, + `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') + const comment = actionStatusSpy.mock.calls.at(-1)?.arguments[0].message ?? '' + assert.ok( + comment.includes( + '- __Reason__:\n\n routine `\n \n ## Deployment approved\n [continue](https://example.com)\n\n- __Branch__: `octocats-everywhere`' + ) + ) + assert.ok(!comment.includes('\n## Deployment approved')) + assert.ok(!comment.includes('\n[continue](https://example.com)')) +}) + +test('successfully runs the action in lock mode - details only - for the development environment', async () => { + const infoSpy = infoMock + setValidPermissionsResult(true) + setLockResult({ + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + global: false, + environment: 'development', + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock development' + }, + status: 'details-only', + global: false, + globalFlag: '--global', + environment: 'development' + }) + setCommentBody('.lock development --details') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'comment_body', '.lock development --details') + assertCalledWith( + infoSpy, + `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('successfully runs the action in lock mode - details only - --info flag', async () => { + const infoSpy = infoMock + setValidPermissionsResult(true) + setLockResult({ + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + status: 'details-only', + global: false, + globalFlag: '--global', + environment: 'production' + }) + setCommentBody('.lock --info') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'comment_body', '.lock --info') + assertCalledWith( + infoSpy, + `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('successfully runs the action in lock mode - details only - lock alias wcid', async () => { + const infoSpy = infoMock + setValidPermissionsResult(true) + setLockResult({ + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + environment: 'production', + global: false, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock production' + }, + environment: 'production', + global: false, + globalFlag: '--global', + status: 'details-only' + }) + + setCommentBody('.wcid') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'comment_body', '.wcid') + assertCalledWith( + infoSpy, + `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock-info-alias') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('successfully runs the action in lock mode - details only - lock alias wcid - and finds a global lock', async () => { + const infoSpy = infoMock + setValidPermissionsResult(true) + setLockResult({ + lockData: { + branch: 'octocats-everywhere', + created_at: '2022-06-14T21:12:14.041Z', + created_by: 'octocat', + global: true, + environment: null, + link: 'https://github.com/test-org/test-repo/pull/2#issuecomment-456', + reason: 'Testing my new feature with lots of cats', + sticky: true, + unlock_command: '.unlock --global' + }, + status: 'details-only', + global: true, + globalFlag: '--global', + environment: null + }) + setCommentBody('.wcid production') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'comment_body', '.wcid production') + assertCalledWith( + infoSpy, + `๐ŸŒ there is a ${COLORS.highlight}global${COLORS.reset} deployment lock on this repository` + ) + assertCalledWith( + infoSpy, + `๐Ÿ”’ the deployment lock is currently claimed by ${COLORS.highlight}octocat` + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock-info-alias') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('successfully runs the action in lock mode and finds no lock - details only', async () => { + const infoSpy = infoMock + setValidPermissionsResult(true) + setLockResult({ + status: null, + lockData: null, + environment: 'production', + global: false, + globalFlag: '--global' + }) + setCommentBody('.lock --details') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'comment_body', '.lock --details') + assertCalledWith(infoSpy, 'โœ… no active deployment locks found') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('successfully runs the action in lock mode and finds no GLOBAL lock - details only', async () => { + const infoSpy = infoMock + setValidPermissionsResult(true) + setLockResult({ + status: null, + lockData: null, + environment: null, + global: true, + globalFlag: '--global' + }) + setCommentBody('.lock --global --details') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'comment_body', '.lock --global --details') + assertCalledWith(infoSpy, 'โœ… no active deployment locks found') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('fails a lock details request when the lock state is ambiguous', async () => { + setValidPermissionsResult(true) + setLockResult({ + status: 'ambiguous', + lockData: null, + environment: 'production', + global: false, + globalFlag: '--global' + }) + setCommentBody('.wcid') + + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'lock_conflict', + operation: 'lock_info', + environment: 'production' + }) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertNotCalled(validDeploymentOrderMock) +}) + +test('fails to aquire the lock on a deploy so it exits', async () => { + setLockResult({ + status: false, + lockData: null, + environment: 'production', + global: false, + globalFlag: '' + }) + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + + assertNotCalled(validDeploymentOrderMock) +}) + +test('runs with the unlock trigger', async () => { + setCommentBody('.unlock') + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'complete', + reason_code: 'unlock_completed', + operation: 'unlock', + environment: 'production' + }) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'unlock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + + assertNotCalled(validDeploymentOrderMock) +}) + +test('fails the action when an interactive unlock cannot delete the lock', async () => { + setCommentBody('.unlock') + unlockMock.mock.mockImplementation(failedUnlock) + + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'failure', + reason_code: 'unlock_failed', + operation: 'unlock', + environment: 'production' + }) + assertCalledWith(setFailedMock, 'failed to remove the deployment lock') +}) + +test('runs with the deprecated noop input', async () => { + setCommentBody('.deploy noop') + isDeprecatedMock.mock.mockImplementation(() => Promise.resolve(true)) + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'deprecated_command', + operation: 'none' + }) + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'bypass', 'true') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('runs with a naked command when naked commands are NOT allowed', async () => { + setEnv('INPUT_DISABLE_NAKED_COMMANDS', 'true') + setCommentBody('.deploy') + nakedCommandCheckMock.mock.mockImplementation(() => Promise.resolve(true)) + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'naked_command_disabled', + operation: 'none' + }) + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('successfully runs the action on a deployment to an exact sha in development with params', async () => { + setEnv('INPUT_ALLOW_SHA_DEPLOYMENTS', 'true') + setPrechecksResult({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: false, + sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc', + isFork: false + }) + + setCommentBody( + '.deploy 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3' + ) + + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith( + setOutputMock, + 'comment_body', + '.deploy 82c238c277ca3df56fe9418a5913d9188eafe3bc development | something1 something2 something3' + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'development') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'deployment_id', 123) + assertCalledWith(debugMock, 'production_environment: false') +}) + +test('successfully runs the action on a deployment and parse the given parameters', async () => { + setEnv('INPUT_ALLOW_SHA_DEPLOYMENTS', 'true') + setPrechecksResult({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: false, + sha: '82c238c277ca3df56fe9418a5913d9188eafe3bc', + isFork: false + }) + + setCommentBody( + '.deploy | --cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' + ) + const expectedParams = { + _: [], + cpu: 2, // Parser automatically cast to number + memory: '4G', + env: 'development', + port: 8080, // Same here + name: 'my-app', + q: 'my-queue' + } + + assert.strictEqual(await run(), 'success') + assertCalledWith( + setOutputMock, + 'params', + '--cpu=2 --memory=4G --env=development --port=8080 --name=my-app -q my-queue' + ) + assertCalledWith(setOutputMock, 'parsed_params', expectedParams) +}) + +test('successfully runs the action after trimming the body', async () => { + setPrechecksResult({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: true, + sha: 'deadbeef', + isFork: false + }) + setCommentBody('.noop \n\t\n ') + assert.strictEqual(await run(), 'success - noop') + // other expects are similar to previous tests. +}) + +test('successfully runs the action with required contexts', async () => { + setEnv('INPUT_REQUIRED_CONTEXTS', 'lint,test,build') + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + + assertNotCalled(validDeploymentOrderMock) +}) + +test('successfully runs the action with required contexts, explict checks, and some ignored checks', async () => { + setEnv('INPUT_CHECKS', 'test,build') + setEnv('INPUT_REQUIRED_CONTEXTS', 'lint,test,build') + setEnv('INPUT_IGNORED_CHECKS', 'lint,foo') + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'deployment_id', 123) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'continue', 'true') + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + + assertNotCalled(validDeploymentOrderMock) +}) + +test('detects an out of date branch and exits', async () => { + deploymentMessage = 'Auto-merged' + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'base_branch_update_required', + operation: 'deploy', + deployment_type: 'branch', + environment: 'production', + ref: 'test-ref', + sha: 'abc123' + }) + assertCalledWith(setOutputMock, 'comment_body', '.deploy') + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'ref', 'test-ref') + assertCalledWith(setOutputMock, 'noop', false) + assertCalledWith(setOutputMock, 'type', 'deploy') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'environment', 'production') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'ref', 'test-ref') + assertCalledWith(saveStateMock, 'noop', false) + assertCalledWith(saveStateMock, 'bypass', 'true') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('fails when GitHub returns a deployment message other than auto-merge', async () => { + deploymentMessage = 'Deployment could not be created' + + assert.strictEqual(await run(), undefined) + assertOperationResult({ + decision: 'failure', + reason_code: 'unexpected_error', + operation: 'deploy', + deployment_type: 'branch', + deployment_id: null, + environment: 'production', + ref: 'test-ref', + sha: 'abc123' + }) + assertCalledWith( + setFailedMock, + 'GitHub did not create a deployment: Deployment could not be created' + ) + assertCalledWith(saveStateMock, 'bypass', 'true') +}) + +test('fails due to a bad context', async () => { + contextCheckMock.mock.mockImplementation(() => false) + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'unsupported_event', + operation: 'none' + }) +}) + +test('fails due to no valid environment targets being found in the comment body', async () => { + setCommentBody('.deploy to chaos') + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'invalid_environment', + operation: 'deploy' + }) + assertCalledWith(debugMock, 'No valid environment targets found') +}) + +test('fails due to no trigger being found', async () => { + setEnv('INPUT_TRIGGER', '.shipit') + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'stop', + reason_code: 'no_trigger', + operation: 'none' + }) + assertCalledWith(infoMock, 'โ›” no trigger detected in comment - exiting') +}) + +test('fails prechecks', async () => { + setPrechecksResult({ + status: false, + message: '### โš ๏ธ Cannot proceed with deployment... something went wrong' + }) + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'prechecks_failed', + operation: 'deploy', + environment: 'production' + }) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertCalledWith( + setFailedMock, + '### โš ๏ธ Cannot proceed with deployment... something went wrong' + ) + + assertNotCalled(validDeploymentOrderMock) +}) + +test('fails commitSafetyChecks', async () => { + commitSafetyChecksMock.mock.mockImplementation(() => ({ + status: false, + message: + '### โš ๏ธ Cannot proceed with deployment... a scary commit was found', + isVerified: false + })) + assert.strictEqual(await run(), 'failure') + assertOperationResult({ + decision: 'failure', + reason_code: 'commit_safety_failed', + operation: 'deploy', + environment: 'production', + ref: 'test-ref', + sha: 'abc123' + }) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertCalledWith( + setFailedMock, + '### โš ๏ธ Cannot proceed with deployment... a scary commit was found' + ) + + assertNotCalled(validDeploymentOrderMock) +}) + +test('fails commitSafetyChecks but proceeds because the operation is on the stable branch', async () => { + setCommentBody('.deploy main') + commitSafetyChecksMock.mock.mockImplementation(() => ({ + status: false, + message: + '### โš ๏ธ Cannot proceed with deployment... a scary commit was found', + isVerified: false + })) + assert.strictEqual(await run(), 'success') + assertCalledWith( + warningMock, + 'commit safety checks failed but the stable branch is being used so the workflow will continue - you should inspect recent commits on this branch as a precaution' + ) +}) + +test('runs the .help command successfully', async () => { + setCommentBody('.help') + assert.strictEqual(await run(), 'safe-exit') + assertOperationResult({ + decision: 'complete', + reason_code: 'help_completed', + operation: 'help' + }) + assertCalledWith(debugMock, 'help command detected') + + assertNotCalled(validDeploymentOrderMock) +}) + +test('runs the .help command successfully', async () => { + setValidPermissionsResult(permissionsMsg) + setCommentBody('.help') + + assert.strictEqual(await run(), 'failure') + assertCalledWith(debugMock, 'help command detected') + assertCalledWith(setFailedMock, permissionsMsg) +}) + +test('runs the action in lock mode and fails due to an invalid environment', async () => { + setValidPermissionsResult(true) + setCommentBody('.lock --details super-production') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith( + debugMock, + 'No valid environment targets found for lock/unlock request' + ) + assertCalledWith( + setOutputMock, + 'comment_body', + '.lock --details super-production' + ) + assertCalledWith(setOutputMock, 'triggered', 'true') + assertCalledWith(setOutputMock, 'comment_id', 123) + assertCalledWith(setOutputMock, 'type', 'lock') + assertCalledWith(saveStateMock, 'isPost', 'true') + assertCalledWith(saveStateMock, 'actionsToken', 'faketoken') + assertCalledWith(saveStateMock, 'comment_id', 123) + assertCalledWith(saveStateMock, 'bypass', 'true') + setEnv('INPUT_GLOBAL_LOCK_FLAG', '') +}) + +test('successfully runs in mergeDeployMode', async () => { + setEnv('INPUT_MERGE_DEPLOY_MODE', 'true') + assert.strictEqual(await run(), 'success - merge deploy mode') + assertOperationResult({ + decision: 'stop', + reason_code: 'merge_deploy_not_required', + operation: 'merge_deploy', + environment: 'production' + }) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertCalledWith(infoMock, `๐Ÿƒ running in 'merge deploy' mode`) +}) + +test('continues mergeDeployMode when the default branch needs deployment', async () => { + setEnv('INPUT_MERGE_DEPLOY_MODE', 'true') + identicalCommitCheckMock.mock.mockImplementation(() => Promise.resolve(false)) + + assert.strictEqual(await run(), 'success - merge deploy mode') + assertOperationResult({ + decision: 'continue', + reason_code: 'merge_deploy_required', + operation: 'merge_deploy', + environment: 'production' + }) +}) + +test('successfully runs in unlockOnMergeMode', async () => { + setEnv('INPUT_UNLOCK_ON_MERGE_MODE', 'true') + assert.strictEqual(await run(), 'success - unlock on merge mode') + assertOperationResult({ + decision: 'complete', + reason_code: 'unlock_on_merge_completed', + operation: 'unlock_on_merge', + environment: 'production' + }) + assertCalledWith(infoMock, `๐Ÿƒ running in 'unlock on merge' mode`) + assertCalledWith(saveStateMock, 'bypass', 'true') + assertNotCalled(validDeploymentOrderMock) +}) + +test('handles an input validation error and exits', async () => { + setEnv('INPUT_UPDATE_BRANCH', 'badvalue') + assert.strictEqual(await run(), undefined) + assertOperationResult({ + decision: 'failure', + reason_code: 'unexpected_error', + operation: 'none' + }) + assert.ok(setFailedMock.mock.callCount() > 0) +}) + +test('handles an unexpected error and exits', async () => { + githubContext.payload = {} + assert.strictEqual(await run(), undefined) + assert.ok(setFailedMock.mock.callCount() > 0) +}) + +test('continues without a decorative reaction when no reaction id is returned', async () => { + reactEmoteMock.mock.mockImplementation(() => Promise.resolve(null)) + assert.strictEqual(await run(), 'success') + assertCalledWith(setOutputMock, 'initial_reaction_id', '') + assertCalledWith(saveStateMock, 'reaction_id', '') +}) + +test('safe-exits when environment target parsing returns an empty target', async () => { + setEnv('INPUT_ENVIRONMENT_TARGETS', '') + assert.strictEqual(await run(), 'safe-exit') + assertCalledWith(debugMock, 'No valid environment targets found') + assertNotCalled(prechecksMock) +}) + +test('stores params and parsed params into context', async () => { + setCommentBody('.deploy | something1 --foo=bar') + const params = 'something1 --foo=bar' + const parsed_params = { + _: ['something1'], + foo: 'bar' + } + assert.strictEqual(await run(), 'success') + const request = createDeploymentMock.mock.calls.at(-1)?.arguments[0] + assert.ok(request !== undefined) + assert.partialDeepStrictEqual(request, { + auto_merge: true, + ref: 'test-ref', + environment: 'production', + owner: 'corp', + repo: 'test', + production_environment: true, + required_contexts: [], + payload: { + params, + parsed_params, + sha: 'abc123', + type: 'branch-deploy', + github_run_id: 12345 + } + }) + assertCalledWith(setOutputMock, 'params', params) + assertCalledWith(setOutputMock, 'parsed_params', parsed_params) +}) + +test('stores params and parsed params into context with complex params', async () => { + setPrechecksResult({ + ref: 'test-ref', + status: true, + message: 'โœ”๏ธ PR is approved and all CI checks passed - OK', + noopMode: false, + sha: 'deadbeef', + isFork: false + }) + + setCommentBody( + '.deploy | something1 --foo=bar --env.development=false --env.production=true LOG_LEVEL=debug,CPU_CORES=4 --config.db.host=localhost --config.db.port=5432' + ) + const params = + 'something1 --foo=bar --env.development=false --env.production=true LOG_LEVEL=debug,CPU_CORES=4 --config.db.host=localhost --config.db.port=5432' + const parsed_params = { + _: ['something1', 'LOG_LEVEL=debug,CPU_CORES=4'], + foo: 'bar', + env: { + development: 'false', + production: 'true' + }, + config: { + db: { + host: 'localhost', + port: 5432 + } + } + } + assert.strictEqual(await run(), 'success') + const request = createDeploymentMock.mock.calls.at(-1)?.arguments[0] + assert.ok(request !== undefined) + assert.partialDeepStrictEqual(request, { + auto_merge: true, + ref: 'test-ref', + environment: 'production', + owner: 'corp', + repo: 'test', + production_environment: true, + required_contexts: [], + payload: { + params, + parsed_params, + sha: 'deadbeef', + type: 'branch-deploy', + github_run_id: 12345, + initial_comment_id: 123, + initial_reaction_id: 123, + deployment_started_comment_id: 123456, + timestamp: '2025-01-01T00:00:00.000Z', + commit_verified: true, + actor: 'monalisa', + stable_branch_used: false + } + }) + assertCalledWith(setOutputMock, 'params', params) + assertCalledWith(setOutputMock, 'parsed_params', parsed_params) +}) + +test('renders arbitrary pre-deploy values as valid fenced JSON', async testContext => { + const hostile = 'quote " slash \\ newline\nUnicode ๐Ÿš€ and `````` backticks' + const comment = githubContext.payload.comment + if (comment === undefined) { + throw new Error('missing test comment') + } + comment['created_at'] = hostile + comment['updated_at'] = hostile + comment['html_url'] = hostile + const createCommentSpy = testContext.mock.method( + octokit.rest.issues, + 'createComment' + ) + + assert.strictEqual(await run(), 'success') + const request = createCommentSpy.mock.calls[0]?.arguments[0] + const rendered = String(request?.body) + const match = rendered.match( + /\n\n(`{3,})json\n([\s\S]*?)\n\1\n\n/u + ) + if (match?.[1] === undefined || match[2] === undefined) { + throw new Error('expected pre-deploy metadata block') + } + assert.ok(match[1].length > 6) + const metadata = unsafeInvalidValue<{ + readonly context: { + readonly comment: { + readonly created_at: string + readonly html_url: string + readonly updated_at: string + } + } + }>(decodedJsonValue(match[2])) + assert.deepStrictEqual(metadata.context.comment, { + created_at: hostile, + updated_at: hostile, + body: '.deploy', + html_url: hostile + }) +}) diff --git a/__tests__/node-test-helpers.test.ts b/__tests__/node-test-helpers.test.ts new file mode 100644 index 00000000..23c6181f --- /dev/null +++ b/__tests__/node-test-helpers.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import { + assertCalledTimes, + assertCalledWith, + assertLastCalledWith, + assertNotCalled, + createMock, + installModuleMock, + queueMockImplementation, + stubEnv, + type Assert, + type Equal, + type Extends, + type Not +} from './node-test-helpers.ts' + +test('creates typed mocks and queues one-time implementations', async () => { + const noImplementation = createMock<() => undefined>() + assert.strictEqual(noImplementation(), undefined) + + const function_ = createMock<(value: string) => Promise>(value => + Promise.resolve(value.length) + ) + queueMockImplementation( + function_, + () => Promise.resolve(99), + () => Promise.resolve(100) + ) + + assert.strictEqual(await function_('first'), 99) + assert.strictEqual(await function_('second'), 100) + assert.strictEqual(await function_('next'), 4) + assertCalledTimes(function_, 3) + assertCalledWith(function_, 'next') + assertLastCalledWith(function_, 'next') +}) + +test('asserts absent and mismatched calls', () => { + const function_ = createMock<(value: string) => void>(() => undefined) + assertNotCalled(function_) + assert.throws( + () => assertLastCalledWith(function_, 'missing'), + /expected mock to have been called/u + ) + + function_('actual') + assert.throws( + () => assertCalledWith(function_, 'different'), + /expected mock to have been called with the supplied arguments/u + ) +}) + +test('restores an environment variable that originally existed', context => { + process.env['NODE_TEST_HELPER_EXISTING'] = 'original' + context.after(() => delete process.env['NODE_TEST_HELPER_EXISTING']) + + stubEnv(context, 'NODE_TEST_HELPER_EXISTING', undefined) + assert.strictEqual(process.env['NODE_TEST_HELPER_EXISTING'], undefined) +}) + +test('restores an environment variable that was originally absent', context => { + delete process.env['NODE_TEST_HELPER_ABSENT'] + + stubEnv(context, 'NODE_TEST_HELPER_ABSENT', 'temporary') + assert.strictEqual(process.env['NODE_TEST_HELPER_ABSENT'], 'temporary') +}) + +test('installs cached ESM module mocks with explicit exports', async context => { + const format = createMock<(value: string) => string>(() => 'mocked') + installModuleMock(context.mock, 'node:util', {format}) + + const util = await import('node:util') + assert.strictEqual(util.format('ignored'), 'mocked') + assertCalledWith(format, 'ignored') +}) + +test('provides compile-time assertion types', () => { + const equal: Assert> = true + const extends_: Assert> = true + const not: Assert> = true + + assert.strictEqual(equal && extends_ && not, true) +}) diff --git a/__tests__/node-test-helpers.ts b/__tests__/node-test-helpers.ts new file mode 100644 index 00000000..f469ebe0 --- /dev/null +++ b/__tests__/node-test-helpers.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import type {Mock, MockModuleContext, MockTracker, TestContext} from 'node:test' +import {mock} from 'node:test' +import {isDeepStrictEqual} from 'node:util' + +// Node 24.18 replaced namedExports/defaultExport with exports. The Node types +// have not yet incorporated that runtime API. +declare module 'node:test' { + interface MockModuleOptions { + exports?: object + } +} + +type Callable = (...arguments_: never[]) => unknown + +export type Assert = Condition + +export type Equal = + (() => Value extends Left ? 1 : 2) extends < + Value + >() => Value extends Right ? 1 : 2 + ? true + : false + +export type Extends = [Value] extends [Expected] ? true : false + +export type Not = Value extends true ? false : true + +export function createMock( + implementation?: FunctionType +): Mock { + return mock.fn(implementation) +} + +export function queueMockImplementation( + mockFunction: Mock, + ...implementations: readonly FunctionType[] +): void { + const firstCall = mockFunction.mock.callCount() + implementations.forEach((implementation, index) => { + mockFunction.mock.mockImplementationOnce(implementation, firstCall + index) + }) +} + +export function installModuleMock( + tracker: MockTracker, + specifier: string | URL, + exports: ModuleExports +): MockModuleContext { + return tracker.module(String(specifier), {cache: true, exports}) +} + +export function stubEnv( + context: TestContext, + name: string, + value: string | undefined +): void { + const existed = Object.hasOwn(process.env, name) + const original = process.env[name] + + context.after(() => { + if (existed) { + process.env[name] = original + } else { + delete process.env[name] + } + }) + + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} + +export function assertCalledTimes( + mockFunction: Mock, + expected: number +): void { + assert.strictEqual(mockFunction.mock.callCount(), expected) +} + +export function assertNotCalled( + mockFunction: Mock +): void { + assertCalledTimes(mockFunction, 0) +} + +export function assertCalledWith( + mockFunction: Mock, + ...expected: Parameters +): void { + assert.ok( + mockFunction.mock.calls.some(call => + isDeepStrictEqual(call.arguments, expected) + ), + 'expected mock to have been called with the supplied arguments' + ) +} + +export function assertLastCalledWith( + mockFunction: Mock, + ...expected: Parameters +): void { + const calls = mockFunction.mock.calls + assert.ok(calls.length > 0, 'expected mock to have been called') + const lastCall = calls.at(-1) + assert.ok(lastCall !== undefined, 'expected a final mock call') + assert.deepStrictEqual(lastCall.arguments, expected) +} diff --git a/__tests__/schemas/action.schema.yml b/__tests__/schemas/action.schema.yml index 4ee9ca44..0c9bcb4e 100644 --- a/__tests__/schemas/action.schema.yml +++ b/__tests__/schemas/action.schema.yml @@ -533,6 +533,18 @@ inputs: # outputs section outputs: + decision: + description: + type: string + required: true + reason_code: + description: + type: string + required: true + result: + description: + type: string + required: true continue: description: type: string diff --git a/__tests__/setup.js b/__tests__/setup.js deleted file mode 100644 index b9d4a145..00000000 --- a/__tests__/setup.js +++ /dev/null @@ -1,23 +0,0 @@ -import {vi} from 'vitest' - -// Mock @actions/core module globally to suppress output -// Individual tests can still spy on these mocked functions -vi.mock('@actions/core', async () => { - const actual = await vi.importActual('@actions/core') - return { - ...actual, - debug: vi.fn(), - info: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setOutput: vi.fn(), - saveState: vi.fn(), - setFailed: vi.fn(), - setSecret: vi.fn(), - exportVariable: vi.fn(), - addPath: vi.fn(), - notice: vi.fn(), - startGroup: vi.fn(), - endGroup: vi.fn() - } -}) diff --git a/__tests__/templates/test_deployment_message.md b/__tests__/templates/test_deployment_message.md index 0e27292e..3130ba0e 100644 --- a/__tests__/templates/test_deployment_message.md +++ b/__tests__/templates/test_deployment_message.md @@ -9,7 +9,7 @@ The following variables are available to use in this template: - `ref` - The ref of the deployment (String) - `sha` - The exact commit SHA of the deployment (String) - `actor` - The GitHub username of the actor who triggered the deployment (String) -- `approved_reviews_count` - The number of approved reviews on the pull request at the time of deployment (String of a number) +- `approved_reviews_count` - The number of approved reviews on the pull request at the time of deployment (Number or null) - `deployment_id` - The ID of the deployment (String) - `review_decision` - The decision of the review (String or null) - `"APPROVED"`, `"REVIEW_REQUIRED"`, `"CHANGES_REQUESTED"`, `null`, etc. - `params` - The raw parameters provided in the deploy command (String) @@ -17,7 +17,8 @@ The following variables are available to use in this template: - `deployment_end_time` - The end time of the deployment - this value is not _exact_ but it is very close (String) - `logs` - The url to the logs of the deployment (String) - `commit_verified` - Whether or not the commit was verified (Boolean) -- `total_seconds` - The total number of seconds the deployment took (String of a number) +- `total_seconds` - The total number of seconds the deployment took (Number) +- `results` - The raw deployment result from `DEPLOY_MESSAGE` (String) Here is an example: @@ -31,7 +32,13 @@ The review decision for this deployment was `{{ review_decision }}`. The deployment had the following parameters provided in the deploy command: `{{ params }}` -The deployment had the following "parsed" parameters provided in the deploy command: `{{ parsed_params | safe }}` +The deployment had the following "parsed" parameters provided in the deploy command: `{{ parsed_params }}` + +
Deployment output + +{{ results }} + +
The deployment process ended at `{{ deployment_end_time }}` and it took `{{ total_seconds }}` seconds to complete. @@ -39,7 +46,7 @@ Here are the deployment logs: {{ logs }} {% if commit_verified %}The commit was verified.{% else %}The commit was not verified.{% endif %} -{% if environment_url %}You can view the deployment [here]({{ environment_url }}).{% endif %} +{% if environment_url !== null %}You can view the deployment [here]({{ environment_url }}).{% endif %} {% if noop %}This was a noop deployment.{% endif %} diff --git a/__tests__/test-helpers.ts b/__tests__/test-helpers.ts new file mode 100644 index 00000000..4f97267e --- /dev/null +++ b/__tests__/test-helpers.ts @@ -0,0 +1,136 @@ +import * as github from '@actions/github' +import type { + ActionInputs, + BranchDeployContext, + BranchDeployOctokit, + IssueCommentContext, + IssueCommentPayload, + IssuePayload +} from '../src/types.ts' + +const DEFAULT_INPUTS = { + admins: 'false', + allowForks: false, + allow_non_default_target_branch_deployments: false, + allow_sha_deployments: false, + checks: 'all', + commit_verification: false, + deployment_confirmation: false, + deployment_confirmation_timeout: 60, + disable_lock: false, + disable_naked_commands: false, + draft_permitted_targets: '', + enforced_deployment_order: [], + environment: 'production', + environment_targets: 'production,staging,development', + environment_urls: '', + global_lock_flag: '--global', + help_trigger: '.help', + ignored_checks: [], + lock_info_alias: '.wcid', + lock_trigger: '.lock', + mergeDeployMode: false, + noop_trigger: '.noop', + outdated_mode: 'default_branch', + param_separator: '|', + permissions: ['write', 'admin'], + production_environments: ['production'], + reaction: 'eyes', + required_contexts: 'false', + skipCi: '', + skipReviews: '', + stable_branch: 'main', + sticky_locks: false, + sticky_locks_for_noop: false, + trigger: '.deploy', + unlockOnMergeMode: false, + unlock_trigger: '.unlock', + update_branch: 'warn', + use_security_warnings: true +} satisfies ActionInputs + +export type DeepMutable = T extends (...args: infer Args) => infer Result + ? (...args: Args) => Result + : T extends readonly (infer Item)[] + ? DeepMutable[] + : T extends object + ? {-readonly [Key in keyof T]: DeepMutable} + : T + +const DEFAULT_COMMENT = { + body: '.deploy', + created_at: '2025-01-01T00:00:00Z', + html_url: 'https://github.com/octo-org/octo-repo/pull/1#issuecomment-1', + id: 1, + updated_at: '2025-01-01T00:00:00Z', + user: {login: 'octocat'} +} satisfies IssueCommentPayload + +const DEFAULT_ISSUE = { + number: 1, + pull_request: {} +} satisfies IssuePayload + +export function createContext( + overrides: Partial = {} +): BranchDeployContext { + return { + actor: 'octocat', + eventName: 'issue_comment', + issue: {number: 1}, + payload: {}, + repo: {owner: 'octo-org', repo: 'octo-repo'}, + runId: 1, + ...overrides + } +} + +export function createActionInputs( + overrides: Partial = {} +): DeepMutable { + const checks = overrides.checks ?? DEFAULT_INPUTS.checks + return { + ...DEFAULT_INPUTS, + ...overrides, + checks: typeof checks === 'string' ? checks : [...checks], + enforced_deployment_order: [ + ...(overrides.enforced_deployment_order ?? + DEFAULT_INPUTS.enforced_deployment_order) + ], + ignored_checks: [ + ...(overrides.ignored_checks ?? DEFAULT_INPUTS.ignored_checks) + ], + permissions: [...(overrides.permissions ?? DEFAULT_INPUTS.permissions)], + production_environments: [ + ...(overrides.production_environments ?? + DEFAULT_INPUTS.production_environments) + ] + } +} + +export function createIssueCommentContext( + overrides: Partial> & { + payload?: Omit< + Partial, + 'comment' | 'issue' + > & { + comment?: Partial + issue?: Partial + } + } = {} +): IssueCommentContext { + const {payload = {}, ...contextOverrides} = overrides + + return { + ...createContext(contextOverrides), + payload: { + ...payload, + comment: {...DEFAULT_COMMENT, ...payload.comment}, + issue: {...DEFAULT_ISSUE, ...payload.issue} + } + } +} + +export function createOctokit(): BranchDeployOctokit { + return github.getOctokit('test-token') +} diff --git a/__tests__/types.test.ts b/__tests__/types.test.ts new file mode 100644 index 00000000..ee82a9db --- /dev/null +++ b/__tests__/types.test.ts @@ -0,0 +1,173 @@ +import yargsParser from 'yargs-parser' +import assert from 'node:assert/strict' +import {test} from 'node:test' +import { + ACTION_INPUT_KEYS, + ACTION_OUTPUT_KEYS, + ACTION_STATE_KEYS, + BOOLEAN_ACTION_INPUT_KEYS, + INTEGER_ACTION_INPUT_KEYS, + type ActionInputKey, + type BooleanActionInputKey, + type IntegerActionInputKey, + type ActionOutputKey, + type ActionStateKey +} from '../src/action-io.ts' +import { + LITERAL_ACTION_INPUT_KEYS, + LITERAL_ACTION_INPUT_VALUES, + type LiteralActionInputKey +} from '../src/functions/inputs.ts' +import type {ActionStatusRequest} from '../src/functions/action-status.ts' +import type { + DeploymentEnvironmentRequest, + EnvironmentTargetsRequest, + LockEnvironmentRequest +} from '../src/functions/environment-targets.ts' +import type {LockRequest} from '../src/functions/lock.ts' +import type { + InteractiveUnlockRequest, + SilentUnlockRequest +} from '../src/functions/unlock.ts' +import {post} from '../src/functions/post.ts' +import {run} from '../src/main.ts' +import {OPERATION_REASON_CODES} from '../src/operation-result.ts' +import type { + DeploymentConfirmationResult, + LockResponse, + OperationDecision, + OperationReasonCode, + OperationResultV1, + PrecheckResult, + RunResult +} from '../src/types.ts' +import type {Assert, Equal, Extends, Not} from './node-test-helpers.ts' + +function assertType( + condition: Assert +): void { + assert.strictEqual(condition, true) +} + +test('action registries expose only their literal key unions', () => { + assertType>(true) + assertType>(true) + assertType>(true) +}) + +test('typed input registries expose exact ActionInputKey subsets', () => { + type ExpectedBooleanInputKey = + | 'allow_forks' + | 'allow_non_default_target_branch_deployments' + | 'allow_sha_deployments' + | 'commit_verification' + | 'deployment_confirmation' + | 'disable_lock' + | 'disable_naked_commands' + | 'environment_url_in_comment' + | 'merge_deploy_mode' + | 'skip_completing' + | 'skip_successful_deploy_labels_if_approved' + | 'skip_successful_noop_labels_if_approved' + | 'sticky_locks' + | 'sticky_locks_for_noop' + | 'unlock_on_merge_mode' + | 'use_security_warnings' + + assertType< + Equal<(typeof BOOLEAN_ACTION_INPUT_KEYS)[number], BooleanActionInputKey> + >(true) + assertType>(true) + assertType>(true) + assertType< + Equal<(typeof INTEGER_ACTION_INPUT_KEYS)[number], IntegerActionInputKey> + >(true) + assertType>( + true + ) + assertType>(true) + assertType< + Equal<(typeof LITERAL_ACTION_INPUT_KEYS)[number], LiteralActionInputKey> + >(true) + assertType< + Equal + >(true) + assertType>(true) + assertType< + Equal< + (typeof LITERAL_ACTION_INPUT_VALUES)['update_branch'][number], + 'disabled' | 'force' | 'warn' + > + >(true) + assertType< + Equal< + (typeof LITERAL_ACTION_INPUT_VALUES)['outdated_mode'][number], + 'default_branch' | 'pr_base' | 'strict' + > + >(true) + assertType< + Equal< + (typeof LITERAL_ACTION_INPUT_VALUES)['checks'][number], + 'all' | 'required' + > + >(true) +}) + +test('state-machine results retain correlated discriminants', () => { + assertType['sha'], string>>( + true + ) + assertType['sha'], undefined>>( + true + ) + assertType< + Not['lockData'], null>> + >(true) + assertType< + Equal['lockData'], null> + >(true) + assertType< + Equal<(typeof OPERATION_REASON_CODES)[number], OperationReasonCode> + >(true) + assertType>(true) + assertType>(true) + assertType< + Equal + >(true) +}) + +test('request objects select behavior through literal modes', () => { + assertType< + Equal< + Extract, + DeploymentEnvironmentRequest + > + >(true) + assertType< + Equal< + Extract, + LockEnvironmentRequest + > + >(true) + assertType< + Equal< + ActionStatusRequest['result'], + 'alternate-success' | 'failure' | 'success' | undefined + > + >(true) + assertType>(true) + assertType>(true) + assertType>(true) +}) + +test('entrypoints retain their literal result contracts', () => { + assertType>, RunResult>>(true) + assertType>, void>>(true) +}) + +test('local vendor declarations stay intentionally narrow', () => { + assertType[0], string>>(true) + assertType['_'], (number | string)[]>>( + true + ) +}) diff --git a/__tests__/types/js-yaml.d.ts b/__tests__/types/js-yaml.d.ts new file mode 100644 index 00000000..c934f566 --- /dev/null +++ b/__tests__/types/js-yaml.d.ts @@ -0,0 +1,3 @@ +declare module 'js-yaml' { + export function load(input: string): unknown +} diff --git a/__tests__/typescript-policy.test.ts b/__tests__/typescript-policy.test.ts new file mode 100644 index 00000000..4969de85 --- /dev/null +++ b/__tests__/typescript-policy.test.ts @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import {tmpdir} from 'node:os' +import {join, resolve} from 'node:path' +import {mock, test} from 'node:test' +import { + POLICY_IDS, + checkProject, + checkSourceText, + checkSourceTexts, + formatDiagnostic, + runPolicy, + type PolicyDiagnostic, + type PolicyId +} from '../tools/typescript-policy.ts' + +interface PolicyFixture { + readonly expected: readonly string[] + readonly name: string + readonly source: string +} + +const fixturePath = resolve( + import.meta.dirname, + 'fixtures/typescript-policy/cases.json' +) +const fixtureData: unknown = JSON.parse(readFileSync(fixturePath, 'utf8')) + +function parseFixtures(value: unknown): readonly PolicyFixture[] { + if (!isUnknownArray(value)) { + throw new TypeError('policy fixtures must be an array') + } + return value.map(entry => { + if (!isRecord(entry)) { + throw new TypeError('policy fixture must be an object') + } + const {expected, name, source} = entry + if ( + !isStringArray(expected) || + typeof name !== 'string' || + typeof source !== 'string' + ) { + throw new TypeError('policy fixture fields are invalid') + } + return {expected, name, source} + }) +} + +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isStringArray(value: unknown): value is string[] { + return isUnknownArray(value) && value.every(item => typeof item === 'string') +} + +const fixtures = parseFixtures(fixtureData) + +function fixtureSourcePath(name: string): string { + return `__tests__/fixtures/typescript-policy/${name}.ts` +} + +function diagnosticMap( + diagnostics: readonly PolicyDiagnostic[] +): ReadonlyMap { + const output = new Map() + for (const diagnostic of diagnostics) { + const current = output.get(diagnostic.path) + const formatted = formatDiagnostic(diagnostic) + if (current === undefined) output.set(diagnostic.path, [formatted]) + else current.push(formatted) + } + return output +} + +test('TypeScript policy fixtures match diagnostics', () => { + const diagnostics = diagnosticMap( + checkSourceTexts( + fixtures.map(fixture => ({ + path: fixtureSourcePath(fixture.name), + sourceText: fixture.source + })) + ) + ) + + for (const fixture of fixtures) { + assert.deepStrictEqual( + diagnostics.get(fixtureSourcePath(fixture.name)) ?? [], + fixture.expected + ) + } +}) + +test('every named policy has an invalid fixture', () => { + const covered = new Set() + for (const fixture of fixtures) { + for (const diagnostic of fixture.expected) { + const policy = POLICY_IDS.find(id => diagnostic.includes(`: ${id}: `)) + if (policy !== undefined) covered.add(policy) + } + } + assert.deepStrictEqual([...covered].sort(), [...POLICY_IDS].sort()) +}) + +test('grouped policy variants remain enforced', () => { + const cases = [ + ['Promise.resolve(1);\n', 'promise-safety'], + ['async function value() { return 1 }\n', 'promise-safety'], + ['await 1\n', 'promise-safety'], + ['new Promise(async resolve => resolve())\n', 'promise-safety'], + ['const value: any = 1\nvalue.member\n', 'no-unsafe-any'], + ["if ((value = 'x')) {}\n", 'control-flow'], + ["({}).hasOwnProperty('x')\n", 'control-flow'], + ['NaN === 1\n', 'control-flow'], + ['try {} finally { return }\n', 'control-flow'], + ["throw 'value'\n", 'control-flow'], + ["'value' + 1\n", 'safe-string-operations'], + ["(0, eval)('value')\n", 'dangerous-eval'] + ] as const + const diagnostics = diagnosticMap( + checkSourceTexts( + cases.map(([source], index) => ({ + path: fixtureSourcePath(`grouped-${String(index)}`), + sourceText: source + })), + 'force', + false + ) + ) + + for (const [index, [, policy]] of cases.entries()) { + assert.ok( + ( + diagnostics.get(fixtureSourcePath(`grouped-${String(index)}`)) ?? [] + ).some(diagnostic => diagnostic.includes(`: ${policy}: `)), + `${policy} did not reject case ${String(index)}` + ) + } + + assert.deepStrictEqual( + checkSourceTexts( + [ + { + path: fixtureSourcePath('grouped-valid-boolean-condition'), + sourceText: 'if (true && true) {}\n' + }, + { + path: fixtureSourcePath('grouped-valid-loop-break'), + sourceText: 'for (; true; ) { break }\n' + }, + { + path: fixtureSourcePath('grouped-valid-date-constructor'), + sourceText: 'new Date\n' + }, + { + path: fixtureSourcePath('grouped-valid-default-export'), + sourceText: 'const value = 1\nexport default value\n' + }, + { + path: fixtureSourcePath('grouped-valid-nested-finally-return'), + sourceText: + 'try {} finally { function nested(): void { return } nested() }\n' + } + ], + 'force', + false + ), + [] + ) + assert.ok( + checkSourceText( + 'declare function take(value: string): void\nconst value: any = 1\ntake(value)\n' + ).some(diagnostic => diagnostic.policyId === 'no-unsafe-any') + ) +}) + +test('unsafe TypeScript escape hatches stay at named trust boundaries', () => { + assert.strictEqual(runPolicy(), 0) +}) + +test('reports invalid policy diagnostics through the CLI boundary', () => { + const diagnostic = { + column: 3, + line: 2, + message: 'example message', + path: 'src/example.ts', + policyId: 'control-flow' + } as const satisfies PolicyDiagnostic + const errorMock = mock.method(console, 'error', () => undefined) + + assert.strictEqual(runPolicy([]), 0) + assert.strictEqual(runPolicy([diagnostic]), 1) + assert.deepStrictEqual( + errorMock.mock.calls.map(call => call.arguments), + [[formatDiagnostic(diagnostic)]] + ) +}) + +test('rejects a missing project configuration', context => { + const root = mkdtempSync(join(tmpdir(), 'branch-deploy-policy-missing-')) + context.after(() => rmSync(root, {force: true, recursive: true})) + assert.throws(() => checkProject(root), /could not parse tsconfig\.json/u) +}) + +test('accepts a standalone script without module exports', () => { + assert.deepStrictEqual( + checkSourceText( + 'const value = true\n', + '../typescript-policy-script.ts', + 'legacy' + ), + [] + ) +}) + +test('accepts source without a global Error symbol', () => { + assert.deepStrictEqual( + checkSourceText( + '/// \nconst value = true\n', + '__tests__/fixtures/typescript-policy/no-global-error-symbol.ts', + 'force', + false + ), + [] + ) +}) + +test('reports stale trust-boundary allowlist entries', context => { + const root = mkdtempSync(join(tmpdir(), 'branch-deploy-policy-stale-')) + context.after(() => rmSync(root, {force: true, recursive: true})) + mkdirSync(join(root, 'src')) + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({compilerOptions: {strict: true}, include: ['src/**/*.ts']}) + ) + writeFileSync(join(root, 'src/example.ts'), 'export const value = true\n') + + assert.deepStrictEqual(checkProject(root).map(formatDiagnostic), [ + '__tests__/unsafe-fixtures.ts:1:1: no-unsafe-assertion: stale trust-boundary allowance: fixture-assertion', + 'src/trust-boundaries.ts:1:1: no-unsafe-any: stale trust-boundary allowance: trust-any', + 'src/trust-boundaries.ts:1:1: no-unsafe-assertion: stale trust-boundary allowance: trust-assertion', + 'src/trust-boundaries.ts:1:1: strict-equality: stale trust-boundary allowance: trust-equality' + ]) +}) diff --git a/__tests__/unsafe-fixtures.ts b/__tests__/unsafe-fixtures.ts new file mode 100644 index 00000000..21f64089 --- /dev/null +++ b/__tests__/unsafe-fixtures.ts @@ -0,0 +1,10 @@ +/** + * Supplies a deliberately invalid JavaScript value to a typed API. + * + * Keep this helper limited to characterization tests that exercise values a + * TypeScript caller could not provide. Ordinary fixtures must satisfy their + * production type without using this escape hatch. + */ +export function unsafeInvalidValue(value: unknown): T { + return value as T +} diff --git a/__tests__/version.test.js b/__tests__/version.test.js deleted file mode 100644 index 3d7c0665..00000000 --- a/__tests__/version.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import {VERSION} from '../src/version.js' -import {expect, test} from 'vitest' - -const versionRegex = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/ - -test('VERSION constant should match the version pattern', () => { - expect(VERSION).toMatch(versionRegex) -}) - -test('should validate v1.0.0', () => { - const version = 'v1.0.0' - expect(version).toMatch(versionRegex) -}) - -test('should validate v4.5.1', () => { - const version = 'v4.5.1' - expect(version).toMatch(versionRegex) -}) - -test('should validate v10.123.44', () => { - const version = 'v10.123.44' - expect(version).toMatch(versionRegex) -}) - -test('should validate v1.1.1-rc.1', () => { - const version = 'v1.1.1-rc.1' - expect(version).toMatch(versionRegex) -}) - -test('should validate v15.19.4-rc.35', () => { - const version = 'v15.19.4-rc.35' - expect(version).toMatch(versionRegex) -}) diff --git a/__tests__/version.test.ts b/__tests__/version.test.ts new file mode 100644 index 00000000..dc0c5e59 --- /dev/null +++ b/__tests__/version.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {VERSION} from '../src/version.ts' + +const stableVersionRegex = /^v\d+\.\d+\.\d+$/ + +test('VERSION constant should match the stable version pattern', () => { + assert.match(VERSION, stableVersionRegex) +}) + +for (const version of ['v1.0.0', 'v4.5.1', 'v10.123.44']) { + test(`should validate stable version ${version}`, () => { + assert.match(version, stableVersionRegex) + }) +} + +for (const version of [ + 'v1.1.1-rc.1', + 'v15.19.4-rc.35', + 'v1.2.3-beta.1', + 'v1.2', + '1.2.3', + 'v1.two.3' +]) { + test(`should reject non-stable version ${version}`, () => { + assert.doesNotMatch(version, stableVersionRegex) + }) +} diff --git a/action.yml b/action.yml index 88841d0d..f8a05b70 100644 --- a/action.yml +++ b/action.yml @@ -114,9 +114,9 @@ inputs: required: false default: "" allow_forks: - description: 'Allow branch deployments to run on repository forks. If you want to harden your workflows, this option can be set to false. Default is "true"' + description: 'Allow branch deployments to run on repository forks. Default is "false". Set to "true" only when your workflow intentionally supports deployments from forked pull requests.' required: false - default: "true" + default: "false" admins: description: 'A comma separated list of GitHub usernames or teams that should be considered admins by this Action. Admins can deploy pull requests without the need for branch protection approvals. Example: "monalisa,octocat,my-org/my-team"' required: false @@ -126,7 +126,7 @@ inputs: required: false default: "false" merge_deploy_mode: - description: This is an advanced option that is an alternate workflow bundled into this Action. You can control how merge commits are handled when a PR is merged into your repository's default branch. If the merge commit SHA matches the latest deployment for the same environment, then the 'continue' output will be set to 'false' which indicates that a deployment should not be performed again as the latest deployment is identical. If the merge commit SHA does not match the latest deployment for the same environment, then the 'continue' output will be set to 'true' which indicates that a deployment should be performed. With this option, the 'environment' output is also set for subsequent steps to reference + description: This advanced alternate mode controls deployment after changes reach the default branch. The 'continue' output is 'false' only when the newest identifiable Branch Deploy deployment for the environment is active and its commit tree matches the current default branch. Missing, inactive, failed, pending, malformed, or different-tree deployment history sets 'continue' to 'true'. The 'environment' output is also set for subsequent steps. required: false default: "false" unlock_on_merge_mode: @@ -134,15 +134,15 @@ inputs: required: false default: "false" skip_completing: - description: 'If set to "true", skip the process of completing a deployment. You must manually create a deployment status after the deployment is complete. Default is "false"' + description: 'If set to true, bypass the entire post-action completion path. The workflow must manage final deployment status, comments, reactions, labels, and non-sticky lock cleanup. Default is false.' required: false default: "false" deploy_message_path: - description: 'The path to a markdown file which is used as a template for custom deployment messages. Example: ".github/deployment_message.md"' + description: 'The repository-relative path to a trusted Markdown template for custom deployment messages. The file is fetched from the repository at the exact workflow SHA. Example: ".github/deployment_message.md"' required: false default: ".github/deployment_message.md" sticky_locks: - description: 'If set to "true", locks will not be released after a deployment run completes. This applies to both successful, and failed deployments.Sticky locks are also known as "hubot style deployment locks". They will persist until they are manually released by a user, or if you configure another workflow with the "unlock on merge" mode to remove them automatically on PR merge.' + description: 'If set to "true", locks will not be released after a deployment run completes. This applies to both successful, and failed deployments. Sticky locks are also known as "hubot style deployment locks". They will persist until they are manually released by a user, or if you configure another workflow with the "unlock on merge" mode to remove them automatically on PR merge.' required: false default: "false" sticky_locks_for_noop: @@ -150,7 +150,7 @@ inputs: required: false default: "false" disable_lock: - description: 'If set to "true", all deployment locking is disabled. Useful for workflows where concurrent deployments are safe (e.g. iOS/Android builds uploaded to TestFlight). When disabled, .lock and .unlock commands will return an informational message instead of modifying lock state.' + description: 'If set to "true", all deployment locking is disabled. Useful for workflows where concurrent deployments are safe (e.g. iOS/Android builds uploaded to TestFlight). When disabled, lock-related commands return an informational message instead of modifying lock state.' required: false default: "false" allow_sha_deployments: @@ -202,7 +202,7 @@ inputs: required: false default: "false" deployment_confirmation_timeout: - description: 'The number of seconds to wait for a deployment confirmation before timing out. Default is "60" seconds (1 minute).' + description: 'The number of seconds to wait for a deployment confirmation before timing out. Must be a positive integer. Default is "60" seconds (1 minute).' required: false default: "60" outputs: @@ -265,13 +265,13 @@ outputs: sha_deployment: description: 'If "allow_sha_deployments" is enabled, and a sha deployment is performed instead of a branch deployment, this output variable will contain the sha that was deployed. Otherwise, this output variable will be empty' review_decision: - description: 'The pull request review status. Can be one of a few values - examples: APPROVED, REVIEW_REQUIRED, CHANGES_REQUESTED, skip_reviews,, null' + description: 'The pull request review status. Can be one of a few values - examples: APPROVED, REVIEW_REQUIRED, CHANGES_REQUESTED, skip_reviews, null' is_outdated: description: 'The string "true" if the branch is outdated, otherwise "false"' merge_state_status: description: 'The status of the merge state. Can be one of a few values - examples: "DIRTY", "DRAFT", "CLEAN", etc' commit_status: - description: 'The status of the commit. Can be one of a few values - examples: "SUCCESS", null, "skip_ci", "PENDING", "FAILURE" etc' + description: 'The status of the commit. Can be one of a few values - examples: "SUCCESS", null, "skip_ci", "PENDING", "FAILURE", or "UNAVAILABLE". UNAVAILABLE blocks deployment because CI status could not be verified.' approved_reviews_count: description: 'The number of approved reviews on the pull request' needs_to_be_deployed: @@ -282,6 +282,12 @@ outputs: description: 'The total number of seconds that the deployment took to complete (Integer)' non_default_target_branch_used: description: 'The string "true" if the pull request is targeting a branch other than the default branch (aka stable branch) for the merge, otherwise unset' + decision: + description: 'The preferred main action decision output: continue, complete, stop, or failure' + reason_code: + description: 'The preferred stable machine-readable reason code for the main action decision' + result: + description: 'The preferred deterministic JSON string describing the versioned main action result' runs: using: "node24" main: "dist/index.js" diff --git a/dist/index.js b/dist/index.js index d0d10560..d99b8e88 100644 --- a/dist/index.js +++ b/dist/index.js @@ -842,276 +842,6 @@ class DecodedURL extends URL { } //# sourceMappingURL=proxy.js.map -/***/ }), - -/***/ 7330: -/***/ (function(module) { - -// MIT license (by Elan Shanker). -(function(globals) { - 'use strict'; - - var executeSync = function(){ - var args = Array.prototype.slice.call(arguments); - if (typeof args[0] === 'function'){ - args[0].apply(null, args.splice(1)); - } - }; - - var executeAsync = function(fn){ - if (typeof setImmediate === 'function') { - setImmediate(fn); - } else if (typeof process !== 'undefined' && process.nextTick) { - process.nextTick(fn); - } else { - setTimeout(fn, 0); - } - }; - - var makeIterator = function (tasks) { - var makeCallback = function (index) { - var fn = function () { - if (tasks.length) { - tasks[index].apply(null, arguments); - } - return fn.next(); - }; - fn.next = function () { - return (index < tasks.length - 1) ? makeCallback(index + 1): null; - }; - return fn; - }; - return makeCallback(0); - }; - - var _isArray = Array.isArray || function(maybeArray){ - return Object.prototype.toString.call(maybeArray) === '[object Array]'; - }; - - var waterfall = function (tasks, callback, forceAsync) { - var nextTick = forceAsync ? executeAsync : executeSync; - callback = callback || function () {}; - if (!_isArray(tasks)) { - var err = new Error('First argument to waterfall must be an array of functions'); - return callback(err); - } - if (!tasks.length) { - return callback(); - } - var wrapIterator = function (iterator) { - return function (err) { - if (err) { - callback.apply(null, arguments); - callback = function () {}; - } else { - var args = Array.prototype.slice.call(arguments, 1); - var next = iterator.next(); - if (next) { - args.push(wrapIterator(next)); - } else { - args.push(callback); - } - nextTick(function () { - iterator.apply(null, args); - }); - } - }; - }; - wrapIterator(makeIterator(tasks))(); - }; - - if (typeof define !== 'undefined' && define.amd) { - define([], function () { - return waterfall; - }); // RequireJS - } else if ( true && module.exports) { - module.exports = waterfall; // CommonJS - } else { - globals.waterfall = waterfall; //