From b984297d1cd2a39bf85f24c5fdaf7bffc39f3f12 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Wed, 11 Mar 2026 18:56:19 -0700 Subject: [PATCH 1/5] feat: add unit tests and documentation - Add 60 bats unit tests covering all testable shell scripts: - options_helpers.sh (has_argument, extract_argument) - aws_unset.sh (clears all 4 AWS credential env vars) - configure_pip.sh (pip config calls for each env var) - promote_image.sh (all 11 required env var validations) - update_ecs.sh (--help, AWS CLI invocation, failure path) - update_lambda.sh (--help, AWS CLI invocation, failure path) - Add mock helpers for aws, pip, docker, tput - Add .github/workflows/test-shell-scripts.yml CI workflow - Add documentation/ folder with full reference docs for all 10 actions All 60 tests pass locally. --- .github/workflows/test-shell-scripts.yml | 35 +++ documentation/README.md | 37 +++ documentation/actions/configure-aws.md | 43 ++++ documentation/actions/job-info.md | 71 ++++++ documentation/actions/lint-sql.md | 55 +++++ documentation/actions/lint-terraform.md | 51 ++++ documentation/actions/lint-test-yarn.md | 89 +++++++ documentation/actions/promote-ecr-image.md | 115 +++++++++ documentation/actions/test-python.md | 97 ++++++++ documentation/actions/update-aws-ecs.md | 77 ++++++ documentation/actions/update-aws-lambda.md | 82 +++++++ documentation/actions/validate-terraform.md | 71 ++++++ tests/README.md | 78 ++++++ tests/helpers/mock_helpers.bash | 98 ++++++++ tests/unit/aws_unset.bats | 87 +++++++ tests/unit/configure_pip.bats | 186 ++++++++++++++ tests/unit/options_helpers.bats | 94 ++++++++ tests/unit/promote_image.bats | 255 ++++++++++++++++++++ tests/unit/update_ecs.bats | 166 +++++++++++++ tests/unit/update_lambda.bats | 155 ++++++++++++ 20 files changed, 1942 insertions(+) create mode 100644 .github/workflows/test-shell-scripts.yml create mode 100644 documentation/README.md create mode 100644 documentation/actions/configure-aws.md create mode 100644 documentation/actions/job-info.md create mode 100644 documentation/actions/lint-sql.md create mode 100644 documentation/actions/lint-terraform.md create mode 100644 documentation/actions/lint-test-yarn.md create mode 100644 documentation/actions/promote-ecr-image.md create mode 100644 documentation/actions/test-python.md create mode 100644 documentation/actions/update-aws-ecs.md create mode 100644 documentation/actions/update-aws-lambda.md create mode 100644 documentation/actions/validate-terraform.md create mode 100644 tests/README.md create mode 100644 tests/helpers/mock_helpers.bash create mode 100644 tests/unit/aws_unset.bats create mode 100644 tests/unit/configure_pip.bats create mode 100644 tests/unit/options_helpers.bats create mode 100644 tests/unit/promote_image.bats create mode 100644 tests/unit/update_ecs.bats create mode 100644 tests/unit/update_lambda.bats diff --git a/.github/workflows/test-shell-scripts.yml b/.github/workflows/test-shell-scripts.yml new file mode 100644 index 0000000..46051b1 --- /dev/null +++ b/.github/workflows/test-shell-scripts.yml @@ -0,0 +1,35 @@ +name: Test Shell Scripts + +on: + push: + branches: [main] + paths: + - '.github/actions/**/*.sh' + - 'tests/**' + pull_request: + branches: [main] + paths: + - '.github/actions/**/*.sh' + - 'tests/**' + +jobs: + unit-tests: + name: bats unit tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install bats + run: npm install -g bats + + - name: Verify bats version + run: bats --version + + - name: Run unit tests + run: bats --verbose-run tests/unit/ + + - name: Summarise results + if: always() + run: echo "Shell script unit tests complete." diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..93d226e --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,37 @@ +# Documentation + +Comprehensive reference documentation for all GitHub Actions in this repository. + +## Actions + +| Action | Description | +|--------|-------------| +| [configure-aws](./actions/configure-aws.md) | Configure AWS credentials on the runner | +| [job-info](./actions/job-info.md) | Derive branch, tag, PR branch, and environment from the GitHub context | +| [lint-sql](./actions/lint-sql.md) | Lint SQL files with SQLFluff | +| [lint-terraform](./actions/lint-terraform.md) | Lint Terraform with `terraform fmt` | +| [lint-test-yarn](./actions/lint-test-yarn.md) | Lint and test a Node.js project with Yarn | +| [promote-ecr-image](./actions/promote-ecr-image.md) | Promote a Docker image between ECR environments | +| [test-python](./actions/test-python.md) | Run Python tests with pytest or tox | +| [update-aws-ecs](./actions/update-aws-ecs.md) | Force-update an ECS service | +| [update-aws-lambda](./actions/update-aws-lambda.md) | Update an AWS Lambda function's container image | +| [validate-terraform](./actions/validate-terraform.md) | Validate Terraform configuration | + +## Internal Scripts + +Each AWS-facing action bundles a set of bash helper scripts under its `scripts/general/` directory. + +| Script | Purpose | +|--------|---------| +| `options_helpers.sh` | Argument-parsing utilities (`has_argument`, `extract_argument`) | +| `aws_unset.sh` | Clears AWS credential environment variables | +| `assume_role.sh` | Calls `aws sts assume-role` and exports the credentials | +| `assume_ecr_write_access_role.sh` | Wraps `assume_role` for ECR write access | +| `assume_ecs_access_role.sh` | Wraps `assume_role` for ECS access | +| `assume_lambda_update_role.sh` | Wraps `assume_role` for Lambda update permissions | + +> **Note:** `options_helpers.sh`, `aws_unset.sh`, and `assume_role.sh` are currently duplicated across the `promote-ecr-image`, `update-aws-ecs`, and `update-aws-lambda` actions. They are functionally identical. + +## Testing + +See [../tests/README.md](../tests/README.md) for information on running the unit test suite. diff --git a/documentation/actions/configure-aws.md b/documentation/actions/configure-aws.md new file mode 100644 index 0000000..1637cfa --- /dev/null +++ b/documentation/actions/configure-aws.md @@ -0,0 +1,43 @@ +# configure-aws + +**Path:** `.github/actions/configure-aws` + +Configures AWS credentials on the GitHub Actions runner by writing them into the default AWS CLI profile, then schedules a post-run cleanup step that scrubs those credentials after the job finishes. + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| `aws_access_key_id` | ✅ | AWS access key ID | +| `aws_secret_access_key` | ✅ | AWS secret access key | +| `aws_default_region` | ✅ | AWS region (e.g. `us-east-1`) | + +## Outputs + +None. + +## How It Works + +1. Runs `aws configure set` to write the three credential values into `~/.aws/credentials` under the `default` profile. +2. Registers three **post-run** cleanup steps via `webiny/action-post-run@3.1.0` that overwrite the credential values with `"XXX"` once the job finishes — preventing credential leakage in log artifacts or subsequent steps. + +## Usage + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/configure-aws@ + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_default_region: us-east-1 +``` + +## Notes + +- This action is used internally by `promote-ecr-image`, `update-aws-ecs`, and `update-aws-lambda` when AWS credentials are passed as inputs. +- If you are using OIDC-based authentication (e.g. `aws-actions/configure-aws-credentials`), you do **not** need this action. +- The post-run cleanup is best-effort; if the runner is forcibly terminated, cleanup may not execute. + +## Dependencies + +- AWS CLI must be available on the runner (`ubuntu-latest` includes it by default). +- `webiny/action-post-run@3.1.0` for scheduled post-step execution. diff --git a/documentation/actions/job-info.md b/documentation/actions/job-info.md new file mode 100644 index 0000000..f6f579b --- /dev/null +++ b/documentation/actions/job-info.md @@ -0,0 +1,71 @@ +# job-info + +**Path:** `.github/actions/job-info` + +Derives contextual information about the current job — target branch, tag, PR branch, and deployment environment — from the GitHub Actions runtime context. Use this as an early step to get consistent environment names across all your workflows. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `default_environment` | ❌ | `''` | Fallback environment name when no branch/tag rule matches | + +## Outputs + +| Output | Description | +|--------|-------------| +| `branch` | The target branch (base branch for PRs; current branch for pushes) | +| `env_name` | The resolved deployment environment name | +| `pr_branch` | The source branch of a pull request (empty for non-PR events) | +| `tag` | The git tag that triggered the workflow, or `"none"` | + +## Environment Mapping + +### Branch → environment + +| Branch | Environment | +|--------|------------| +| `develop` | `dev` | +| `main` | `prod` | +| `qa` | `qa` | +| `sandbox` | `sandbox` | +| `staging` | `staging` | +| `test` | `test` | +| anything else | `default_environment` input | + +### Tag → environment + +| Tag pattern | Environment | +|-------------|------------| +| `*-dev` | `dev` | +| `*-qa` | `qa` | +| `*-sandbox` | `sandbox` | +| `*-staging` | `staging` | +| `*-test` | `test` | +| semver (e.g. `1.2.3`, `1.2.3-rc.1`) | `prod` | +| anything else | unchanged (branch rule applies first) | + +Tag rules take precedence over branch rules when both apply. + +## Usage + +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - id: info + uses: generalui/github-workflow-accelerators/.github/actions/job-info@ + with: + default_environment: dev + + - name: Deploy to ${{ steps.info.outputs.env_name }} + run: echo "Deploying to ${{ steps.info.outputs.env_name }}" +``` + +## Notes + +- For **pull_request** events, `branch` is the **base** (target) branch, not the head branch. Use `pr_branch` to get the source branch. +- For **push** events on a branch, `branch` is the branch name. `pr_branch` will be empty. +- For **tag push** events, `tag` contains the tag name and `env_name` is resolved from the tag pattern table above. +- The semver pattern matches `MAJOR.MINOR.PATCH` with optional pre-release/build metadata (e.g. `1.0.0`, `2.1.0-beta.1`, `3.0.0+build.42`). diff --git a/documentation/actions/lint-sql.md b/documentation/actions/lint-sql.md new file mode 100644 index 0000000..bd2e0ed --- /dev/null +++ b/documentation/actions/lint-sql.md @@ -0,0 +1,55 @@ +# lint-sql + +**Path:** `.github/actions/lint-sql` + +Lints SQL files using [SQLFluff](https://docs.sqlfluff.com/). Checks out the repository, installs the requested version of SQLFluff, and runs `sqlfluff lint` against the specified path. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `path` | ✅ | — | Path to a SQL file or directory to lint. Use `.` for the entire repository. | +| `config` | ❌ | `''` | Path to an additional SQLFluff config file (`.cfg` format) that overrides the standard configuration. | +| `python-version` | ❌ | `latest` | Python version to install (passed to `actions/setup-python`). | +| `sqlfluff-version` | ❌ | `''` | SQLFluff version to install (e.g. `2.3.0`). Defaults to the latest published version. | + +## Outputs + +None. + +## Usage + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-sql@ + with: + path: ./sql +``` + +### Pin a specific SQLFluff version + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-sql@ + with: + path: ./sql + sqlfluff-version: '2.3.0' + python-version: '3.11' +``` + +### Provide a custom config + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-sql@ + with: + path: ./sql + config: ./.sqlfluff +``` + +## Notes + +- The action always does a full `git fetch` (`fetch-depth: 0`) so SQLFluff can diff against the base branch if configured to do so. +- SQLFluff respects a `.sqlfluff` config file in the repository root automatically; the `config` input is for an **additional** override file only. + +## Dependencies + +- Python (installed via `actions/setup-python@v5`). +- `pip` (bundled with Python). diff --git a/documentation/actions/lint-terraform.md b/documentation/actions/lint-terraform.md new file mode 100644 index 0000000..cb37a20 --- /dev/null +++ b/documentation/actions/lint-terraform.md @@ -0,0 +1,51 @@ +# lint-terraform + +**Path:** `.github/actions/lint-terraform` + +Lints all Terraform files in the repository using `terraform fmt -check`. Fails the job if any file is not formatted according to canonical Terraform style. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `terraform-version` | ❌ | `latest` | Terraform version to install (e.g. `1.6.0`). | + +## Outputs + +None. + +## Usage + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-terraform@ +``` + +### Pin a specific Terraform version + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-terraform@ + with: + terraform-version: '1.6.0' +``` + +## How It Works + +1. Checks out the repository with full history. +2. Installs Terraform via `hashicorp/setup-terraform@v2`. +3. Runs `terraform fmt -check -recursive -diff` from the repository root. + - `-check` — exits non-zero if any file needs reformatting (no changes are written). + - `-recursive` — processes all subdirectories. + - `-diff` — prints a diff of the changes that *would* be made, helping developers fix issues quickly. + +## Notes + +- This action only **checks** formatting; it does not modify files. Developers must run `terraform fmt` locally and commit the result. +- Combine with `validate-terraform` for comprehensive Terraform quality gates. + +## Dependencies + +- Terraform (installed via `hashicorp/setup-terraform@v2`). + +## See Also + +- [validate-terraform](./validate-terraform.md) diff --git a/documentation/actions/lint-test-yarn.md b/documentation/actions/lint-test-yarn.md new file mode 100644 index 0000000..e1f348d --- /dev/null +++ b/documentation/actions/lint-test-yarn.md @@ -0,0 +1,89 @@ +# lint-test-yarn + +**Path:** `.github/actions/lint-test-yarn` + +Runs lint and unit tests for a Node.js project managed with Yarn. Supports optional code coverage upload, custom pre-test commands, and selective skip of lint or tests. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `node-version` | ❌ | `latest` | Node.js version to install. | +| `yarn-version` | ❌ | `latest` | Yarn version to use. | +| `branch` | ❌ | `''` | Branch name used when naming the coverage artifact. Defaults to the current branch. | +| `checkout-code` | ❌ | `yes` | Set to anything other than `yes` to skip checkout (useful when code was already checked out by a prior step). | +| `run-before-tests` | ❌ | `''` | Shell command to run before the test step (e.g. starting a local server). | +| `should-run-lint` | ❌ | `yes` | Set to anything other than `yes` to skip linting. | +| `should-run-tests` | ❌ | `yes` | Set to anything other than `yes` to skip tests. | +| `upload-coverage` | ❌ | `no` | Set to `yes` to upload the coverage directory as a workflow artifact. Requires `yarn test:coverage` to exist. | + +## Outputs + +None. + +## Usage + +### Lint and test (defaults) + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ + with: + node-version: '20' +``` + +### Lint only (skip tests) + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ + with: + node-version: '20' + should-run-tests: 'no' +``` + +### Tests with coverage upload + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ + with: + node-version: '20' + upload-coverage: 'yes' + branch: ${{ github.head_ref }} +``` + +### Skip checkout (code already checked out) + +```yaml +- uses: actions/checkout@v4 + +- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ + with: + checkout-code: 'no' + node-version: '20' +``` + +## How It Works + +1. Optionally checks out the repository. +2. Installs the requested Node.js version (with Yarn cache enabled). +3. Sets the Yarn version and runs `yarn install --immutable`. +4. Runs `yarn lint` if `should-run-lint == 'yes'`. +5. Runs an optional pre-test command. +6. Runs `yarn test --passWithNoTests` (or `yarn test:coverage --passWithNoTests` when `upload-coverage == 'yes'`). +7. If coverage upload is requested, sanitises the branch name (replaces special chars with `-`), copies the `coverage/` directory, and uploads it as an artifact named `-test-coverage`. + +## Coverage Artifact + +The artifact is named `-test-coverage` and stored for the workflow's default retention period. + +The `coverage/` directory must be produced by your test command (e.g. via Jest's `--coverage` flag in your `test:coverage` script). + +## Notes + +- `yarn install --immutable` ensures the lockfile is not modified in CI — commit your `yarn.lock`. +- If both `should-run-lint` and `should-run-tests` are not `yes`, the action exits 0 (no-op) via an explicit `exit 0` step. +- The Yarn cache is keyed via `actions/setup-node`, which uses the `yarn.lock` file. + +## Dependencies + +- Node.js (installed via `actions/setup-node@v4`). +- Yarn (assumed already available or set via `yarn set version`). diff --git a/documentation/actions/promote-ecr-image.md b/documentation/actions/promote-ecr-image.md new file mode 100644 index 0000000..122967f --- /dev/null +++ b/documentation/actions/promote-ecr-image.md @@ -0,0 +1,115 @@ +# promote-ecr-image + +**Path:** `.github/actions/promote-ecr-image` + +Promotes a Docker image from a lower environment (dev → staging, or staging → prod) within AWS ECR. Supports same-account and cross-account promotion. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `aws_account` | ✅ | — | AWS account ID of the **target** (higher) environment. | +| `ecr` | ✅ | — | ECR repository name in the target account. | +| `ecr_access_role_name` | ✅ | — | IAM role name to assume for ECR write access in the target account. | +| `ecr_tag_name` | ✅ | — | Tag prefix (e.g. `my-app`). The full tag is built as `{ecr_tag_name}-{environment}-latest`. | +| `environment` | ✅ | — | Target environment: `staging` or `prod`. | +| `aws_access_key_id` | ❌ | `''` | AWS access key ID. If omitted, assumes credentials are already configured. | +| `aws_secret_access_key` | ❌ | `''` | AWS secret access key. If omitted, assumes credentials are already configured. | +| `aws_default_region` | ❌ | `''` | AWS region. If omitted, assumes credentials are already configured. | +| `lower_aws_account` | ❌ | `''` | AWS account ID of the **source** (lower) environment. Omit if same account. | +| `lower_aws_default_region` | ❌ | `''` | AWS region of the source environment. Omit if same region. | +| `lower_ecr` | ❌ | `''` | ECR repository in the source account. Omit if same repository. | +| `lower_ecr_access_role_name` | ❌ | `''` | IAM role name for ECR access in the source account. Omit if same account. | + +## Outputs + +None. + +## Promotion Logic + +### Same-account promotion + +When `lower_aws_account` and `lower_aws_default_region` are **not** provided, the action: + +1. Resolves the lower environment tag (`{ecr_tag_name}-{lower_branch}-latest`). +2. Assumes the ECR write access role in the target account. +3. Fetches all tags associated with the source image. +4. Derives the timestamped tag from the existing tags (or generates a new one). +5. Copies the image manifest using `aws ecr put-image` to both: + - `{ecr_tag_name}-{environment}-{timestamp}` (immutable snapshot) + - `{ecr_tag_name}-{environment}-latest` (mutable pointer) + +### Cross-account promotion + +When `lower_aws_account` and `lower_aws_default_region` are provided, the action delegates to `scripts/promote_image.sh`, which: + +1. Assumes the **source** account ECR role. +2. Logs in to the source ECR and pulls the image locally. +3. Resolves the timestamped tag from the source account's image metadata. +4. Assumes the **target** account ECR role. +5. Logs in to the target ECR and pushes both the timestamped and `latest` tags. + +## Environment → Lower Branch Mapping + +| `environment` | Lower branch | +|---------------|-------------| +| `staging` | `dev` | +| `prod` | `staging` | + +## Usage + +### Promote dev → staging (same account) + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/promote-ecr-image@ + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_default_region: us-east-1 + aws_account: '123456789012' + ecr: my-app + ecr_access_role_name: ecr-write-access + ecr_tag_name: my-app + environment: staging +``` + +### Promote staging → prod (cross-account) + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/promote-ecr-image@ + with: + aws_account: '111111111111' + ecr: my-app + ecr_access_role_name: ecr-write-access + ecr_tag_name: my-app + environment: prod + lower_aws_account: '222222222222' + lower_aws_default_region: us-east-1 + lower_ecr: my-app + lower_ecr_access_role_name: ecr-write-access +``` + +## Required IAM Permissions + +The assumed role needs: + +```json +{ + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:DescribeImages" + ], + "Resource": "*" +} +``` + +Plus `sts:AssumeRole` on the calling identity. + +## Dependencies + +- AWS CLI +- Docker (cross-account promotion only) +- `jq` diff --git a/documentation/actions/test-python.md b/documentation/actions/test-python.md new file mode 100644 index 0000000..b7b9877 --- /dev/null +++ b/documentation/actions/test-python.md @@ -0,0 +1,97 @@ +# test-python + +**Path:** `.github/actions/test-python` + +Runs Python unit tests using pytest (with coverage) or tox, optionally uploading the coverage report as a workflow artifact. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `branch` | ✅ | — | Branch name used when naming the coverage artifact. | +| `python-version` | ❌ | `3.11.7` | Python version to install. | +| `checkout-code` | ❌ | `yes` | Set to anything other than `yes` to skip checkout. | +| `coverage-prefix` | ❌ | `''` | Prefix added to the coverage artifact name (avoids collisions in matrix jobs). | +| `global-index-url` | ❌ | `''` | Custom PyPI index URL (PEP 503). If provided, sets `global.index-url` in pip config. | +| `global-trusted-host` | ❌ | `''` | Trusted pip host. If provided, sets `global.trusted-host` in pip config. | +| `min-coverage` | ❌ | `0` | Minimum coverage percentage. Passed to pytest as `--cov-fail-under`. `0` disables the check. | +| `retention-days` | ❌ | `31` | Days to keep the coverage artifact. | +| `run-before-tests` | ❌ | `''` | Shell command to run before tests (e.g. start a local database). | +| `search-index` | ❌ | `''` | Custom PyPI search index URL. If provided, sets `search.index` in pip config. | +| `should-run-tests` | ❌ | `yes` | Set to anything other than `yes` to skip tests entirely. | +| `tox-version` | ❌ | `''` | Tox version to use. If provided, tox is used instead of pytest. | +| `upload-coverage` | ❌ | `yes` | Set to anything other than `yes` to skip coverage artifact upload. | + +## Outputs + +None. + +## Usage + +### Basic pytest run + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ + with: + branch: ${{ github.head_ref || github.ref_name }} +``` + +### With minimum coverage gate + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ + with: + branch: ${{ github.head_ref || github.ref_name }} + min-coverage: 80 +``` + +### With tox + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ + with: + branch: ${{ github.head_ref || github.ref_name }} + tox-version: '4.11.3' + python-version: '3.11.7' +``` + +### Custom PyPI index (private registry) + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ + with: + branch: ${{ github.head_ref || github.ref_name }} + global-index-url: https://private.pypi.example.com/simple + global-trusted-host: private.pypi.example.com +``` + +## How It Works + +1. Optionally checks out the repository. +2. Sets up Python with pip caching keyed on `setup.cfg`, `setup.py`, `requirements-dev.txt`, and `requirements-test.txt`. +3. Configures pip (custom index URL / trusted host / search index) via `scripts/configure_pip.sh`. +4. Upgrades pip. +5. Installs dependencies: + - **tox mode:** `pip install tox=={version}` + - **pytest mode:** installs `requirements-test.txt` (falls back to `requirements-dev.txt`) +6. Runs an optional pre-test command. +7. Runs tests: + - **tox mode:** `tox run -e coverage-py{major}{minor}` + - **pytest mode:** `pytest --cov --cov-report html -n auto [--cov-fail-under=N]` +8. Uploads the `coverage/` directory as a workflow artifact named `{prefix}{branch}-test-coverage`. + +## Coverage Artifact + +- **Name pattern:** `{coverage-prefix}{sanitised-branch}-test-coverage` +- Branch names have `":<>|*?\\/` replaced with `-` to create a valid artifact name. +- The artifact contains the `htmlcov/` output from pytest-cov. + +## Notes + +- `should-run-tests != 'yes'` causes the action to exit 0 immediately (skips all steps). +- The `-n auto` flag requires `pytest-xdist` in your test requirements. + +## Dependencies + +- Python (installed via `actions/setup-python@v5`). +- `pytest`, `pytest-cov`, `pytest-xdist` (or tox) in your test requirements file. diff --git a/documentation/actions/update-aws-ecs.md b/documentation/actions/update-aws-ecs.md new file mode 100644 index 0000000..da82707 --- /dev/null +++ b/documentation/actions/update-aws-ecs.md @@ -0,0 +1,77 @@ +# update-aws-ecs + +**Path:** `.github/actions/update-aws-ecs` + +Forces a new deployment of an Amazon ECS service, causing ECS to pull the latest task definition and container image. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `assume_ecs_access_role_arn` | ✅ | — | Full ARN of the IAM role to assume for ECS access. | +| `cluster` | ✅ | — | ECS cluster name. | +| `service` | ✅ | — | ECS service name. | +| `aws_access_key_id` | ❌ | `''` | AWS access key ID. If omitted, assumes credentials are already configured. | +| `aws_secret_access_key` | ❌ | `''` | AWS secret access key. If omitted, assumes credentials are already configured. | +| `aws_default_region` | ❌ | `''` | AWS region. If omitted, assumes credentials are already configured. | + +## Outputs + +None. + +## Usage + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-ecs@ + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_default_region: us-east-1 + assume_ecs_access_role_arn: arn:aws:iam::123456789012:role/ecs-deploy-access + cluster: production-cluster + service: api-service +``` + +### Using pre-configured credentials (OIDC) + +```yaml +- uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/github-actions + aws-region: us-east-1 + +- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-ecs@ + with: + assume_ecs_access_role_arn: arn:aws:iam::123456789012:role/ecs-deploy-access + cluster: production-cluster + service: api-service +``` + +## How It Works + +1. Optionally configures AWS credentials (if `aws_access_key_id`, `aws_secret_access_key`, and `aws_default_region` are all provided). +2. Runs `scripts/update_ecs.sh`: + - Clears any existing AWS credential env vars (`aws_unset.sh`). + - Assumes the specified ECS access role via `sts:AssumeRole`. + - Calls `aws ecs update-service --force-new-deployment` for the given cluster and service. + - Exits 1 if the response is empty (indicating failure). + - Clears credentials again on exit. + +## Required IAM Permissions + +The assumed role (`assume_ecs_access_role_arn`) needs: + +```json +{ + "Effect": "Allow", + "Action": ["ecs:UpdateService"], + "Resource": "arn:aws:ecs:{region}:{account}:service/{cluster}/{service}" +} +``` + +The calling identity also needs `sts:AssumeRole` on the target role. + +## Dependencies + +- AWS CLI +- `jq` diff --git a/documentation/actions/update-aws-lambda.md b/documentation/actions/update-aws-lambda.md new file mode 100644 index 0000000..ff13175 --- /dev/null +++ b/documentation/actions/update-aws-lambda.md @@ -0,0 +1,82 @@ +# update-aws-lambda + +**Path:** `.github/actions/update-aws-lambda` + +Updates an AWS Lambda function's container image URI, triggering a redeployment with the new image. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `assume_lambda_update_role_arn` | ✅ | — | Full ARN of the IAM role to assume for Lambda update permissions. | +| `function_name` | ✅ | — | Name of the Lambda function to update. | +| `image_url` | ✅ | — | Full ECR image URI including tag (e.g. `123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest`). | +| `aws_access_key_id` | ❌ | `''` | AWS access key ID. If omitted, assumes credentials are already configured. | +| `aws_secret_access_key` | ❌ | `''` | AWS secret access key. If omitted, assumes credentials are already configured. | +| `aws_default_region` | ❌ | `''` | AWS region. If omitted, assumes credentials are already configured. | + +## Outputs + +None. + +## Usage + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-lambda@ + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_default_region: us-east-1 + assume_lambda_update_role_arn: arn:aws:iam::123456789012:role/lambda-deploy + function_name: my-payment-processor + image_url: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest +``` + +### Using pre-configured credentials (OIDC) + +```yaml +- uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/github-actions + aws-region: us-east-1 + +- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-lambda@ + with: + assume_lambda_update_role_arn: arn:aws:iam::123456789012:role/lambda-deploy + function_name: my-payment-processor + image_url: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest +``` + +## How It Works + +1. Optionally configures AWS credentials (if all three credential inputs are provided). +2. Runs `scripts/update_lambda.sh`: + - Clears any existing AWS credential env vars (`aws_unset.sh`). + - Assumes the Lambda update role via `sts:AssumeRole`. + - Calls `aws lambda update-function-code --function-name {name} --image-uri {url}`. + - Exits 1 if the response is empty. + - Clears credentials on exit. + +## Required IAM Permissions + +The assumed role (`assume_lambda_update_role_arn`) needs: + +```json +{ + "Effect": "Allow", + "Action": ["lambda:UpdateFunctionCode"], + "Resource": "arn:aws:lambda:{region}:{account}:function:{function-name}" +} +``` + +The calling identity also needs `sts:AssumeRole` on the target role. + +## Notes + +- This action updates the **code image** only. To update environment variables, memory, timeout, or other configuration, use a separate step with the AWS CLI or CDK/Terraform. +- Lambda may take a few seconds to propagate the update; add a wait step after this action if subsequent steps depend on the new version being live. + +## Dependencies + +- AWS CLI +- `jq` diff --git a/documentation/actions/validate-terraform.md b/documentation/actions/validate-terraform.md new file mode 100644 index 0000000..0eb178f --- /dev/null +++ b/documentation/actions/validate-terraform.md @@ -0,0 +1,71 @@ +# validate-terraform + +**Path:** `.github/actions/validate-terraform` + +Validates Terraform configuration files using `terraform validate`. Runs `terraform init` then `terraform validate` across one or more directory paths. + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `terraform-version` | ❌ | `latest` | Terraform version to install. | +| `paths` | ❌ | `./` | Newline-separated list of paths to validate. Each path must end with `/` (e.g. `infra/` or `infra/modules/vpc/`). | + +## Outputs + +None. + +## Usage + +### Validate the root module + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/validate-terraform@ +``` + +### Validate multiple modules + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/validate-terraform@ + with: + paths: | + infra/ + infra/modules/vpc/ + infra/modules/rds/ +``` + +### Pin a Terraform version + +```yaml +- uses: generalui/github-workflow-accelerators/.github/actions/validate-terraform@ + with: + terraform-version: '1.6.0' + paths: infra/ +``` + +## How It Works + +1. Checks out the repository with full history. +2. Installs Terraform via `hashicorp/setup-terraform@v2`. +3. For each path in the `paths` input: + - `cd` into the directory. + - Runs `terraform init`. +4. For each path again: + - `cd` into the directory. + - Runs `terraform validate`. + +The action exits with a non-zero code if any `init` or `validate` step fails. + +## Notes + +- `terraform init` downloads providers and modules — ensure your provider configurations are correct for CI (no backend authentication required for `validate`; use `terraform init -backend=false` patterns in your `.tf` files if needed). +- This action validates **syntax and internal consistency** only. It does not plan or apply changes, and does not require cloud credentials for the validation step itself. +- Combine with `lint-terraform` for a complete Terraform quality gate. + +## Dependencies + +- Terraform (installed via `hashicorp/setup-terraform@v2`). + +## See Also + +- [lint-terraform](./lint-terraform.md) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1db3720 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,78 @@ +# Tests + +Unit tests for the shell scripts that power the GitHub Actions in this repository. + +## Framework + +Tests are written using [bats-core](https://github.com/bats-core/bats-core) — the Bash Automated Testing System. + +## Structure + +``` +tests/ +├── unit/ +│ ├── options_helpers.bats # Tests for the shared options_helpers.sh utility +│ ├── aws_unset.bats # Tests for the shared aws_unset.sh utility +│ ├── configure_pip.bats # Tests for test-python/scripts/configure_pip.sh +│ ├── promote_image.bats # Tests for promote-ecr-image/scripts/promote_image.sh +│ ├── update_ecs.bats # Tests for update-aws-ecs/scripts/update_ecs.sh +│ └── update_lambda.bats # Tests for update-aws-lambda/scripts/update_lambda.sh +└── helpers/ + └── mock_helpers.bash # Shared mock creation and assertion utilities +``` + +## What Is Tested + +| Script | Tests | +|--------|-------| +| `options_helpers.sh` | `has_argument()` and `extract_argument()` parsing logic | +| `aws_unset.sh` | All four AWS credential env vars are cleared | +| `configure_pip.sh` | Correct `pip config set` calls for each env var; no-op when unset | +| `promote_image.sh` | Env var validation (exits 1 for each missing required var) | +| `update_ecs.sh` | AWS CLI invocation, `--force-new-deployment`, empty-response failure | +| `update_lambda.sh` | AWS CLI invocation, function name + image URL propagation, failure | + +### What Is NOT Tested Here + +- **Composite action YAML** — action `.yml` files use GitHub Actions expression syntax (`${{ inputs.xxx }}`) that cannot run outside of a GitHub Actions runner. Use the integration test workflow (`.github/workflows/test-shell-scripts.yml`) for those. +- **Live AWS calls** — tests that require actual AWS credentials are integration tests and must run in a real CI environment with OIDC or stored secrets. + +## Running Locally + +### Install bats + +```bash +# via npm (recommended) +npm install -g bats + +# via Homebrew +brew install bats-core +``` + +### Run all tests + +```bash +# From the repo root +bats tests/unit/ +``` + +### Run a single test file + +```bash +bats tests/unit/options_helpers.bats +``` + +### Run tests with verbose output + +```bash +bats --verbose-run tests/unit/ +``` + +## Writing New Tests + +1. Create `tests/unit/.bats` +2. Set `REPO_ROOT` using `BATS_TEST_DIRNAME` so paths are always absolute +3. Mock external commands (aws, docker, pip) using `MOCK_DIR` in PATH +4. Use `run bash -c "..."` for tests that expect `exit 1` from the script under test + +See existing test files and `tests/helpers/mock_helpers.bash` for patterns. diff --git a/tests/helpers/mock_helpers.bash b/tests/helpers/mock_helpers.bash new file mode 100644 index 0000000..3f431fa --- /dev/null +++ b/tests/helpers/mock_helpers.bash @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# ============================================================================= +# mock_helpers.bash +# Shared helper utilities for bats unit tests. +# Provides mock command creation and assertion utilities. +# ============================================================================= + +# Set up a temporary directory and create mock executables for the given +# commands. Each mock records every call (arguments) to +# "$MOCK_DIR/_calls.log" and exits 0 by default. +# +# Usage: +# source tests/helpers/mock_helpers.bash +# setup_mocks pip tput aws docker +# +# After setup_mocks, $MOCK_DIR is in PATH, so the fakes shadow real binaries. +setup_mocks() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + for cmd in "$@"; do + # Write the mock script + cat > "$MOCK_DIR/$cmd" << 'MOCK_SCRIPT' +#!/usr/bin/env bash +# Record the call +echo "$@" >> "${MOCK_DIR}/${0##*/}_calls.log" +exit 0 +MOCK_SCRIPT + + # Substitute the actual command name (heredoc can't expand $cmd) + sed -i.bak "s|\${0##\*/}|${cmd}|g" "$MOCK_DIR/$cmd" + rm -f "$MOCK_DIR/$cmd.bak" + chmod +x "$MOCK_DIR/$cmd" + done + + # Prepend mock dir to PATH so mocks shadow real binaries + export PATH="$MOCK_DIR:$PATH" +} + +# Remove the mock directory created by setup_mocks. +teardown_mocks() { + if [[ -n "${MOCK_DIR:-}" && -d "$MOCK_DIR" ]]; then + rm -rf "$MOCK_DIR" + fi +} + +# Assert that a mock was called with the given argument string. +# +# Usage: +# assert_mock_called_with "pip" "config set global.index-url https://example.com" +assert_mock_called_with() { + local cmd="$1" + local expected_args="$2" + local log_file="$MOCK_DIR/${cmd}_calls.log" + + if [[ ! -f "$log_file" ]]; then + echo "FAIL: mock '$cmd' was never called (no call log found)" >&2 + return 1 + fi + + if ! grep -qF "$expected_args" "$log_file"; then + echo "FAIL: mock '$cmd' was not called with args: $expected_args" >&2 + echo "Actual calls recorded in $log_file:" >&2 + cat "$log_file" >&2 + return 1 + fi + + return 0 +} + +# Assert that a mock was NOT called at all. +# +# Usage: +# assert_mock_not_called "pip" +assert_mock_not_called() { + local cmd="$1" + local log_file="$MOCK_DIR/${cmd}_calls.log" + + if [[ -f "$log_file" ]]; then + echo "FAIL: mock '$cmd' was called but should not have been" >&2 + echo "Calls recorded:" >&2 + cat "$log_file" >&2 + return 1 + fi + + return 0 +} + +# Return the call count for a given mock command. +mock_call_count() { + local cmd="$1" + local log_file="$MOCK_DIR/${cmd}_calls.log" + if [[ -f "$log_file" ]]; then + wc -l < "$log_file" | tr -d ' ' + else + echo "0" + fi +} diff --git a/tests/unit/aws_unset.bats b/tests/unit/aws_unset.bats new file mode 100644 index 0000000..c1053da --- /dev/null +++ b/tests/unit/aws_unset.bats @@ -0,0 +1,87 @@ +#!/usr/bin/env bats +# ============================================================================= +# aws_unset.bats +# Unit tests for the aws_unset.sh helper script. +# +# This script is duplicated across three actions — all copies are identical. +# We test the promote-ecr-image version as the canonical copy. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +AWS_UNSET_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/aws_unset.sh" + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@test "aws_unset: unsets AWS_ACCESS_KEY_ID" { + export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_ACCESS_KEY_ID:-}" ] +} + +@test "aws_unset: unsets AWS_SECRET_ACCESS_KEY" { + export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] +} + +@test "aws_unset: unsets AWS_SESSION_TOKEN" { + export AWS_SESSION_TOKEN="AQoDYXdzEJr//example//session//token" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_SESSION_TOKEN:-}" ] +} + +@test "aws_unset: unsets AWS_DEFAULT_REGION" { + export AWS_DEFAULT_REGION="us-east-1" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_DEFAULT_REGION:-}" ] +} + +@test "aws_unset: is a no-op when vars are already unset" { + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_DEFAULT_REGION + # Should not error + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_ACCESS_KEY_ID:-}" ] + [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] + [ -z "${AWS_SESSION_TOKEN:-}" ] + [ -z "${AWS_DEFAULT_REGION:-}" ] +} + +@test "aws_unset: clears all four vars in one pass" { + export AWS_ACCESS_KEY_ID="key" + export AWS_SECRET_ACCESS_KEY="secret" + export AWS_SESSION_TOKEN="token" + export AWS_DEFAULT_REGION="eu-west-1" + + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + + [ -z "${AWS_ACCESS_KEY_ID:-}" ] + [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] + [ -z "${AWS_SESSION_TOKEN:-}" ] + [ -z "${AWS_DEFAULT_REGION:-}" ] +} + +@test "aws_unset: identical copies across actions produce the same result" { + # Verify all three copies of aws_unset.sh produce equivalent results. + local scripts=( + "$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/aws_unset.sh" + "$REPO_ROOT/.github/actions/update-aws-ecs/scripts/general/aws_unset.sh" + "$REPO_ROOT/.github/actions/update-aws-lambda/scripts/general/aws_unset.sh" + ) + + for script in "${scripts[@]}"; do + export AWS_ACCESS_KEY_ID="key" + export AWS_SESSION_TOKEN="token" + # shellcheck source=/dev/null + source "$script" + [ -z "${AWS_ACCESS_KEY_ID:-}" ] + [ -z "${AWS_SESSION_TOKEN:-}" ] + done +} diff --git a/tests/unit/configure_pip.bats b/tests/unit/configure_pip.bats new file mode 100644 index 0000000..ccf649a --- /dev/null +++ b/tests/unit/configure_pip.bats @@ -0,0 +1,186 @@ +#!/usr/bin/env bats +# ============================================================================= +# configure_pip.bats +# Unit tests for test-python/scripts/configure_pip.sh +# +# Strategy: replace `pip` and `tput` with lightweight mocks that record +# every call, then assert that configure_pip() invokes pip with the right +# arguments based on which environment variables are set. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +CONFIGURE_PIP_SCRIPT="$REPO_ROOT/.github/actions/test-python/scripts/configure_pip.sh" + +# --------------------------------------------------------------------------- +# Setup / Teardown +# --------------------------------------------------------------------------- + +setup() { + # Create a temp dir for mock binaries + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock pip: records args to a log file, exits 0 + cat > "$MOCK_DIR/pip" << 'EOF' +#!/usr/bin/env bash +echo "$@" >> "$MOCK_DIR/pip_calls.log" +exit 0 +EOF + chmod +x "$MOCK_DIR/pip" + # Inject the MOCK_DIR variable into the mock at runtime + sed -i.bak 's|\$MOCK_DIR|'"$MOCK_DIR"'|g' "$MOCK_DIR/pip" + rm -f "$MOCK_DIR/pip.bak" + + # Mock tput: silently succeed (avoids "no terminal" errors in CI) + cat > "$MOCK_DIR/tput" << 'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir to PATH + export PATH="$MOCK_DIR:$PATH" + + # Clear all pip-related env vars before each test + unset GLOBAL_INDEX_URL GLOBAL_TRUSTED_HOST SEARCH_URL + + # Source the script — this defines configure_pip() and immediately calls it + # with no args (since $@ is empty in the test context). Because all env vars + # are unset, the initial call is a no-op. + # shellcheck source=/dev/null + source "$CONFIGURE_PIP_SCRIPT" + + # Remove any pip calls logged during the initial source-time invocation + rm -f "$MOCK_DIR/pip_calls.log" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset GLOBAL_INDEX_URL GLOBAL_TRUSTED_HOST SEARCH_URL +} + +# Helper: assert pip was called with a given argument string +assert_pip_called_with() { + local expected="$1" + if ! grep -qF "$expected" "$MOCK_DIR/pip_calls.log" 2>/dev/null; then + echo "FAIL: pip was not called with: $expected" >&2 + echo "Actual pip calls:" >&2 + cat "$MOCK_DIR/pip_calls.log" 2>/dev/null || echo "(no calls)" >&2 + return 1 + fi +} + +# Helper: assert pip was never called +assert_pip_not_called() { + if [[ -f "$MOCK_DIR/pip_calls.log" ]]; then + echo "FAIL: pip was called but should not have been" >&2 + cat "$MOCK_DIR/pip_calls.log" >&2 + return 1 + fi +} + +# --------------------------------------------------------------------------- +# No-op tests (no env vars set) +# --------------------------------------------------------------------------- + +@test "configure_pip: does nothing when no env vars are set" { + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# GLOBAL_INDEX_URL +# --------------------------------------------------------------------------- + +@test "configure_pip: sets global.index-url when GLOBAL_INDEX_URL is set" { + export GLOBAL_INDEX_URL="https://pypi.example.com/simple" + configure_pip + assert_pip_called_with "config set global.index-url https://pypi.example.com/simple" +} + +@test "configure_pip: does NOT set global.index-url when GLOBAL_INDEX_URL is empty" { + export GLOBAL_INDEX_URL="" + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# GLOBAL_TRUSTED_HOST +# --------------------------------------------------------------------------- + +@test "configure_pip: sets global.trusted-host when GLOBAL_TRUSTED_HOST is set" { + export GLOBAL_TRUSTED_HOST="pypi.example.com" + configure_pip + assert_pip_called_with "config set global.trusted-host pypi.example.com" +} + +@test "configure_pip: does NOT set global.trusted-host when GLOBAL_TRUSTED_HOST is empty" { + export GLOBAL_TRUSTED_HOST="" + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# SEARCH_URL +# --------------------------------------------------------------------------- + +@test "configure_pip: sets search.index when SEARCH_URL is set" { + export SEARCH_URL="https://pypi.example.com/pypi" + configure_pip + assert_pip_called_with "config set search.index https://pypi.example.com/pypi" +} + +@test "configure_pip: does NOT set search.index when SEARCH_URL is empty" { + export SEARCH_URL="" + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# Combined vars +# --------------------------------------------------------------------------- + +@test "configure_pip: sets all three when all env vars are provided" { + export GLOBAL_INDEX_URL="https://pypi.example.com/simple" + export GLOBAL_TRUSTED_HOST="pypi.example.com" + export SEARCH_URL="https://pypi.example.com/pypi" + + configure_pip + + assert_pip_called_with "config set global.index-url https://pypi.example.com/simple" + assert_pip_called_with "config set global.trusted-host pypi.example.com" + assert_pip_called_with "config set search.index https://pypi.example.com/pypi" +} + +@test "configure_pip: sets only the provided vars when only two are given" { + export GLOBAL_INDEX_URL="https://pypi.example.com/simple" + export SEARCH_URL="https://pypi.example.com/pypi" + # GLOBAL_TRUSTED_HOST intentionally NOT set + + configure_pip + + assert_pip_called_with "config set global.index-url https://pypi.example.com/simple" + assert_pip_called_with "config set search.index https://pypi.example.com/pypi" + + # trusted-host should NOT have been set + if grep -qF "global.trusted-host" "$MOCK_DIR/pip_calls.log" 2>/dev/null; then + echo "FAIL: global.trusted-host was set but should not have been" >&2 + return 1 + fi +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "configure_pip: --help exits 0 and does not call pip" { + run bash -c " + export PATH='$MOCK_DIR:\$PATH' + export MOCK_DIR='$MOCK_DIR' + source '$CONFIGURE_PIP_SCRIPT' + configure_pip --help + " + [ "$status" -eq 0 ] + # pip should not have been called + [ ! -f "$MOCK_DIR/pip_calls.log" ] || ! grep -q "config set" "$MOCK_DIR/pip_calls.log" +} diff --git a/tests/unit/options_helpers.bats b/tests/unit/options_helpers.bats new file mode 100644 index 0000000..d253e97 --- /dev/null +++ b/tests/unit/options_helpers.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# ============================================================================= +# options_helpers.bats +# Unit tests for the options_helpers.sh utility functions. +# +# These helpers are shared across multiple actions: +# - promote-ecr-image/scripts/general/options_helpers.sh +# - update-aws-ecs/scripts/general/options_helpers.sh +# - update-aws-lambda/scripts/general/options_helpers.sh +# +# All three copies are functionally identical; we test one authoritative copy. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +HELPERS_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/options_helpers.sh" + +setup() { + # shellcheck source=/dev/null + source "$HELPERS_SCRIPT" +} + +# --------------------------------------------------------------------------- +# has_argument +# --------------------------------------------------------------------------- + +@test "has_argument: returns true for --flag=value format" { + has_argument "--flag=value" +} + +@test "has_argument: returns true for --flag=value with extra args" { + has_argument "--flag=value" "--other" +} + +@test "has_argument: returns false for --flag= (empty value after =)" { + ! has_argument "--flag=" +} + +@test "has_argument: returns true when second arg is a plain value" { + has_argument "--flag" "myvalue" +} + +@test "has_argument: returns false when second arg starts with a dash" { + ! has_argument "--flag" "--other-flag" +} + +@test "has_argument: returns false when no second arg and first has no =" { + ! has_argument "--flag" +} + +@test "has_argument: returns false with only a bare flag and empty second arg" { + ! has_argument "--flag" "" +} + +@test "has_argument: returns true for single-char -r=value" { + has_argument "-r=arn:aws:iam::123:role/my-role" +} + +@test "has_argument: returns true for -r followed by a value" { + has_argument "-r" "arn:aws:iam::123:role/my-role" +} + +# --------------------------------------------------------------------------- +# extract_argument +# --------------------------------------------------------------------------- + +@test "extract_argument: extracts value after = in first arg" { + result=$(extract_argument "--flag=myvalue") + [ "$result" = "myvalue" ] +} + +@test "extract_argument: returns second arg when both formats provided (second wins)" { + result=$(extract_argument "--flag=fromflag" "fromsecond") + [ "$result" = "fromsecond" ] +} + +@test "extract_argument: returns second arg when only second arg given" { + result=$(extract_argument "--flag" "onlysecond") + [ "$result" = "onlysecond" ] +} + +@test "extract_argument: handles ARN values with colons and slashes" { + result=$(extract_argument "--roleArn=arn:aws:iam::123456789012:role/my-role") + [ "$result" = "arn:aws:iam::123456789012:role/my-role" ] +} + +@test "extract_argument: handles ARN value as second argument" { + result=$(extract_argument "--roleArn" "arn:aws:iam::123456789012:role/my-role") + [ "$result" = "arn:aws:iam::123456789012:role/my-role" ] +} + +@test "extract_argument: handles value with spaces when quoted" { + result=$(extract_argument "--desc" "a value with spaces") + [ "$result" = "a value with spaces" ] +} diff --git a/tests/unit/promote_image.bats b/tests/unit/promote_image.bats new file mode 100644 index 0000000..b6920f0 --- /dev/null +++ b/tests/unit/promote_image.bats @@ -0,0 +1,255 @@ +#!/usr/bin/env bats +# ============================================================================= +# promote_image.bats +# Unit tests for promote-ecr-image/scripts/promote_image.sh +# +# Strategy: test the env var validation logic and --help behaviour. +# The core AWS+Docker operations require live credentials and are integration +# tests; those are NOT covered here. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +PROMOTE_IMAGE_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/promote_image.sh" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# All required env vars for the promote_image function +_set_all_required_vars() { + export AWS_ACCOUNT="123456789012" + export AWS_DEFAULT_REGION="us-east-1" + export ENVIRONMENT="staging" + export ECR="my-ecr-repo" + export ECR_ACCESS_ROLE_NAME="ecr-write-access" + export ECR_TAG_NAME="my-app" + export LOWER_AWS_ACCOUNT="098765432109" + export LOWER_AWS_DEFAULT_REGION="us-east-1" + export LOWER_BRANCH="dev" + export LOWER_ECR="my-ecr-repo" + export LOWER_ECR_ACCESS_ROLE_NAME="ecr-write-access" +} + +_unset_all_required_vars() { + unset AWS_ACCOUNT AWS_DEFAULT_REGION ENVIRONMENT ECR ECR_ACCESS_ROLE_NAME \ + ECR_TAG_NAME LOWER_AWS_ACCOUNT LOWER_AWS_DEFAULT_REGION LOWER_BRANCH \ + LOWER_ECR LOWER_ECR_ACCESS_ROLE_NAME +} + +setup() { + _unset_all_required_vars +} + +teardown() { + _unset_all_required_vars +} + +# --------------------------------------------------------------------------- +# Required environment variable validation +# --------------------------------------------------------------------------- + +@test "promote_image: exits 1 when AWS_ACCOUNT is missing" { + run bash -c " + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when AWS_DEFAULT_REGION is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ENVIRONMENT is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ECR is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ECR_ACCESS_ROLE_NAME is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ECR_TAG_NAME is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_AWS_ACCOUNT is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_BRANCH is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_ECR is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_ECR_ACCESS_ROLE_NAME is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when no env vars are set" { + run bash -c "source '$PROMOTE_IMAGE_SCRIPT'" + [ "$status" -eq 1 ] +} + +@test "promote_image: error output mentions the missing variable name" { + run bash -c "source '$PROMOTE_IMAGE_SCRIPT'" 2>&1 + # At minimum one of the required var names should appear in stderr + [[ "$output" =~ "AWS_ACCOUNT" ]] || [[ "$output" =~ "are empty" ]] +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "promote_image: --help exits 0" { + # Pass --help via set -- so the script's bottom-level `promote_image "$@"` call + # receives it. All required env vars are set so the validation block passes first. + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + set -- --help + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 0 ] +} diff --git a/tests/unit/update_ecs.bats b/tests/unit/update_ecs.bats new file mode 100644 index 0000000..b7e5de3 --- /dev/null +++ b/tests/unit/update_ecs.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# ============================================================================= +# update_ecs.bats +# Unit tests for update-aws-ecs/scripts/update_ecs.sh +# +# Tests cover: +# - --help flag (exits 0, no AWS calls) +# - AWS CLI invocation with correct arguments (using a mock aws binary) +# - Failure when aws ecs update-service returns empty response +# +# Strategy: setup() prepends a MOCK_DIR to PATH and exports it. All run +# bash -c "..." subshells inherit this PATH automatically — do NOT override +# PATH inside the subshell, as that breaks system tools like dirname. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +UPDATE_ECS_SCRIPT="$REPO_ROOT/.github/actions/update-aws-ecs/scripts/update_ecs.sh" + +setup() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock aws: records all calls; returns real-looking JSON for known sub-commands + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"FAKEKEY","SecretAccessKey":"FAKESECRET","SessionToken":"FAKETOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +elif [[ "\$*" == *"update-service"* ]]; then + echo '{"service":{"serviceName":"test-service","clusterArn":"arn:aws:ecs:us-east-1:123:cluster/test"}}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + # Mock tput (avoids "no terminal" errors in CI) + printf '#!/bin/sh\nexit 0\n' > "$MOCK_DIR/tput" + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir — subshells inherit this PATH + export PATH="$MOCK_DIR:$PATH" + + # Required env vars (used by the script's function body) + export CLUSTER_NAME="my-cluster" + export SERVICE_NAME="my-service" + export ASSUME_ECS_ACCESS_ROLE_ARN="arn:aws:iam::123456789012:role/ecs-access" + export AWS_DEFAULT_REGION="us-east-1" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset CLUSTER_NAME SERVICE_NAME ASSUME_ECS_ACCESS_ROLE_ARN AWS_DEFAULT_REGION +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "update_ecs: --help exits 0 without running the main body" { + # Pass --help as positional arg via set -- so update_ecs "$@" receives it + run bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + set -- --help + source '$UPDATE_ECS_SCRIPT' + " + [ "$status" -eq 0 ] +} + +@test "update_ecs: --help does not call aws ecs update-service" { + run bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + set -- --help + source '$UPDATE_ECS_SCRIPT' + " + [ ! -f "$MOCK_DIR/aws_calls.log" ] || ! grep -q "update-service" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Successful execution +# --------------------------------------------------------------------------- + +@test "update_ecs: calls aws ecs update-service" { + bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "update-service" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: passes --force-new-deployment to aws ecs update-service" { + bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "force-new-deployment" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: passes CLUSTER_NAME to aws ecs update-service" { + bash -c " + export CLUSTER_NAME='production-cluster' + export SERVICE_NAME='api-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "production-cluster" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: passes SERVICE_NAME to aws ecs update-service" { + bash -c " + export CLUSTER_NAME='production-cluster' + export SERVICE_NAME='api-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "api-service" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: calls aws sts assume-role before update-service" { + bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "assume-role" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Failure: empty response from aws ecs update-service +# --------------------------------------------------------------------------- + +@test "update_ecs: exits 1 when aws ecs update-service returns empty response" { + # Override mock to return empty for update-service + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"KEY","SecretAccessKey":"SECRET","SessionToken":"TOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +fi +# Intentionally return nothing for update-service +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + [ "$status" -eq 1 ] +} diff --git a/tests/unit/update_lambda.bats b/tests/unit/update_lambda.bats new file mode 100644 index 0000000..4c8ea0c --- /dev/null +++ b/tests/unit/update_lambda.bats @@ -0,0 +1,155 @@ +#!/usr/bin/env bats +# ============================================================================= +# update_lambda.bats +# Unit tests for update-aws-lambda/scripts/update_lambda.sh +# +# Tests cover: +# - --help flag (exits 0, no AWS calls) +# - AWS CLI invocation with correct arguments (using a mock aws binary) +# - Failure when aws lambda update-function-code returns empty response +# +# Strategy: setup() prepends a MOCK_DIR to PATH and exports it. All run +# bash -c "..." subshells inherit this PATH automatically — do NOT override +# PATH inside the subshell, as that breaks system tools like dirname. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +UPDATE_LAMBDA_SCRIPT="$REPO_ROOT/.github/actions/update-aws-lambda/scripts/update_lambda.sh" + +setup() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock aws: records all calls; returns real-looking JSON for known sub-commands + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"FAKEKEY","SecretAccessKey":"FAKESECRET","SessionToken":"FAKETOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +elif [[ "\$*" == *"update-function-code"* ]]; then + echo '{"FunctionName":"my-function","FunctionArn":"arn:aws:lambda:us-east-1:123:function:my-function"}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + # Mock tput (avoids "no terminal" errors in CI) + printf '#!/bin/sh\nexit 0\n' > "$MOCK_DIR/tput" + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir — subshells inherit this PATH + export PATH="$MOCK_DIR:$PATH" + + # Required env vars + export FUNCTION_NAME="my-lambda-function" + export IMAGE_URL="123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest" + export ASSUME_LAMBDA_UPDATE_ROLE_ARN="arn:aws:iam::123456789012:role/lambda-update" + export AWS_DEFAULT_REGION="us-east-1" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset FUNCTION_NAME IMAGE_URL ASSUME_LAMBDA_UPDATE_ROLE_ARN AWS_DEFAULT_REGION +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "update_lambda: --help exits 0 without running the main body" { + # Pass --help as positional arg via set -- so update_lambda "$@" receives it + run bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:tag' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + set -- --help + source '$UPDATE_LAMBDA_SCRIPT' + " + [ "$status" -eq 0 ] +} + +@test "update_lambda: --help does not call aws lambda update-function-code" { + run bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:tag' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + set -- --help + source '$UPDATE_LAMBDA_SCRIPT' + " + [ ! -f "$MOCK_DIR/aws_calls.log" ] || ! grep -q "update-function-code" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Successful execution +# --------------------------------------------------------------------------- + +@test "update_lambda: calls aws lambda update-function-code" { + bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "update-function-code" "$MOCK_DIR/aws_calls.log" +} + +@test "update_lambda: passes FUNCTION_NAME to aws lambda update-function-code" { + bash -c " + export FUNCTION_NAME='payments-processor' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "payments-processor" "$MOCK_DIR/aws_calls.log" +} + +@test "update_lambda: passes IMAGE_URL to aws lambda update-function-code" { + bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/payments:prod-20240101000000' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "prod-20240101000000" "$MOCK_DIR/aws_calls.log" +} + +@test "update_lambda: calls aws sts assume-role before update-function-code" { + bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "assume-role" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Failure: empty response from aws lambda update-function-code +# --------------------------------------------------------------------------- + +@test "update_lambda: exits 1 when aws lambda update-function-code returns empty response" { + # Override mock to return empty for update-function-code + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"KEY","SecretAccessKey":"SECRET","SessionToken":"TOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +fi +# Intentionally return nothing for update-function-code +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + [ "$status" -eq 1 ] +} From 339bbfb07d39958ace89d4555d9240f1ad60b1b7 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Wed, 11 Mar 2026 19:20:32 -0700 Subject: [PATCH 2/5] refactor: simplify documentation folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant action docs (each action already has its own README.md) - Move LINTING.md → documentation/LINTING.md - Add documentation/TESTING.md with full testing guide - Update README.md links to point to new paths --- README.md | 6 +- LINTING.md => documentation/LINTING.md | 0 documentation/README.md | 37 ------- documentation/TESTING.md | 96 ++++++++++++++++ documentation/actions/configure-aws.md | 43 -------- documentation/actions/job-info.md | 71 ------------ documentation/actions/lint-sql.md | 55 ---------- documentation/actions/lint-terraform.md | 51 --------- documentation/actions/lint-test-yarn.md | 89 --------------- documentation/actions/promote-ecr-image.md | 115 -------------------- documentation/actions/test-python.md | 97 ----------------- documentation/actions/update-aws-ecs.md | 77 ------------- documentation/actions/update-aws-lambda.md | 82 -------------- documentation/actions/validate-terraform.md | 71 ------------ 14 files changed, 101 insertions(+), 789 deletions(-) rename LINTING.md => documentation/LINTING.md (100%) delete mode 100644 documentation/README.md create mode 100644 documentation/TESTING.md delete mode 100644 documentation/actions/configure-aws.md delete mode 100644 documentation/actions/job-info.md delete mode 100644 documentation/actions/lint-sql.md delete mode 100644 documentation/actions/lint-terraform.md delete mode 100644 documentation/actions/lint-test-yarn.md delete mode 100644 documentation/actions/promote-ecr-image.md delete mode 100644 documentation/actions/test-python.md delete mode 100644 documentation/actions/update-aws-ecs.md delete mode 100644 documentation/actions/update-aws-lambda.md delete mode 100644 documentation/actions/validate-terraform.md diff --git a/README.md b/README.md index a01a8b7..64a31d2 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,8 @@ This repo hold various accelerators for Github workflows (Actions) as well as re ### Linting and Formatting -See [LINTING.md](./LINTING.md) for more information. +See [documentation/LINTING.md](./documentation/LINTING.md) for more information. + +### Testing + +See [documentation/TESTING.md](./documentation/TESTING.md) for more information. diff --git a/LINTING.md b/documentation/LINTING.md similarity index 100% rename from LINTING.md rename to documentation/LINTING.md diff --git a/documentation/README.md b/documentation/README.md deleted file mode 100644 index 93d226e..0000000 --- a/documentation/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Documentation - -Comprehensive reference documentation for all GitHub Actions in this repository. - -## Actions - -| Action | Description | -|--------|-------------| -| [configure-aws](./actions/configure-aws.md) | Configure AWS credentials on the runner | -| [job-info](./actions/job-info.md) | Derive branch, tag, PR branch, and environment from the GitHub context | -| [lint-sql](./actions/lint-sql.md) | Lint SQL files with SQLFluff | -| [lint-terraform](./actions/lint-terraform.md) | Lint Terraform with `terraform fmt` | -| [lint-test-yarn](./actions/lint-test-yarn.md) | Lint and test a Node.js project with Yarn | -| [promote-ecr-image](./actions/promote-ecr-image.md) | Promote a Docker image between ECR environments | -| [test-python](./actions/test-python.md) | Run Python tests with pytest or tox | -| [update-aws-ecs](./actions/update-aws-ecs.md) | Force-update an ECS service | -| [update-aws-lambda](./actions/update-aws-lambda.md) | Update an AWS Lambda function's container image | -| [validate-terraform](./actions/validate-terraform.md) | Validate Terraform configuration | - -## Internal Scripts - -Each AWS-facing action bundles a set of bash helper scripts under its `scripts/general/` directory. - -| Script | Purpose | -|--------|---------| -| `options_helpers.sh` | Argument-parsing utilities (`has_argument`, `extract_argument`) | -| `aws_unset.sh` | Clears AWS credential environment variables | -| `assume_role.sh` | Calls `aws sts assume-role` and exports the credentials | -| `assume_ecr_write_access_role.sh` | Wraps `assume_role` for ECR write access | -| `assume_ecs_access_role.sh` | Wraps `assume_role` for ECS access | -| `assume_lambda_update_role.sh` | Wraps `assume_role` for Lambda update permissions | - -> **Note:** `options_helpers.sh`, `aws_unset.sh`, and `assume_role.sh` are currently duplicated across the `promote-ecr-image`, `update-aws-ecs`, and `update-aws-lambda` actions. They are functionally identical. - -## Testing - -See [../tests/README.md](../tests/README.md) for information on running the unit test suite. diff --git a/documentation/TESTING.md b/documentation/TESTING.md new file mode 100644 index 0000000..7049a95 --- /dev/null +++ b/documentation/TESTING.md @@ -0,0 +1,96 @@ +# Testing + +Unit tests for the shell scripts that power the GitHub Actions in this repository. + +## Framework + +Tests are written using [bats-core](https://github.com/bats-core/bats-core) — the Bash Automated Testing System. + +## Structure + +```text +tests/ +├── unit/ +│ ├── options_helpers.bats # Tests for the shared options_helpers.sh utility +│ ├── aws_unset.bats # Tests for the shared aws_unset.sh utility +│ ├── configure_pip.bats # Tests for test-python/scripts/configure_pip.sh +│ ├── promote_image.bats # Tests for promote-ecr-image/scripts/promote_image.sh +│ ├── update_ecs.bats # Tests for update-aws-ecs/scripts/update_ecs.sh +│ └── update_lambda.bats # Tests for update-aws-lambda/scripts/update_lambda.sh +└── helpers/ + └── mock_helpers.bash # Shared mock creation and assertion utilities +``` + +## What Is Tested + +| Script | Tests | What's covered | +|--------|-------|----------------| +| `options_helpers.sh` | 15 | `has_argument()` and `extract_argument()` parsing logic | +| `aws_unset.sh` | 7 | All 4 AWS credential env vars are cleared; no-op when already unset | +| `configure_pip.sh` | 10 | Correct `pip config set` calls per env var; no-op when unset; `--help` | +| `promote_image.sh` | 13 | Every required env var validation (exits 1 for each missing var); `--help` | +| `update_ecs.sh` | 8 | `--help`, `aws ecs update-service` invocation, `--force-new-deployment`, failure path | +| `update_lambda.sh` | 7 | `--help`, `aws lambda update-function-code` invocation, failure path | + +### What Is NOT Tested Here + +- **Composite action YAML** — action `.yml` files use GitHub Actions expression syntax + (`${{ inputs.xxx }}`) that cannot run outside of a GitHub Actions runner. +- **Live AWS calls** — tests that require actual AWS credentials are integration tests + and must run in a real CI environment with OIDC or stored secrets. + +## Mocking Strategy + +External commands (`aws`, `pip`, `tput`) are replaced with lightweight mock binaries +that record every invocation to a log file (`$MOCK_DIR/_calls.log`). +Tests assert the correct arguments were passed without hitting real cloud APIs. + +`tests/helpers/mock_helpers.bash` provides shared utilities for creating mocks and +making assertions against them. + +## Running Locally + +### Install bats + +```sh +# via npm (recommended — matches the CI install) +npm install -g bats + +# via Homebrew +brew install bats-core +``` + +### Run all tests + +```sh +bats tests/unit/ +``` + +### Run a single test file + +```sh +bats tests/unit/options_helpers.bats +``` + +### Run with verbose output + +```sh +bats --verbose-run tests/unit/ +``` + +## CI + +The workflow `.github/workflows/test-shell-scripts.yml` runs the full suite automatically +on every push or pull request that touches `tests/` or any `.sh` file under `.github/actions/`. + +## Writing New Tests + +1. Create `tests/unit/.bats`. +2. Set `REPO_ROOT` using `BATS_TEST_DIRNAME` so paths are always absolute. +3. In `setup()`, create a `MOCK_DIR`, add mocks for any external commands, and prepend + `$MOCK_DIR` to `PATH` — subshells spawned by `run bash -c "..."` inherit the PATH + automatically, so do not re-export `PATH` inside the subshell. +4. Use `run bash -c "source '...script.sh'"` for tests that need to capture a non-zero + exit code from the script under test. + +See existing test files for patterns. diff --git a/documentation/actions/configure-aws.md b/documentation/actions/configure-aws.md deleted file mode 100644 index 1637cfa..0000000 --- a/documentation/actions/configure-aws.md +++ /dev/null @@ -1,43 +0,0 @@ -# configure-aws - -**Path:** `.github/actions/configure-aws` - -Configures AWS credentials on the GitHub Actions runner by writing them into the default AWS CLI profile, then schedules a post-run cleanup step that scrubs those credentials after the job finishes. - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| `aws_access_key_id` | ✅ | AWS access key ID | -| `aws_secret_access_key` | ✅ | AWS secret access key | -| `aws_default_region` | ✅ | AWS region (e.g. `us-east-1`) | - -## Outputs - -None. - -## How It Works - -1. Runs `aws configure set` to write the three credential values into `~/.aws/credentials` under the `default` profile. -2. Registers three **post-run** cleanup steps via `webiny/action-post-run@3.1.0` that overwrite the credential values with `"XXX"` once the job finishes — preventing credential leakage in log artifacts or subsequent steps. - -## Usage - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/configure-aws@ - with: - aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-east-1 -``` - -## Notes - -- This action is used internally by `promote-ecr-image`, `update-aws-ecs`, and `update-aws-lambda` when AWS credentials are passed as inputs. -- If you are using OIDC-based authentication (e.g. `aws-actions/configure-aws-credentials`), you do **not** need this action. -- The post-run cleanup is best-effort; if the runner is forcibly terminated, cleanup may not execute. - -## Dependencies - -- AWS CLI must be available on the runner (`ubuntu-latest` includes it by default). -- `webiny/action-post-run@3.1.0` for scheduled post-step execution. diff --git a/documentation/actions/job-info.md b/documentation/actions/job-info.md deleted file mode 100644 index f6f579b..0000000 --- a/documentation/actions/job-info.md +++ /dev/null @@ -1,71 +0,0 @@ -# job-info - -**Path:** `.github/actions/job-info` - -Derives contextual information about the current job — target branch, tag, PR branch, and deployment environment — from the GitHub Actions runtime context. Use this as an early step to get consistent environment names across all your workflows. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `default_environment` | ❌ | `''` | Fallback environment name when no branch/tag rule matches | - -## Outputs - -| Output | Description | -|--------|-------------| -| `branch` | The target branch (base branch for PRs; current branch for pushes) | -| `env_name` | The resolved deployment environment name | -| `pr_branch` | The source branch of a pull request (empty for non-PR events) | -| `tag` | The git tag that triggered the workflow, or `"none"` | - -## Environment Mapping - -### Branch → environment - -| Branch | Environment | -|--------|------------| -| `develop` | `dev` | -| `main` | `prod` | -| `qa` | `qa` | -| `sandbox` | `sandbox` | -| `staging` | `staging` | -| `test` | `test` | -| anything else | `default_environment` input | - -### Tag → environment - -| Tag pattern | Environment | -|-------------|------------| -| `*-dev` | `dev` | -| `*-qa` | `qa` | -| `*-sandbox` | `sandbox` | -| `*-staging` | `staging` | -| `*-test` | `test` | -| semver (e.g. `1.2.3`, `1.2.3-rc.1`) | `prod` | -| anything else | unchanged (branch rule applies first) | - -Tag rules take precedence over branch rules when both apply. - -## Usage - -```yaml -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - id: info - uses: generalui/github-workflow-accelerators/.github/actions/job-info@ - with: - default_environment: dev - - - name: Deploy to ${{ steps.info.outputs.env_name }} - run: echo "Deploying to ${{ steps.info.outputs.env_name }}" -``` - -## Notes - -- For **pull_request** events, `branch` is the **base** (target) branch, not the head branch. Use `pr_branch` to get the source branch. -- For **push** events on a branch, `branch` is the branch name. `pr_branch` will be empty. -- For **tag push** events, `tag` contains the tag name and `env_name` is resolved from the tag pattern table above. -- The semver pattern matches `MAJOR.MINOR.PATCH` with optional pre-release/build metadata (e.g. `1.0.0`, `2.1.0-beta.1`, `3.0.0+build.42`). diff --git a/documentation/actions/lint-sql.md b/documentation/actions/lint-sql.md deleted file mode 100644 index bd2e0ed..0000000 --- a/documentation/actions/lint-sql.md +++ /dev/null @@ -1,55 +0,0 @@ -# lint-sql - -**Path:** `.github/actions/lint-sql` - -Lints SQL files using [SQLFluff](https://docs.sqlfluff.com/). Checks out the repository, installs the requested version of SQLFluff, and runs `sqlfluff lint` against the specified path. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `path` | ✅ | — | Path to a SQL file or directory to lint. Use `.` for the entire repository. | -| `config` | ❌ | `''` | Path to an additional SQLFluff config file (`.cfg` format) that overrides the standard configuration. | -| `python-version` | ❌ | `latest` | Python version to install (passed to `actions/setup-python`). | -| `sqlfluff-version` | ❌ | `''` | SQLFluff version to install (e.g. `2.3.0`). Defaults to the latest published version. | - -## Outputs - -None. - -## Usage - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-sql@ - with: - path: ./sql -``` - -### Pin a specific SQLFluff version - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-sql@ - with: - path: ./sql - sqlfluff-version: '2.3.0' - python-version: '3.11' -``` - -### Provide a custom config - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-sql@ - with: - path: ./sql - config: ./.sqlfluff -``` - -## Notes - -- The action always does a full `git fetch` (`fetch-depth: 0`) so SQLFluff can diff against the base branch if configured to do so. -- SQLFluff respects a `.sqlfluff` config file in the repository root automatically; the `config` input is for an **additional** override file only. - -## Dependencies - -- Python (installed via `actions/setup-python@v5`). -- `pip` (bundled with Python). diff --git a/documentation/actions/lint-terraform.md b/documentation/actions/lint-terraform.md deleted file mode 100644 index cb37a20..0000000 --- a/documentation/actions/lint-terraform.md +++ /dev/null @@ -1,51 +0,0 @@ -# lint-terraform - -**Path:** `.github/actions/lint-terraform` - -Lints all Terraform files in the repository using `terraform fmt -check`. Fails the job if any file is not formatted according to canonical Terraform style. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `terraform-version` | ❌ | `latest` | Terraform version to install (e.g. `1.6.0`). | - -## Outputs - -None. - -## Usage - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-terraform@ -``` - -### Pin a specific Terraform version - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-terraform@ - with: - terraform-version: '1.6.0' -``` - -## How It Works - -1. Checks out the repository with full history. -2. Installs Terraform via `hashicorp/setup-terraform@v2`. -3. Runs `terraform fmt -check -recursive -diff` from the repository root. - - `-check` — exits non-zero if any file needs reformatting (no changes are written). - - `-recursive` — processes all subdirectories. - - `-diff` — prints a diff of the changes that *would* be made, helping developers fix issues quickly. - -## Notes - -- This action only **checks** formatting; it does not modify files. Developers must run `terraform fmt` locally and commit the result. -- Combine with `validate-terraform` for comprehensive Terraform quality gates. - -## Dependencies - -- Terraform (installed via `hashicorp/setup-terraform@v2`). - -## See Also - -- [validate-terraform](./validate-terraform.md) diff --git a/documentation/actions/lint-test-yarn.md b/documentation/actions/lint-test-yarn.md deleted file mode 100644 index e1f348d..0000000 --- a/documentation/actions/lint-test-yarn.md +++ /dev/null @@ -1,89 +0,0 @@ -# lint-test-yarn - -**Path:** `.github/actions/lint-test-yarn` - -Runs lint and unit tests for a Node.js project managed with Yarn. Supports optional code coverage upload, custom pre-test commands, and selective skip of lint or tests. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `node-version` | ❌ | `latest` | Node.js version to install. | -| `yarn-version` | ❌ | `latest` | Yarn version to use. | -| `branch` | ❌ | `''` | Branch name used when naming the coverage artifact. Defaults to the current branch. | -| `checkout-code` | ❌ | `yes` | Set to anything other than `yes` to skip checkout (useful when code was already checked out by a prior step). | -| `run-before-tests` | ❌ | `''` | Shell command to run before the test step (e.g. starting a local server). | -| `should-run-lint` | ❌ | `yes` | Set to anything other than `yes` to skip linting. | -| `should-run-tests` | ❌ | `yes` | Set to anything other than `yes` to skip tests. | -| `upload-coverage` | ❌ | `no` | Set to `yes` to upload the coverage directory as a workflow artifact. Requires `yarn test:coverage` to exist. | - -## Outputs - -None. - -## Usage - -### Lint and test (defaults) - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ - with: - node-version: '20' -``` - -### Lint only (skip tests) - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ - with: - node-version: '20' - should-run-tests: 'no' -``` - -### Tests with coverage upload - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ - with: - node-version: '20' - upload-coverage: 'yes' - branch: ${{ github.head_ref }} -``` - -### Skip checkout (code already checked out) - -```yaml -- uses: actions/checkout@v4 - -- uses: generalui/github-workflow-accelerators/.github/actions/lint-test-yarn@ - with: - checkout-code: 'no' - node-version: '20' -``` - -## How It Works - -1. Optionally checks out the repository. -2. Installs the requested Node.js version (with Yarn cache enabled). -3. Sets the Yarn version and runs `yarn install --immutable`. -4. Runs `yarn lint` if `should-run-lint == 'yes'`. -5. Runs an optional pre-test command. -6. Runs `yarn test --passWithNoTests` (or `yarn test:coverage --passWithNoTests` when `upload-coverage == 'yes'`). -7. If coverage upload is requested, sanitises the branch name (replaces special chars with `-`), copies the `coverage/` directory, and uploads it as an artifact named `-test-coverage`. - -## Coverage Artifact - -The artifact is named `-test-coverage` and stored for the workflow's default retention period. - -The `coverage/` directory must be produced by your test command (e.g. via Jest's `--coverage` flag in your `test:coverage` script). - -## Notes - -- `yarn install --immutable` ensures the lockfile is not modified in CI — commit your `yarn.lock`. -- If both `should-run-lint` and `should-run-tests` are not `yes`, the action exits 0 (no-op) via an explicit `exit 0` step. -- The Yarn cache is keyed via `actions/setup-node`, which uses the `yarn.lock` file. - -## Dependencies - -- Node.js (installed via `actions/setup-node@v4`). -- Yarn (assumed already available or set via `yarn set version`). diff --git a/documentation/actions/promote-ecr-image.md b/documentation/actions/promote-ecr-image.md deleted file mode 100644 index 122967f..0000000 --- a/documentation/actions/promote-ecr-image.md +++ /dev/null @@ -1,115 +0,0 @@ -# promote-ecr-image - -**Path:** `.github/actions/promote-ecr-image` - -Promotes a Docker image from a lower environment (dev → staging, or staging → prod) within AWS ECR. Supports same-account and cross-account promotion. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `aws_account` | ✅ | — | AWS account ID of the **target** (higher) environment. | -| `ecr` | ✅ | — | ECR repository name in the target account. | -| `ecr_access_role_name` | ✅ | — | IAM role name to assume for ECR write access in the target account. | -| `ecr_tag_name` | ✅ | — | Tag prefix (e.g. `my-app`). The full tag is built as `{ecr_tag_name}-{environment}-latest`. | -| `environment` | ✅ | — | Target environment: `staging` or `prod`. | -| `aws_access_key_id` | ❌ | `''` | AWS access key ID. If omitted, assumes credentials are already configured. | -| `aws_secret_access_key` | ❌ | `''` | AWS secret access key. If omitted, assumes credentials are already configured. | -| `aws_default_region` | ❌ | `''` | AWS region. If omitted, assumes credentials are already configured. | -| `lower_aws_account` | ❌ | `''` | AWS account ID of the **source** (lower) environment. Omit if same account. | -| `lower_aws_default_region` | ❌ | `''` | AWS region of the source environment. Omit if same region. | -| `lower_ecr` | ❌ | `''` | ECR repository in the source account. Omit if same repository. | -| `lower_ecr_access_role_name` | ❌ | `''` | IAM role name for ECR access in the source account. Omit if same account. | - -## Outputs - -None. - -## Promotion Logic - -### Same-account promotion - -When `lower_aws_account` and `lower_aws_default_region` are **not** provided, the action: - -1. Resolves the lower environment tag (`{ecr_tag_name}-{lower_branch}-latest`). -2. Assumes the ECR write access role in the target account. -3. Fetches all tags associated with the source image. -4. Derives the timestamped tag from the existing tags (or generates a new one). -5. Copies the image manifest using `aws ecr put-image` to both: - - `{ecr_tag_name}-{environment}-{timestamp}` (immutable snapshot) - - `{ecr_tag_name}-{environment}-latest` (mutable pointer) - -### Cross-account promotion - -When `lower_aws_account` and `lower_aws_default_region` are provided, the action delegates to `scripts/promote_image.sh`, which: - -1. Assumes the **source** account ECR role. -2. Logs in to the source ECR and pulls the image locally. -3. Resolves the timestamped tag from the source account's image metadata. -4. Assumes the **target** account ECR role. -5. Logs in to the target ECR and pushes both the timestamped and `latest` tags. - -## Environment → Lower Branch Mapping - -| `environment` | Lower branch | -|---------------|-------------| -| `staging` | `dev` | -| `prod` | `staging` | - -## Usage - -### Promote dev → staging (same account) - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/promote-ecr-image@ - with: - aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-east-1 - aws_account: '123456789012' - ecr: my-app - ecr_access_role_name: ecr-write-access - ecr_tag_name: my-app - environment: staging -``` - -### Promote staging → prod (cross-account) - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/promote-ecr-image@ - with: - aws_account: '111111111111' - ecr: my-app - ecr_access_role_name: ecr-write-access - ecr_tag_name: my-app - environment: prod - lower_aws_account: '222222222222' - lower_aws_default_region: us-east-1 - lower_ecr: my-app - lower_ecr_access_role_name: ecr-write-access -``` - -## Required IAM Permissions - -The assumed role needs: - -```json -{ - "Effect": "Allow", - "Action": [ - "ecr:GetAuthorizationToken", - "ecr:BatchGetImage", - "ecr:PutImage", - "ecr:DescribeImages" - ], - "Resource": "*" -} -``` - -Plus `sts:AssumeRole` on the calling identity. - -## Dependencies - -- AWS CLI -- Docker (cross-account promotion only) -- `jq` diff --git a/documentation/actions/test-python.md b/documentation/actions/test-python.md deleted file mode 100644 index b7b9877..0000000 --- a/documentation/actions/test-python.md +++ /dev/null @@ -1,97 +0,0 @@ -# test-python - -**Path:** `.github/actions/test-python` - -Runs Python unit tests using pytest (with coverage) or tox, optionally uploading the coverage report as a workflow artifact. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `branch` | ✅ | — | Branch name used when naming the coverage artifact. | -| `python-version` | ❌ | `3.11.7` | Python version to install. | -| `checkout-code` | ❌ | `yes` | Set to anything other than `yes` to skip checkout. | -| `coverage-prefix` | ❌ | `''` | Prefix added to the coverage artifact name (avoids collisions in matrix jobs). | -| `global-index-url` | ❌ | `''` | Custom PyPI index URL (PEP 503). If provided, sets `global.index-url` in pip config. | -| `global-trusted-host` | ❌ | `''` | Trusted pip host. If provided, sets `global.trusted-host` in pip config. | -| `min-coverage` | ❌ | `0` | Minimum coverage percentage. Passed to pytest as `--cov-fail-under`. `0` disables the check. | -| `retention-days` | ❌ | `31` | Days to keep the coverage artifact. | -| `run-before-tests` | ❌ | `''` | Shell command to run before tests (e.g. start a local database). | -| `search-index` | ❌ | `''` | Custom PyPI search index URL. If provided, sets `search.index` in pip config. | -| `should-run-tests` | ❌ | `yes` | Set to anything other than `yes` to skip tests entirely. | -| `tox-version` | ❌ | `''` | Tox version to use. If provided, tox is used instead of pytest. | -| `upload-coverage` | ❌ | `yes` | Set to anything other than `yes` to skip coverage artifact upload. | - -## Outputs - -None. - -## Usage - -### Basic pytest run - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ - with: - branch: ${{ github.head_ref || github.ref_name }} -``` - -### With minimum coverage gate - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ - with: - branch: ${{ github.head_ref || github.ref_name }} - min-coverage: 80 -``` - -### With tox - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ - with: - branch: ${{ github.head_ref || github.ref_name }} - tox-version: '4.11.3' - python-version: '3.11.7' -``` - -### Custom PyPI index (private registry) - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/test-python@ - with: - branch: ${{ github.head_ref || github.ref_name }} - global-index-url: https://private.pypi.example.com/simple - global-trusted-host: private.pypi.example.com -``` - -## How It Works - -1. Optionally checks out the repository. -2. Sets up Python with pip caching keyed on `setup.cfg`, `setup.py`, `requirements-dev.txt`, and `requirements-test.txt`. -3. Configures pip (custom index URL / trusted host / search index) via `scripts/configure_pip.sh`. -4. Upgrades pip. -5. Installs dependencies: - - **tox mode:** `pip install tox=={version}` - - **pytest mode:** installs `requirements-test.txt` (falls back to `requirements-dev.txt`) -6. Runs an optional pre-test command. -7. Runs tests: - - **tox mode:** `tox run -e coverage-py{major}{minor}` - - **pytest mode:** `pytest --cov --cov-report html -n auto [--cov-fail-under=N]` -8. Uploads the `coverage/` directory as a workflow artifact named `{prefix}{branch}-test-coverage`. - -## Coverage Artifact - -- **Name pattern:** `{coverage-prefix}{sanitised-branch}-test-coverage` -- Branch names have `":<>|*?\\/` replaced with `-` to create a valid artifact name. -- The artifact contains the `htmlcov/` output from pytest-cov. - -## Notes - -- `should-run-tests != 'yes'` causes the action to exit 0 immediately (skips all steps). -- The `-n auto` flag requires `pytest-xdist` in your test requirements. - -## Dependencies - -- Python (installed via `actions/setup-python@v5`). -- `pytest`, `pytest-cov`, `pytest-xdist` (or tox) in your test requirements file. diff --git a/documentation/actions/update-aws-ecs.md b/documentation/actions/update-aws-ecs.md deleted file mode 100644 index da82707..0000000 --- a/documentation/actions/update-aws-ecs.md +++ /dev/null @@ -1,77 +0,0 @@ -# update-aws-ecs - -**Path:** `.github/actions/update-aws-ecs` - -Forces a new deployment of an Amazon ECS service, causing ECS to pull the latest task definition and container image. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `assume_ecs_access_role_arn` | ✅ | — | Full ARN of the IAM role to assume for ECS access. | -| `cluster` | ✅ | — | ECS cluster name. | -| `service` | ✅ | — | ECS service name. | -| `aws_access_key_id` | ❌ | `''` | AWS access key ID. If omitted, assumes credentials are already configured. | -| `aws_secret_access_key` | ❌ | `''` | AWS secret access key. If omitted, assumes credentials are already configured. | -| `aws_default_region` | ❌ | `''` | AWS region. If omitted, assumes credentials are already configured. | - -## Outputs - -None. - -## Usage - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-ecs@ - with: - aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-east-1 - assume_ecs_access_role_arn: arn:aws:iam::123456789012:role/ecs-deploy-access - cluster: production-cluster - service: api-service -``` - -### Using pre-configured credentials (OIDC) - -```yaml -- uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::123456789012:role/github-actions - aws-region: us-east-1 - -- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-ecs@ - with: - assume_ecs_access_role_arn: arn:aws:iam::123456789012:role/ecs-deploy-access - cluster: production-cluster - service: api-service -``` - -## How It Works - -1. Optionally configures AWS credentials (if `aws_access_key_id`, `aws_secret_access_key`, and `aws_default_region` are all provided). -2. Runs `scripts/update_ecs.sh`: - - Clears any existing AWS credential env vars (`aws_unset.sh`). - - Assumes the specified ECS access role via `sts:AssumeRole`. - - Calls `aws ecs update-service --force-new-deployment` for the given cluster and service. - - Exits 1 if the response is empty (indicating failure). - - Clears credentials again on exit. - -## Required IAM Permissions - -The assumed role (`assume_ecs_access_role_arn`) needs: - -```json -{ - "Effect": "Allow", - "Action": ["ecs:UpdateService"], - "Resource": "arn:aws:ecs:{region}:{account}:service/{cluster}/{service}" -} -``` - -The calling identity also needs `sts:AssumeRole` on the target role. - -## Dependencies - -- AWS CLI -- `jq` diff --git a/documentation/actions/update-aws-lambda.md b/documentation/actions/update-aws-lambda.md deleted file mode 100644 index ff13175..0000000 --- a/documentation/actions/update-aws-lambda.md +++ /dev/null @@ -1,82 +0,0 @@ -# update-aws-lambda - -**Path:** `.github/actions/update-aws-lambda` - -Updates an AWS Lambda function's container image URI, triggering a redeployment with the new image. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `assume_lambda_update_role_arn` | ✅ | — | Full ARN of the IAM role to assume for Lambda update permissions. | -| `function_name` | ✅ | — | Name of the Lambda function to update. | -| `image_url` | ✅ | — | Full ECR image URI including tag (e.g. `123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest`). | -| `aws_access_key_id` | ❌ | `''` | AWS access key ID. If omitted, assumes credentials are already configured. | -| `aws_secret_access_key` | ❌ | `''` | AWS secret access key. If omitted, assumes credentials are already configured. | -| `aws_default_region` | ❌ | `''` | AWS region. If omitted, assumes credentials are already configured. | - -## Outputs - -None. - -## Usage - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-lambda@ - with: - aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-east-1 - assume_lambda_update_role_arn: arn:aws:iam::123456789012:role/lambda-deploy - function_name: my-payment-processor - image_url: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest -``` - -### Using pre-configured credentials (OIDC) - -```yaml -- uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::123456789012:role/github-actions - aws-region: us-east-1 - -- uses: generalui/github-workflow-accelerators/.github/actions/update-aws-lambda@ - with: - assume_lambda_update_role_arn: arn:aws:iam::123456789012:role/lambda-deploy - function_name: my-payment-processor - image_url: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest -``` - -## How It Works - -1. Optionally configures AWS credentials (if all three credential inputs are provided). -2. Runs `scripts/update_lambda.sh`: - - Clears any existing AWS credential env vars (`aws_unset.sh`). - - Assumes the Lambda update role via `sts:AssumeRole`. - - Calls `aws lambda update-function-code --function-name {name} --image-uri {url}`. - - Exits 1 if the response is empty. - - Clears credentials on exit. - -## Required IAM Permissions - -The assumed role (`assume_lambda_update_role_arn`) needs: - -```json -{ - "Effect": "Allow", - "Action": ["lambda:UpdateFunctionCode"], - "Resource": "arn:aws:lambda:{region}:{account}:function:{function-name}" -} -``` - -The calling identity also needs `sts:AssumeRole` on the target role. - -## Notes - -- This action updates the **code image** only. To update environment variables, memory, timeout, or other configuration, use a separate step with the AWS CLI or CDK/Terraform. -- Lambda may take a few seconds to propagate the update; add a wait step after this action if subsequent steps depend on the new version being live. - -## Dependencies - -- AWS CLI -- `jq` diff --git a/documentation/actions/validate-terraform.md b/documentation/actions/validate-terraform.md deleted file mode 100644 index 0eb178f..0000000 --- a/documentation/actions/validate-terraform.md +++ /dev/null @@ -1,71 +0,0 @@ -# validate-terraform - -**Path:** `.github/actions/validate-terraform` - -Validates Terraform configuration files using `terraform validate`. Runs `terraform init` then `terraform validate` across one or more directory paths. - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `terraform-version` | ❌ | `latest` | Terraform version to install. | -| `paths` | ❌ | `./` | Newline-separated list of paths to validate. Each path must end with `/` (e.g. `infra/` or `infra/modules/vpc/`). | - -## Outputs - -None. - -## Usage - -### Validate the root module - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/validate-terraform@ -``` - -### Validate multiple modules - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/validate-terraform@ - with: - paths: | - infra/ - infra/modules/vpc/ - infra/modules/rds/ -``` - -### Pin a Terraform version - -```yaml -- uses: generalui/github-workflow-accelerators/.github/actions/validate-terraform@ - with: - terraform-version: '1.6.0' - paths: infra/ -``` - -## How It Works - -1. Checks out the repository with full history. -2. Installs Terraform via `hashicorp/setup-terraform@v2`. -3. For each path in the `paths` input: - - `cd` into the directory. - - Runs `terraform init`. -4. For each path again: - - `cd` into the directory. - - Runs `terraform validate`. - -The action exits with a non-zero code if any `init` or `validate` step fails. - -## Notes - -- `terraform init` downloads providers and modules — ensure your provider configurations are correct for CI (no backend authentication required for `validate`; use `terraform init -backend=false` patterns in your `.tf` files if needed). -- This action validates **syntax and internal consistency** only. It does not plan or apply changes, and does not require cloud credentials for the validation step itself. -- Combine with `lint-terraform` for a complete Terraform quality gate. - -## Dependencies - -- Terraform (installed via `hashicorp/setup-terraform@v2`). - -## See Also - -- [lint-terraform](./lint-terraform.md) From 653093cc8678c65705e294ec6f14095b56eea805 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Wed, 11 Mar 2026 19:26:23 -0700 Subject: [PATCH 3/5] fix: resolve CI failures - tests/README.md: add language specifier to fenced code block (MD040) - tests/README.md: wrap long lines to stay within 180-char limit (MD013) - test-shell-scripts.yml: use sudo for npm install -g on Ubuntu runner (EACCES) --- .github/workflows/test-shell-scripts.yml | 2 +- tests/README.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-shell-scripts.yml b/.github/workflows/test-shell-scripts.yml index 46051b1..cb07849 100644 --- a/.github/workflows/test-shell-scripts.yml +++ b/.github/workflows/test-shell-scripts.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Install bats - run: npm install -g bats + run: sudo npm install -g bats - name: Verify bats version run: bats --version diff --git a/tests/README.md b/tests/README.md index 1db3720..e32f6b4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,7 @@ Tests are written using [bats-core](https://github.com/bats-core/bats-core) — ## Structure -``` +```text tests/ ├── unit/ │ ├── options_helpers.bats # Tests for the shared options_helpers.sh utility @@ -34,8 +34,10 @@ tests/ ### What Is NOT Tested Here -- **Composite action YAML** — action `.yml` files use GitHub Actions expression syntax (`${{ inputs.xxx }}`) that cannot run outside of a GitHub Actions runner. Use the integration test workflow (`.github/workflows/test-shell-scripts.yml`) for those. -- **Live AWS calls** — tests that require actual AWS credentials are integration tests and must run in a real CI environment with OIDC or stored secrets. +- **Composite action YAML** — action `.yml` files use GitHub Actions expression syntax + (`${{ inputs.xxx }}`) that cannot run outside of a GitHub Actions runner. +- **Live AWS calls** — tests that require actual AWS credentials are integration tests + and must run in a real CI environment with OIDC or stored secrets. ## Running Locally From 4f02cbab75e9b69d8cc8e04a67827f873d119697 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Thu, 12 Mar 2026 09:42:27 -0700 Subject: [PATCH 4/5] refactor: restructure tests per-action, consolidate CI into code-quality.yml - Move flat tests/unit/*.bats into per-action subdirectories (tests/unit//) mirroring the mono-repo structure of the actions themselves - Update BATS_TEST_DIRNAME depth from ../.. to ../../.. in all test files - Delete .github/workflows/test-shell-scripts.yml - Rewrite code-quality.yml with two jobs: - lint-markdown: runs markdownlint when .md files change (unchanged behaviour) - bats-tests: matrix job using tj-actions/changed-files to run tests only for the specific action(s) that changed, each in an isolated job - Update tests/README.md and documentation/TESTING.md to reflect new structure --- .github/workflows/code-quality.yml | 94 +++++++++++++++++-- .github/workflows/test-shell-scripts.yml | 35 ------- documentation/TESTING.md | 57 ++++++----- tests/README.md | 56 ++++++----- .../test_aws_unset.bats} | 2 +- .../test_options_helpers.bats} | 2 +- .../test_promote_image.bats} | 2 +- .../test_configure_pip.bats} | 2 +- .../test_update_ecs.bats} | 2 +- .../test_update_lambda.bats} | 2 +- 10 files changed, 156 insertions(+), 98 deletions(-) delete mode 100644 .github/workflows/test-shell-scripts.yml rename tests/unit/{aws_unset.bats => promote-ecr-image/test_aws_unset.bats} (98%) rename tests/unit/{options_helpers.bats => promote-ecr-image/test_options_helpers.bats} (98%) rename tests/unit/{promote_image.bats => promote-ecr-image/test_promote_image.bats} (99%) rename tests/unit/{configure_pip.bats => test-python/test_configure_pip.bats} (99%) rename tests/unit/{update_ecs.bats => update-aws-ecs/test_update_ecs.bats} (99%) rename tests/unit/{update_lambda.bats => update-aws-lambda/test_update_lambda.bats} (99%) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 1f5fe5b..93d7b02 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,15 +1,19 @@ -name: Github Action - Code Quality +name: Code Quality + on: pull_request: branches: [main] types: [opened, reopened, synchronize] workflow_dispatch: + jobs: - quality: - name: Quality - # The quality pipeline should not take more that 2 minutes. - timeout-minutes: 2 + detect-changes: + name: Detect changes runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + markdown-changed: ${{ steps.changed-markdown.outputs.any_modified }} + test-matrix: ${{ steps.build-matrix.outputs.matrix }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -18,15 +22,87 @@ jobs: - name: Get changed markdown files id: changed-markdown - uses: tj-actions/changed-files@v41 + uses: tj-actions/changed-files@v45 with: files: | **/*.md - .github/workflows/github-action-code-quality.yml + .github/workflows/code-quality.yml + + - name: Get changed action and test paths + id: changed-paths + uses: tj-actions/changed-files@v45 + with: + files: | + .github/actions/** + tests/** + + - name: Build bats test matrix + id: build-matrix + env: + CHANGED_FILES: ${{ steps.changed-paths.outputs.all_changed_files }} + run: | + # Actions that have bats unit tests in tests/unit// + actions_with_tests=( + "promote-ecr-image" + "test-python" + "update-aws-ecs" + "update-aws-lambda" + ) + + matrix=() + for action in "${actions_with_tests[@]}"; do + if echo "$CHANGED_FILES" | grep -qE "(\.github/actions/${action}/|tests/unit/${action}/)"; then + matrix+=("$action") + fi + done + + if [ ${#matrix[@]} -eq 0 ]; then + echo "matrix=[]" >> "$GITHUB_OUTPUT" + else + matrix_json=$(jq -c -n '$ARGS.positional' --args "${matrix[@]}") + echo "matrix=${matrix_json}" >> "$GITHUB_OUTPUT" + fi + + lint-markdown: + name: Lint Markdown + needs: detect-changes + if: needs.detect-changes.outputs.markdown-changed == 'true' + # Linting should not take more than 2 minutes. + timeout-minutes: 2 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 - - name: Lint all Documentation - if: steps.changed-markdown.outputs.any_modified == 'true' + - name: Lint all documentation uses: DavidAnson/markdownlint-cli2-action@v14 with: globs: | **/*.md + + bats-tests: + name: Test - ${{ matrix.action }} + needs: detect-changes + if: > + needs.detect-changes.outputs.test-matrix != '[]' && + needs.detect-changes.outputs.test-matrix != '' + # Each action test suite should not take more than 5 minutes. + timeout-minutes: 5 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + action: ${{ fromJSON(needs.detect-changes.outputs.test-matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install bats + run: sudo npm install -g bats + + - name: Run unit tests for ${{ matrix.action }} + run: bats --verbose-run tests/unit/${{ matrix.action }}/ + + - name: Summarise results + if: always() + run: echo "Unit tests complete for ${{ matrix.action }}." diff --git a/.github/workflows/test-shell-scripts.yml b/.github/workflows/test-shell-scripts.yml deleted file mode 100644 index cb07849..0000000 --- a/.github/workflows/test-shell-scripts.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Test Shell Scripts - -on: - push: - branches: [main] - paths: - - '.github/actions/**/*.sh' - - 'tests/**' - pull_request: - branches: [main] - paths: - - '.github/actions/**/*.sh' - - 'tests/**' - -jobs: - unit-tests: - name: bats unit tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install bats - run: sudo npm install -g bats - - - name: Verify bats version - run: bats --version - - - name: Run unit tests - run: bats --verbose-run tests/unit/ - - - name: Summarise results - if: always() - run: echo "Shell script unit tests complete." diff --git a/documentation/TESTING.md b/documentation/TESTING.md index 7049a95..e4af132 100644 --- a/documentation/TESTING.md +++ b/documentation/TESTING.md @@ -8,29 +8,36 @@ Tests are written using [bats-core](https://github.com/bats-core/bats-core) — ## Structure +Tests are organised per-action, mirroring the mono-repo structure of the actions themselves. +Each action that contains testable shell scripts has a corresponding directory under `tests/unit/`. + ```text tests/ ├── unit/ -│ ├── options_helpers.bats # Tests for the shared options_helpers.sh utility -│ ├── aws_unset.bats # Tests for the shared aws_unset.sh utility -│ ├── configure_pip.bats # Tests for test-python/scripts/configure_pip.sh -│ ├── promote_image.bats # Tests for promote-ecr-image/scripts/promote_image.sh -│ ├── update_ecs.bats # Tests for update-aws-ecs/scripts/update_ecs.sh -│ └── update_lambda.bats # Tests for update-aws-lambda/scripts/update_lambda.sh +│ ├── promote-ecr-image/ +│ │ ├── test_aws_unset.bats # Tests for the shared aws_unset.sh utility +│ │ ├── test_options_helpers.bats # Tests for the shared options_helpers.sh utility +│ │ └── test_promote_image.bats # Tests for promote_image.sh +│ ├── test-python/ +│ │ └── test_configure_pip.bats # Tests for configure_pip.sh +│ ├── update-aws-ecs/ +│ │ └── test_update_ecs.bats # Tests for update_ecs.sh +│ └── update-aws-lambda/ +│ └── test_update_lambda.bats # Tests for update_lambda.sh └── helpers/ - └── mock_helpers.bash # Shared mock creation and assertion utilities + └── mock_helpers.bash # Shared mock creation and assertion utilities ``` ## What Is Tested -| Script | Tests | What's covered | -|--------|-------|----------------| -| `options_helpers.sh` | 15 | `has_argument()` and `extract_argument()` parsing logic | -| `aws_unset.sh` | 7 | All 4 AWS credential env vars are cleared; no-op when already unset | -| `configure_pip.sh` | 10 | Correct `pip config set` calls per env var; no-op when unset; `--help` | -| `promote_image.sh` | 13 | Every required env var validation (exits 1 for each missing var); `--help` | -| `update_ecs.sh` | 8 | `--help`, `aws ecs update-service` invocation, `--force-new-deployment`, failure path | -| `update_lambda.sh` | 7 | `--help`, `aws lambda update-function-code` invocation, failure path | +| Action | Script | Tests | What's covered | +|--------|--------|-------|----------------| +| `promote-ecr-image` | `options_helpers.sh` | 15 | `has_argument()` and `extract_argument()` parsing logic | +| `promote-ecr-image` | `aws_unset.sh` | 7 | All 4 AWS credential env vars are cleared; no-op when already unset | +| `promote-ecr-image` | `promote_image.sh` | 13 | Every required env var validation (exits 1 for each missing var); `--help` | +| `test-python` | `configure_pip.sh` | 10 | Correct `pip config set` calls per env var; no-op when unset; `--help` | +| `update-aws-ecs` | `update_ecs.sh` | 8 | `--help`, `aws ecs update-service` invocation, `--force-new-deployment`, failure path | +| `update-aws-lambda` | `update_lambda.sh` | 7 | `--help`, `aws lambda update-function-code` invocation, failure path | ### What Is NOT Tested Here @@ -60,33 +67,35 @@ npm install -g bats brew install bats-core ``` -### Run all tests +### Run all tests for a specific action ```sh -bats tests/unit/ +bats tests/unit/update-aws-ecs/ ``` -### Run a single test file +### Run tests for all actions ```sh -bats tests/unit/options_helpers.bats +for dir in tests/unit/*/; do bats --verbose-run "$dir"; done ``` ### Run with verbose output ```sh -bats --verbose-run tests/unit/ +bats --verbose-run tests/unit/promote-ecr-image/ ``` ## CI -The workflow `.github/workflows/test-shell-scripts.yml` runs the full suite automatically -on every push or pull request that touches `tests/` or any `.sh` file under `.github/actions/`. +The `code-quality.yml` workflow runs automatically on every PR to `main`. +It uses `tj-actions/changed-files` to detect which action directories have changed +and runs tests only for those actions — each in its own isolated job. ## Writing New Tests -1. Create `tests/unit/.bats`. -2. Set `REPO_ROOT` using `BATS_TEST_DIRNAME` so paths are always absolute. +1. Create `tests/unit//test_.bats`. +2. Set `REPO_ROOT` relative to `BATS_TEST_DIRNAME` — tests are three levels deep, + so use: `REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)"` 3. In `setup()`, create a `MOCK_DIR`, add mocks for any external commands, and prepend `$MOCK_DIR` to `PATH` — subshells spawned by `run bash -c "..."` inherit the PATH automatically, so do not re-export `PATH` inside the subshell. diff --git a/tests/README.md b/tests/README.md index e32f6b4..604ff32 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,29 +8,36 @@ Tests are written using [bats-core](https://github.com/bats-core/bats-core) — ## Structure +Tests are organised per-action, mirroring the mono-repo structure of the actions themselves. +Each action that contains testable shell scripts has a corresponding directory under `tests/unit/`. + ```text tests/ ├── unit/ -│ ├── options_helpers.bats # Tests for the shared options_helpers.sh utility -│ ├── aws_unset.bats # Tests for the shared aws_unset.sh utility -│ ├── configure_pip.bats # Tests for test-python/scripts/configure_pip.sh -│ ├── promote_image.bats # Tests for promote-ecr-image/scripts/promote_image.sh -│ ├── update_ecs.bats # Tests for update-aws-ecs/scripts/update_ecs.sh -│ └── update_lambda.bats # Tests for update-aws-lambda/scripts/update_lambda.sh +│ ├── promote-ecr-image/ +│ │ ├── test_aws_unset.bats # Tests for the shared aws_unset.sh utility +│ │ ├── test_options_helpers.bats # Tests for the shared options_helpers.sh utility +│ │ └── test_promote_image.bats # Tests for promote_image.sh +│ ├── test-python/ +│ │ └── test_configure_pip.bats # Tests for configure_pip.sh +│ ├── update-aws-ecs/ +│ │ └── test_update_ecs.bats # Tests for update_ecs.sh +│ └── update-aws-lambda/ +│ └── test_update_lambda.bats # Tests for update_lambda.sh └── helpers/ - └── mock_helpers.bash # Shared mock creation and assertion utilities + └── mock_helpers.bash # Shared mock creation and assertion utilities ``` ## What Is Tested -| Script | Tests | -|--------|-------| -| `options_helpers.sh` | `has_argument()` and `extract_argument()` parsing logic | -| `aws_unset.sh` | All four AWS credential env vars are cleared | -| `configure_pip.sh` | Correct `pip config set` calls for each env var; no-op when unset | -| `promote_image.sh` | Env var validation (exits 1 for each missing required var) | -| `update_ecs.sh` | AWS CLI invocation, `--force-new-deployment`, empty-response failure | -| `update_lambda.sh` | AWS CLI invocation, function name + image URL propagation, failure | +| Action | Script | Tests | +|--------|--------|-------| +| `promote-ecr-image` | `options_helpers.sh` | `has_argument()` and `extract_argument()` parsing logic | +| `promote-ecr-image` | `aws_unset.sh` | All four AWS credential env vars are cleared | +| `promote-ecr-image` | `promote_image.sh` | Env var validation (exits 1 for each missing required var) | +| `test-python` | `configure_pip.sh` | Correct `pip config set` calls for each env var; no-op when unset | +| `update-aws-ecs` | `update_ecs.sh` | AWS CLI invocation, `--force-new-deployment`, empty-response failure | +| `update-aws-lambda` | `update_lambda.sh` | AWS CLI invocation, function name + image URL propagation, failure | ### What Is NOT Tested Here @@ -51,30 +58,31 @@ npm install -g bats brew install bats-core ``` -### Run all tests +### Run all tests for a specific action ```bash # From the repo root -bats tests/unit/ +bats tests/unit/update-aws-ecs/ ``` -### Run a single test file +### Run tests for all actions ```bash -bats tests/unit/options_helpers.bats +for dir in tests/unit/*/; do bats --verbose-run "$dir"; done ``` -### Run tests with verbose output +### Run with verbose output ```bash -bats --verbose-run tests/unit/ +bats --verbose-run tests/unit/promote-ecr-image/ ``` ## Writing New Tests -1. Create `tests/unit/.bats` -2. Set `REPO_ROOT` using `BATS_TEST_DIRNAME` so paths are always absolute -3. Mock external commands (aws, docker, pip) using `MOCK_DIR` in PATH +1. Create `tests/unit//test_.bats` +2. Set `REPO_ROOT` relative to `BATS_TEST_DIRNAME` — tests are three levels deep, + so use: `REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)"` +3. Mock external commands (`aws`, `docker`, `pip`) using `MOCK_DIR` in PATH 4. Use `run bash -c "..."` for tests that expect `exit 1` from the script under test See existing test files and `tests/helpers/mock_helpers.bash` for patterns. diff --git a/tests/unit/aws_unset.bats b/tests/unit/promote-ecr-image/test_aws_unset.bats similarity index 98% rename from tests/unit/aws_unset.bats rename to tests/unit/promote-ecr-image/test_aws_unset.bats index c1053da..3910526 100644 --- a/tests/unit/aws_unset.bats +++ b/tests/unit/promote-ecr-image/test_aws_unset.bats @@ -7,7 +7,7 @@ # We test the promote-ecr-image version as the canonical copy. # ============================================================================= -REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" AWS_UNSET_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/aws_unset.sh" # --------------------------------------------------------------------------- diff --git a/tests/unit/options_helpers.bats b/tests/unit/promote-ecr-image/test_options_helpers.bats similarity index 98% rename from tests/unit/options_helpers.bats rename to tests/unit/promote-ecr-image/test_options_helpers.bats index d253e97..e3940a8 100644 --- a/tests/unit/options_helpers.bats +++ b/tests/unit/promote-ecr-image/test_options_helpers.bats @@ -11,7 +11,7 @@ # All three copies are functionally identical; we test one authoritative copy. # ============================================================================= -REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" HELPERS_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/options_helpers.sh" setup() { diff --git a/tests/unit/promote_image.bats b/tests/unit/promote-ecr-image/test_promote_image.bats similarity index 99% rename from tests/unit/promote_image.bats rename to tests/unit/promote-ecr-image/test_promote_image.bats index b6920f0..4382452 100644 --- a/tests/unit/promote_image.bats +++ b/tests/unit/promote-ecr-image/test_promote_image.bats @@ -8,7 +8,7 @@ # tests; those are NOT covered here. # ============================================================================= -REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" PROMOTE_IMAGE_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/promote_image.sh" # --------------------------------------------------------------------------- diff --git a/tests/unit/configure_pip.bats b/tests/unit/test-python/test_configure_pip.bats similarity index 99% rename from tests/unit/configure_pip.bats rename to tests/unit/test-python/test_configure_pip.bats index ccf649a..64f2489 100644 --- a/tests/unit/configure_pip.bats +++ b/tests/unit/test-python/test_configure_pip.bats @@ -8,7 +8,7 @@ # arguments based on which environment variables are set. # ============================================================================= -REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" CONFIGURE_PIP_SCRIPT="$REPO_ROOT/.github/actions/test-python/scripts/configure_pip.sh" # --------------------------------------------------------------------------- diff --git a/tests/unit/update_ecs.bats b/tests/unit/update-aws-ecs/test_update_ecs.bats similarity index 99% rename from tests/unit/update_ecs.bats rename to tests/unit/update-aws-ecs/test_update_ecs.bats index b7e5de3..a319bcf 100644 --- a/tests/unit/update_ecs.bats +++ b/tests/unit/update-aws-ecs/test_update_ecs.bats @@ -13,7 +13,7 @@ # PATH inside the subshell, as that breaks system tools like dirname. # ============================================================================= -REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" UPDATE_ECS_SCRIPT="$REPO_ROOT/.github/actions/update-aws-ecs/scripts/update_ecs.sh" setup() { diff --git a/tests/unit/update_lambda.bats b/tests/unit/update-aws-lambda/test_update_lambda.bats similarity index 99% rename from tests/unit/update_lambda.bats rename to tests/unit/update-aws-lambda/test_update_lambda.bats index 4c8ea0c..8501fa9 100644 --- a/tests/unit/update_lambda.bats +++ b/tests/unit/update-aws-lambda/test_update_lambda.bats @@ -13,7 +13,7 @@ # PATH inside the subshell, as that breaks system tools like dirname. # ============================================================================= -REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" UPDATE_LAMBDA_SCRIPT="$REPO_ROOT/.github/actions/update-aws-lambda/scripts/update_lambda.sh" setup() { From 9cb9fe79ffe6ea0b9b565d37a1f0e2e9b3b28412 Mon Sep 17 00:00:00 2001 From: "genui-scotty[bot]" Date: Thu, 12 Mar 2026 10:34:25 -0700 Subject: [PATCH 5/5] refactor: collapse code-quality.yml to a single job with conditional steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the detect-changes + lint-markdown + bats-tests (matrix) pattern with a single 'quality' job. Per-action test steps use tj-actions/changed-files output with contains() checks rather than a matrix — sequential execution in one runner is more efficient for fast-running bats suites where job startup overhead outweighs any parallelism benefit. --- .github/workflows/code-quality.yml | 96 +++++++++--------------------- 1 file changed, 29 insertions(+), 67 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 93d7b02..537362c 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -7,13 +7,11 @@ on: workflow_dispatch: jobs: - detect-changes: - name: Detect changes + quality: + name: Quality + # The quality pipeline should not take more than 10 minutes. + timeout-minutes: 10 runs-on: ubuntu-latest - timeout-minutes: 2 - outputs: - markdown-changed: ${{ steps.changed-markdown.outputs.any_modified }} - test-matrix: ${{ steps.build-matrix.outputs.matrix }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -29,80 +27,44 @@ jobs: .github/workflows/code-quality.yml - name: Get changed action and test paths - id: changed-paths + id: changed-actions uses: tj-actions/changed-files@v45 with: files: | .github/actions/** tests/** - - name: Build bats test matrix - id: build-matrix - env: - CHANGED_FILES: ${{ steps.changed-paths.outputs.all_changed_files }} - run: | - # Actions that have bats unit tests in tests/unit// - actions_with_tests=( - "promote-ecr-image" - "test-python" - "update-aws-ecs" - "update-aws-lambda" - ) - - matrix=() - for action in "${actions_with_tests[@]}"; do - if echo "$CHANGED_FILES" | grep -qE "(\.github/actions/${action}/|tests/unit/${action}/)"; then - matrix+=("$action") - fi - done - - if [ ${#matrix[@]} -eq 0 ]; then - echo "matrix=[]" >> "$GITHUB_OUTPUT" - else - matrix_json=$(jq -c -n '$ARGS.positional' --args "${matrix[@]}") - echo "matrix=${matrix_json}" >> "$GITHUB_OUTPUT" - fi - - lint-markdown: - name: Lint Markdown - needs: detect-changes - if: needs.detect-changes.outputs.markdown-changed == 'true' - # Linting should not take more than 2 minutes. - timeout-minutes: 2 - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Lint all documentation + if: steps.changed-markdown.outputs.any_modified == 'true' uses: DavidAnson/markdownlint-cli2-action@v14 with: globs: | **/*.md - bats-tests: - name: Test - ${{ matrix.action }} - needs: detect-changes - if: > - needs.detect-changes.outputs.test-matrix != '[]' && - needs.detect-changes.outputs.test-matrix != '' - # Each action test suite should not take more than 5 minutes. - timeout-minutes: 5 - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - action: ${{ fromJSON(needs.detect-changes.outputs.test-matrix) }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Install bats + if: steps.changed-actions.outputs.any_changed == 'true' run: sudo npm install -g bats - - name: Run unit tests for ${{ matrix.action }} - run: bats --verbose-run tests/unit/${{ matrix.action }}/ + - name: Test - promote-ecr-image + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/promote-ecr-image') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/promote-ecr-image') + run: bats --verbose-run tests/unit/promote-ecr-image/ + + - name: Test - test-python + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/test-python') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/test-python') + run: bats --verbose-run tests/unit/test-python/ + + - name: Test - update-aws-ecs + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/update-aws-ecs') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/update-aws-ecs') + run: bats --verbose-run tests/unit/update-aws-ecs/ - - name: Summarise results - if: always() - run: echo "Unit tests complete for ${{ matrix.action }}." + - name: Test - update-aws-lambda + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/update-aws-lambda') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/update-aws-lambda') + run: bats --verbose-run tests/unit/update-aws-lambda/