Compare commits

...

15 Commits
v2.6.0 ... main

Author SHA1 Message Date
dependabot[bot]
1ac4723199 chore(deps): bump ghcr.io/devcontainers/features/docker-in-docker
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps ghcr.io/devcontainers/features/docker-in-docker from 3.1.0 to 4.0.0.

---
updated-dependencies:
- dependency-name: ghcr.io/devcontainers/features/docker-in-docker
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-26 08:01:31 +02:00
dependabot[bot]
9b4690b9b1 chore(deps-dev): bump @vercel/ncc
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps the npm-development-dependencies group with 1 update in the / directory: [@vercel/ncc](https://github.com/vercel/ncc).


Updates `@vercel/ncc` from 0.38.4 to 0.44.0
- [Release notes](https://github.com/vercel/ncc/releases)
- [Commits](https://github.com/vercel/ncc/compare/0.38.4...0.44.0)

---
updated-dependencies:
- dependency-name: "@vercel/ncc"
  dependency-version: 0.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 16:40:39 +02:00
dependabot[bot]
e56e65d179 chore(deps): bump the github-actions-dependencies group with 9 updates
Bumps the github-actions-dependencies group with 9 updates:

---
updated-dependencies:
- dependency-name: hoverkraft-tech/ci-github-nodejs
  dependency-version: 0.24.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml
  dependency-version: 0.24.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-publish/.github/workflows/prepare-release.yml
  dependency-version: 0.26.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml
  dependency-version: 0.37.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
2026-06-25 14:47:57 +02:00
dependabot[bot]
e1257b4b9f chore(deps): bump the github-actions-dependencies group with 8 updates
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps the github-actions-dependencies group with 8 updates:

---
updated-dependencies:
- dependency-name: hoverkraft-tech/ci-github-nodejs
  dependency-version: 0.24.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml
  dependency-version: 0.24.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml
  dependency-version: 0.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml
  dependency-version: 0.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common
  dependency-version: 0.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml
  dependency-version: 0.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml
  dependency-version: 0.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml
  dependency-version: 0.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 17:48:08 +02:00
hoverkraft-bot[bot]
68d3d674a0 docs: update actions and workflows documentation
[skip ci]

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-04 16:51:17 +00:00
Marek Sierociński
11beaa1c2d feat!: update to Node 24
BREAKING CHANGE: Run action on Node 24

Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
2026-06-04 18:39:00 +02:00
dependabot[bot]
9c11768144 chore(deps): bump the github-actions-dependencies group with 6 updates
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps the github-actions-dependencies group with 6 updates:
---
updated-dependencies:
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
2026-06-04 17:15:41 +02:00
dependabot[bot]
98fdf9dfda chore(deps): bump the npm-actions-dependencies group across 1 directory with 2 updates
Bumps the npm-actions-dependencies group with 2 updates in the / directory:
- [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core)
- [@actions/github](https://github.com/actions/toolkit/tree/HEAD/packages/github).

Updates `@actions/core` from 3.0.0 to 3.0.1
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core)

Updates `@actions/github` from 9.1.0 to 9.1.1
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/github/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/github)

---
updated-dependencies:
- dependency-name: "@actions/core"
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-actions-dependencies
- dependency-name: "@actions/github"
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 09:58:04 +02:00
dependabot[bot]
9a87d604e1 chore(deps): bump the github-actions-dependencies group across 1 directory with 15 updates
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Bumps the github-actions-dependencies group with 15 updates in the / directory:

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: docker/setup-docker-action
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-dokumentor
  dependency-version: 0.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common
  dependency-version: 0.35.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml
  dependency-version: 0.35.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml
  dependency-version: 0.35.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml
  dependency-version: 0.35.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml
  dependency-version: 0.35.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml
  dependency-version: 0.35.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-nodejs
  dependency-version: 0.24.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml
  dependency-version: 0.24.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-publish/.github/workflows/prepare-release.yml
  dependency-version: 0.26.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/public-docs/.github/workflows/sync-docs-dispatcher.yml
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
2026-06-04 09:29:18 +02:00
hoverkraft-bot[bot]
8fe6100ce0 docs: update actions and workflows documentation
[skip ci]

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-03 16:14:47 +00:00
Emilien Escalle
706130e079
docs: add GitHub Verified Creator badge
Added extra badges for GitHub Verified Creator and codecov.

Signed-off-by: Emilien Escalle <neilime@users.noreply.github.com>
2026-06-03 18:09:08 +02:00
dependabot[bot]
84bbb39845 chore(deps): bump ghcr.io/devcontainers/features/docker-in-docker
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps ghcr.io/devcontainers/features/docker-in-docker from 2.17.0 to 3.0.1.

---
updated-dependencies:
- dependency-name: ghcr.io/devcontainers/features/docker-in-docker
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 07:45:53 +02:00
dependabot[bot]
16d4f8ad1a chore(deps-dev): bump fast-uri from 3.1.0 to 3.1.2
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-09 09:23:44 +02:00
dependabot[bot]
c30088b6b1 chore(deps): bump ghcr.io/devcontainers/features/node
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled
Bumps ghcr.io/devcontainers/features/node from 1.7.1 to 2.0.0.

---
updated-dependencies:
- dependency-name: ghcr.io/devcontainers/features/node
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 09:28:44 +02:00
hoverkraft-bot[bot]
077eccb7f2 docs: update actions and workflows documentation
[skip ci]

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-16 15:44:17 +00:00
46 changed files with 6021 additions and 13032 deletions

View File

@ -1,29 +1,30 @@
{
"name": "Debian",
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"remoteEnv": {
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}"
},
"customizations": {
"vscode": {
"extensions": [
"eamodio.gitlens",
"github.copilot",
"github.copilot-chat",
"github.vscode-github-actions",
"ms-vscode.makefile-tools",
"bierner.markdown-preview-github-styles",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh"
}
}
}
"name": "Debian",
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
"features": {
"ghcr.io/devcontainers/features/node:2": {},
"ghcr.io/devcontainers/features/docker-in-docker:": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"remoteEnv": {
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}"
},
"customizations": {
"vscode": {
"extensions": [
"eamodio.gitlens",
"github.copilot",
"github.copilot-chat",
"github.vscode-github-actions",
"ms-vscode.makefile-tools",
"bierner.markdown-preview-github-styles",
"esbenp.prettier-vscode",
"biomejs.biome",
"vitest.explorer"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh"
}
}
}
}

View File

@ -3,6 +3,8 @@ updates:
- package-ecosystem: github-actions
directory: /
open-pull-requests-limit: 20
cooldown:
default-days: 7
schedule:
interval: weekly
day: friday
@ -17,6 +19,8 @@ updates:
- "/test"
- "/"
open-pull-requests-limit: 20
cooldown:
default-days: 7
schedule:
interval: weekly
day: friday
@ -29,6 +33,8 @@ updates:
- package-ecosystem: docker-compose
directory: "/test"
open-pull-requests-limit: 20
cooldown:
default-days: 7
schedule:
interval: weekly
day: friday
@ -42,6 +48,8 @@ updates:
directory: "/"
open-pull-requests-limit: 20
versioning-strategy: increase
cooldown:
default-days: 7
schedule:
interval: weekly
day: friday
@ -62,6 +70,8 @@ updates:
- package-ecosystem: "devcontainers"
open-pull-requests-limit: 20
directory: "/"
cooldown:
default-days: 7
schedule:
interval: weekly
day: friday

View File

@ -1,3 +1,3 @@
{
"ignore": ["**/dist/**", "**/node_modules/**", "**/coverage/**"]
"ignore": ["**/dist/**", "**/node_modules/**", "**/coverage/**"]
}

View File

@ -181,13 +181,13 @@ jobs:
env:
DOCKER_COMPOSE_VERSION: ${{ matrix.expected-compose-version || '' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Docker context
if: ${{ matrix.docker-context }}
uses: docker/setup-docker-action@1a6edb0ba9ac496f6850236981f15d8f9a82254d # v5.0.0
uses: docker/setup-docker-action@0234bb73ccb40f0c430b795634f9247e2b5c2d23 # v5.2.0
with:
context: ${{ matrix.docker-context }}

View File

@ -12,12 +12,12 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- id: setup-node
uses: hoverkraft-tech/ci-github-nodejs/actions/setup-node@775ce0902c528062cc94141dd7d13261083b752a # 0.22.0
uses: hoverkraft-tech/ci-github-nodejs/actions/setup-node@df348077afa4e79725151d50606e9dc63f86dcb6 # 0.24.4
- name: Build dist/ Directory
id: package

View File

@ -7,7 +7,7 @@ permissions: {}
jobs:
test-nodejs:
uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@775ce0902c528062cc94141dd7d13261083b752a # 0.22.0
uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@df348077afa4e79725151d50606e9dc63f86dcb6 # 0.24.4
permissions:
contents: read
id-token: write

View File

@ -7,15 +7,18 @@ permissions: {}
jobs:
linter:
uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@4b53189212d5810f710bed89711002626977215b # 0.33.0
uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@624be17604ee0a7378488191aacb35851e7cf001 # 0.37.1
permissions:
actions: read
contents: read
issues: write
packages: read
pull-requests: write
security-events: write
statuses: write
with:
linter-env: |
FILTER_REGEX_EXCLUDE=dist/**/*
FILTER_REGEX_EXCLUDE=dist/**/*|.github/social-preview.svg|.github/logo.svg
VALIDATE_JSCPD=false
VALIDATE_TYPESCRIPT_STANDARD=false
VALIDATE_TYPESCRIPT_ES=false
@ -33,7 +36,6 @@ jobs:
packages: read
pull-requests: write
security-events: write
secrets: inherit
check-dist:
name: Test nodejs

View File

@ -3,14 +3,14 @@ name: Greetings
on:
issues:
types: [opened]
pull_request_target:
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only reusable workflow with explicit write permissions and no checkout
branches: [main]
permissions: {}
jobs:
greetings:
uses: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml@4b53189212d5810f710bed89711002626977215b # 0.33.0
uses: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml@624be17604ee0a7378488191aacb35851e7cf001 # 0.37.1
permissions:
contents: read
issues: write

View File

@ -23,11 +23,11 @@ jobs:
actions: read
contents: read
id-token: write
issues: write
packages: read
pull-requests: write
security-events: write
statuses: write
secrets: inherit
prepare-docs:
needs: ci
@ -38,17 +38,22 @@ jobs:
outputs:
artifact-id: ${{ steps.upload-artifact.outputs.artifact-id }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: 📖 Generate documentation
id: generate-documentation
uses: hoverkraft-tech/ci-dokumentor@c46a1a108957237cf485103a80b060c35c7dba33 # 0.2.2
uses: hoverkraft-tech/ci-dokumentor@3258ef0de948ec25e5939618d5ef510445aed869 # 0.3.1
with:
source: action.yml
# yamllint disable rule:line-length
extra-badges: |
[
{
"label": "GitHub Verified Creator",
"url": "https://img.shields.io/badge/GitHub-Verified%20Creator-4493F8?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJyZ2IoNjgsIDE0NywgMjQ4KSI+CiAgPHBhdGggZD0ibTkuNTg1LjUyLjkyOS42OGMuMTUzLjExMi4zMzEuMTg2LjUxOC4yMTVsMS4xMzguMTc1YTIuNjc4IDIuNjc4IDAgMCAxIDIuMjQgMi4yNGwuMTc0IDEuMTM5Yy4wMjkuMTg3LjEwMy4zNjUuMjE1LjUxOGwuNjguOTI4YTIuNjc3IDIuNjc3IDAgMCAxIDAgMy4xN2wtLjY4LjkyOGExLjE3NCAxLjE3NCAwIDAgMC0uMjE1LjUxOGwtLjE3NSAxLjEzOGEyLjY3OCAyLjY3OCAwIDAgMS0yLjI0MSAyLjI0MWwtMS4xMzguMTc1YTEuMTcgMS4xNyAwIDAgMC0uNTE4LjIxNWwtLjkyOC42OGEyLjY3NyAyLjY3NyAwIDAgMS0zLjE3IDBsLS45MjgtLjY4YTEuMTc0IDEuMTc0IDAgMCAwLS41MTgtLjIxNUwzLjgzIDE0LjQxYTIuNjc4IDIuNjc4IDAgMCAxLTIuMjQtMi4yNGwtLjE3NS0xLjEzOGExLjE3IDEuMTcgMCAwIDAtLjIxNS0uNTE4bC0uNjgtLjkyOGEyLjY3NyAyLjY3NyAwIDAgMSAwLTMuMTdsLjY4LS45MjhjLjExMi0uMTUzLjE4Ni0uMzMxLjIxNS0uNTE4bC4xNzUtMS4xNGEyLjY3OCAyLjY3OCAwIDAgMSAyLjI0LTIuMjRsMS4xMzktLjE3NWMuMTg3LS4wMjkuMzY1LS4xMDMuNTE4LS4yMTVsLjkyOC0uNjhhMi42NzcgMi42NzcgMCAwIDEgMy4xNyAwWk03LjMwMyAxLjcyOGwtLjkyNy42OGEyLjY3IDIuNjcgMCAwIDEtMS4xOC40ODlsLTEuMTM3LjE3NGExLjE3OSAxLjE3OSAwIDAgMC0uOTg3Ljk4N2wtLjE3NCAxLjEzNmEyLjY3NyAyLjY3NyAwIDAgMS0uNDg5IDEuMThsLS42OC45MjhhMS4xOCAxLjE4IDAgMCAwIDAgMS4zOTRsLjY4LjkyN2MuMjU2LjM0OC40MjQuNzUzLjQ4OSAxLjE4bC4xNzQgMS4xMzdjLjA3OC41MDkuNDc4LjkwOS45ODcuOTg3bDEuMTM2LjE3NGEyLjY3IDIuNjcgMCAwIDEgMS4xOC40ODlsLjkyOC42OGMuNDE0LjMwNS45NzkuMzA1IDEuMzk0IDBsLjkyNy0uNjhhMi42NyAyLjY3IDAgMCAxIDEuMTgtLjQ4OWwxLjEzNy0uMTc0YTEuMTggMS4xOCAwIDAgMCAuOTg3LS45ODdsLjE3NC0xLjEzNmEyLjY3IDIuNjcgMCAwIDEgLjQ4OS0xLjE4bC42OC0uOTI4YTEuMTc2IDEuMTc2IDAgMCAwIDAtMS4zOTRsLS42OC0uOTI3YTIuNjg2IDIuNjg2IDAgMCAxLS40ODktMS4xOGwtLjE3NC0xLjEzN2ExLjE3OSAxLjE3OSAwIDAgMC0uOTg3LS45ODdsLTEuMTM2LS4xNzRhMi42NzcgMi42NzcgMCAwIDEtMS4xOC0uNDg5bC0uOTI4LS42OGExLjE3NiAxLjE3NiAwIDAgMC0xLjM5NCAwWk0xMS4yOCA2Ljc4bC0zLjc1IDMuNzVhLjc1Ljc1IDAgMCAxLTEuMDYgMEw0LjcyIDguNzhhLjc1MS43NTEgMCAwIDEgLjAxOC0xLjA0Mi43NTEuNzUxIDAgMCAxIDEuMDQyLS4wMThMNyA4Ljk0bDMuMjItMy4yMmEuNzUxLjc1MSAwIDAgMSAxLjA0Mi4wMTguNzUxLjc1MSAwIDAgMSAuMDE4IDEuMDQyWiI+PC9wYXRoPgo8L3N2Zz4K"
},
{
"label":"codecov",
"url":"https://codecov.io/gh/hoverkraft-tech/compose-action/graph/badge.svg?token=90JXB7EIMA",
@ -56,13 +61,13 @@ jobs:
}
]
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: generate-token
with:
app-id: ${{ vars.CI_BOT_APP_ID }}
private-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }}
client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }}
private-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] repository automation uses a dedicated app secret without untrusted code execution
- uses: hoverkraft-tech/ci-github-common/actions/create-and-merge-pull-request@4b53189212d5810f710bed89711002626977215b # 0.33.0
- uses: hoverkraft-tech/ci-github-common/actions/create-and-merge-pull-request@624be17604ee0a7378488191aacb35851e7cf001 # 0.37.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
branch: docs/actions-workflows-documentation-update
@ -74,7 +79,7 @@ jobs:
[skip ci]
- id: upload-artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: documentation-files-${{ github.run_id }}
path: |
@ -84,11 +89,11 @@ jobs:
sync-docs:
needs: prepare-docs
if: needs.prepare-docs.outputs.artifact-id
uses: hoverkraft-tech/public-docs/.github/workflows/sync-docs-dispatcher.yml@c40c17f7d6a8090950b3ef4bfc70502707a6bb9f # 0.3.0
uses: hoverkraft-tech/public-docs/.github/workflows/sync-docs-dispatcher.yml@f3c9291760d927e6214e8d5f0a376af2d537c369 # 0.4.0
permissions:
contents: read
with:
artifact-id: ${{ needs.prepare-docs.outputs.artifact-id }}
github-app-id: ${{ vars.CI_BOT_APP_ID }}
github-app-client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }}
secrets:
github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }}

View File

@ -11,15 +11,16 @@ on:
description: "The SHA of the commit to get the diff for"
required: true
manual-base-ref:
description: "By default, the commit entered above is compared to the one directly
before it; to go back further, enter an earlier SHA here"
description: >-
By default, the commit entered above is compared to the one directly
before it; to go back further, enter an earlier SHA here
required: false
permissions: {}
jobs:
main:
uses: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml@4b53189212d5810f710bed89711002626977215b # 0.33.0
uses: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml@624be17604ee0a7378488191aacb35851e7cf001 # 0.37.1
permissions:
contents: read
issues: write

View File

@ -15,8 +15,7 @@ permissions: {}
jobs:
release:
uses: hoverkraft-tech/ci-github-publish/.github/workflows/prepare-release.yml@b56be562f38e0e3e712f09691a8fe930aae9db1b # 0.22.0
uses: hoverkraft-tech/ci-github-publish/.github/workflows/prepare-release.yml@b2562b46714e535a0113f90f554b55e1248212c1 # 0.26.3
permissions:
contents: read
id-token: write
pull-requests: write

View File

@ -18,8 +18,8 @@ jobs:
actions: read
contents: read
id-token: write
issues: write
packages: read
pull-requests: write
security-events: write
statuses: write
secrets: inherit

View File

@ -1,7 +1,7 @@
name: "Pull Request - Semantic Lint"
on:
pull_request_target:
pull_request_target: # zizmor: ignore[dangerous-triggers] validates PR metadata only through a pinned reusable workflow without checkout
types:
- opened
- edited
@ -11,7 +11,7 @@ permissions: {}
jobs:
main:
uses: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml@4b53189212d5810f710bed89711002626977215b # 0.33.0
uses: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml@624be17604ee0a7378488191aacb35851e7cf001 # 0.37.1
permissions:
contents: write
pull-requests: write

View File

@ -8,7 +8,7 @@ permissions: {}
jobs:
main:
uses: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml@4b53189212d5810f710bed89711002626977215b # 0.33.0
uses: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml@624be17604ee0a7378488191aacb35851e7cf001 # 0.37.1
permissions:
issues: write
pull-requests: write

7
.gitignore vendored
View File

@ -12,7 +12,6 @@ lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
eslint-report.json
# Runtime data
pids
@ -54,9 +53,6 @@ typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
@ -102,3 +98,6 @@ __tests__/runner/*
.idea
.vscode
*.code-workspace
lint.sarif
biome-report.sarif
junit.xml

View File

@ -1 +1 @@
20.19.3
24.14.1

View File

@ -1,12 +1,5 @@
FROM ghcr.io/super-linter/super-linter:slim-v8.0.0
FROM ghcr.io/hoverkraft-tech/docker-base-images/super-linter:0.6.0
HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 CMD ["/bin/sh","-c","test -d /github/home"]
ARG UID=1000
ARG GID=1000
RUN chown -R ${UID}:${GID} /github/home
USER ${UID}:${GID}
ENV RUN_LOCAL=true
ENV USE_FIND_ALGORITHM=true
ENV LOG_LEVEL=WARN
ENV LOG_FILE="/github/home/logs"

View File

@ -7,13 +7,13 @@ lint: ## Execute linting
$(call run_linter,)
lint-fix: ## Execute linting and fix
@npm run format
$(call run_linter, \
-e FIX_JSON_PRETTIER=true \
-e FIX_JAVASCRIPT_PRETTIER=true \
-e FIX_YAML_PRETTIER=true \
-e FIX_MARKDOWN=true \
-e FIX_MARKDOWN_PRETTIER=true \
-e FIX_NATURAL_LANGUAGE=true \
-e FIX_SHELL_SHFMT=true \
-e FIX_BIOME_LINT=true \
-e FIX_BIOME_FORMAT=true \
)
ci: ## Execute all formats and checks
@ -29,12 +29,8 @@ define run_linter
docker build --build-arg UID=$(shell id -u) --build-arg GID=$(shell id -g) --tag $$LINTER_IMAGE .; \
docker run \
-e DEFAULT_WORKSPACE="$$DEFAULT_WORKSPACE" \
-e FILTER_REGEX_EXCLUDE="dist/**/*|.github/social-preview.svg|.github/logo.svg" \
-e FILTER_REGEX_INCLUDE="$(filter-out $@,$(MAKECMDGOALS))" \
-e IGNORE_GITIGNORED_FILES=true \
-e FILTER_REGEX_EXCLUDE=dist/**/* \
-e VALIDATE_TYPESCRIPT_ES=false \
-e VALIDATE_TYPESCRIPT_PRETTIER=false \
-e VALIDATE_JAVASCRIPT_ES=false \
$(1) \
-v $$VOLUME \
--rm \

View File

@ -17,6 +17,7 @@
[![License](https://img.shields.io/github/license/hoverkraft-tech/compose-action)](http://choosealicense.com/licenses/mit/)
[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/compose-action?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/compose-action?style=social)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/compose-action/blob/main/CONTRIBUTING.md)
![GitHub Verified Creator](https://img.shields.io/badge/GitHub-Verified%20Creator-4493F8?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJyZ2IoNjgsIDE0NywgMjQ4KSI+CiAgPHBhdGggZD0ibTkuNTg1LjUyLjkyOS42OGMuMTUzLjExMi4zMzEuMTg2LjUxOC4yMTVsMS4xMzguMTc1YTIuNjc4IDIuNjc4IDAgMCAxIDIuMjQgMi4yNGwuMTc0IDEuMTM5Yy4wMjkuMTg3LjEwMy4zNjUuMjE1LjUxOGwuNjguOTI4YTIuNjc3IDIuNjc3IDAgMCAxIDAgMy4xN2wtLjY4LjkyOGExLjE3NCAxLjE3NCAwIDAgMC0uMjE1LjUxOGwtLjE3NSAxLjEzOGEyLjY3OCAyLjY3OCAwIDAgMS0yLjI0MSAyLjI0MWwtMS4xMzguMTc1YTEuMTcgMS4xNyAwIDAgMC0uNTE4LjIxNWwtLjkyOC42OGEyLjY3NyAyLjY3NyAwIDAgMS0zLjE3IDBsLS45MjgtLjY4YTEuMTc0IDEuMTc0IDAgMCAwLS41MTgtLjIxNUwzLjgzIDE0LjQxYTIuNjc4IDIuNjc4IDAgMCAxLTIuMjQtMi4yNGwtLjE3NS0xLjEzOGExLjE3IDEuMTcgMCAwIDAtLjIxNS0uNTE4bC0uNjgtLjkyOGEyLjY3NyAyLjY3NyAwIDAgMSAwLTMuMTdsLjY4LS45MjhjLjExMi0uMTUzLjE4Ni0uMzMxLjIxNS0uNTE4bC4xNzUtMS4xNGEyLjY3OCAyLjY3OCAwIDAgMSAyLjI0LTIuMjRsMS4xMzktLjE3NWMuMTg3LS4wMjkuMzY1LS4xMDMuNTE4LS4yMTVsLjkyOC0uNjhhMi42NzcgMi42NzcgMCAwIDEgMy4xNyAwWk03LjMwMyAxLjcyOGwtLjkyNy42OGEyLjY3IDIuNjcgMCAwIDEtMS4xOC40ODlsLTEuMTM3LjE3NGExLjE3OSAxLjE3OSAwIDAgMC0uOTg3Ljk4N2wtLjE3NCAxLjEzNmEyLjY3NyAyLjY3NyAwIDAgMS0uNDg5IDEuMThsLS42OC45MjhhMS4xOCAxLjE4IDAgMCAwIDAgMS4zOTRsLjY4LjkyN2MuMjU2LjM0OC40MjQuNzUzLjQ4OSAxLjE4bC4xNzQgMS4xMzdjLjA3OC41MDkuNDc4LjkwOS45ODcuOTg3bDEuMTM2LjE3NGEyLjY3IDIuNjcgMCAwIDEgMS4xOC40ODlsLjkyOC42OGMuNDE0LjMwNS45NzkuMzA1IDEuMzk0IDBsLjkyNy0uNjhhMi42NyAyLjY3IDAgMCAxIDEuMTgtLjQ4OWwxLjEzNy0uMTc0YTEuMTggMS4xOCAwIDAgMCAuOTg3LS45ODdsLjE3NC0xLjEzNmEyLjY3IDIuNjcgMCAwIDEgLjQ4OS0xLjE4bC42OC0uOTI4YTEuMTc2IDEuMTc2IDAgMCAwIDAtMS4zOTRsLS42OC0uOTI3YTIuNjg2IDIuNjg2IDAgMCAxLS40ODktMS4xOGwtLjE3NC0xLjEzN2ExLjE3OSAxLjE3OSAwIDAgMC0uOTg3LS45ODdsLTEuMTM2LS4xNzRhMi42NzcgMi42NzcgMCAwIDEtMS4xOC0uNDg5bC0uOTI4LS42OGExLjE3NiAxLjE3NiAwIDAgMC0xLjM5NCAwWk0xMS4yOCA2Ljc4bC0zLjc1IDMuNzVhLjc1Ljc1IDAgMCAxLTEuMDYgMEw0LjcyIDguNzhhLjc1MS43NTEgMCAwIDEgLjAxOC0xLjA0Mi43NTEuNzUxIDAgMCAxIDEuMDQyLS4wMThMNyA4Ljk0bDMuMjItMy4yMmEuNzUxLjc1MSAwIDAgMSAxLjA0Mi4wMTguNzUxLjc1MSAwIDAgMSAuMDE4IDEuMDQyWiI+PC9wYXRoPgo8L3N2Zz4K)
[![codecov](https://codecov.io/gh/hoverkraft-tech/compose-action/graph/badge.svg?token=90JXB7EIMA)](https://codecov.io/gh/hoverkraft-tech/compose-action)
<!-- badges:end -->
@ -49,7 +50,7 @@ Some extra options can be passed to the `docker compose down` command using the
## Usage
```yaml
- uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
- uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
# Additional options to pass to `docker` command.
docker-flags: ""
@ -139,7 +140,7 @@ jobs:
- uses: actions/checkout@v4.2.2
- name: Run docker compose
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: "./docker/docker-compose.yml"
@ -153,7 +154,7 @@ jobs:
```yaml
steps:
- uses: actions/checkout@v4.2.2
- uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
- uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: "./docker/docker-compose.yml"
env:
@ -168,7 +169,7 @@ Perform `docker compose up` to some given service instead of all of them
steps:
# need checkout before using compose-action
- uses: actions/checkout@v3
- uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
- uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: "./docker/docker-compose.yml"
services: |
@ -206,7 +207,7 @@ A full list of flags can be found in the [Docker compose documentation](https://
steps:
# need checkout before using compose-action
- uses: actions/checkout@v3
- uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
- uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: "./docker/docker-compose.yml"
compose-flags: "--profile profile-1"
@ -220,7 +221,7 @@ This is useful when you have a base compose file and additional files for differ
steps:
# need checkout before using compose-action
- uses: actions/checkout@v3
- uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
- uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: |
./docker/docker-compose.yml

View File

@ -65,6 +65,6 @@ inputs:
required: false
runs:
using: node20
using: node24
main: dist/index.js
post: dist/post.js

34
biome.json Normal file
View File

@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

438
dist/index.js generated vendored
View File

@ -8451,8 +8451,6 @@ function defaultFactory (origin, opts) {
class Agent extends DispatcherBase {
constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
super()
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
@ -8465,6 +8463,8 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('maxRedirections must be a positive number')
}
super(options)
if (connect && typeof connect !== 'function') {
connect = { ...connect }
}
@ -8836,6 +8836,9 @@ const EMPTY_BUF = Buffer.alloc(0)
const FastBuffer = Buffer[Symbol.species]
const addListener = util.addListener
const removeAllListeners = util.removeAllListeners
const kIdleSocketValidation = Symbol('kIdleSocketValidation')
const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
const kSocketUsed = Symbol('kSocketUsed')
let extractBody
@ -9058,29 +9061,71 @@ class Parser {
const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr
if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(data.slice(offset))
} else if (ret === constants.ERROR.PAUSED) {
this.paused = true
socket.unshift(data.slice(offset))
} else if (ret !== constants.ERROR.OK) {
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
/* istanbul ignore else: difficult to make a test case for */
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
if (ret !== constants.ERROR.OK) {
const body = data.subarray(offset)
if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(body)
} else if (ret === constants.ERROR.PAUSED) {
this.paused = true
socket.unshift(body)
} else {
throw this.createError(ret, body)
}
throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset))
}
} catch (err) {
util.destroy(socket, err)
}
}
finish () {
assert(currentParser === null)
assert(this.ptr != null)
assert(!this.paused)
const { llhttp } = this
let ret
try {
currentParser = this
ret = llhttp.llhttp_finish(this.ptr)
} finally {
currentParser = null
}
if (ret === constants.ERROR.OK) {
return null
}
if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
this.paused = true
return null
}
return this.createError(ret, EMPTY_BUF)
}
createError (ret, data) {
const { llhttp, contentLength, bytesRead } = this
if (contentLength && bytesRead !== parseInt(contentLength, 10)) {
return new ResponseContentLengthMismatchError()
}
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
}
return new HTTPParserError(message, constants.ERROR[ret], data)
}
destroy () {
assert(this.ptr != null)
assert(currentParser == null)
@ -9108,6 +9153,11 @@ class Parser {
return -1
}
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]]
if (!request) {
return -1
@ -9211,6 +9261,11 @@ class Parser {
return -1
}
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]]
/* istanbul ignore next: difficult to make a test case for */
@ -9384,6 +9439,7 @@ class Parser {
request.onComplete(headers)
client[kQueue][client[kRunningIdx]++] = null
socket[kSocketUsed] = true
if (socket[kWriting]) {
assert(client[kRunning] === 0)
@ -9442,6 +9498,9 @@ async function connectH1 (client, socket) {
socket[kWriting] = false
socket[kReset] = false
socket[kBlocking] = false
socket[kIdleSocketValidation] = 0
socket[kIdleSocketValidationTimeout] = null
socket[kSocketUsed] = false
socket[kParser] = new Parser(client, socket, llhttpInstance)
addListener(socket, 'error', function (err) {
@ -9452,8 +9511,11 @@ async function connectH1 (client, socket) {
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
// to the user.
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so for as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
this[kError] = parserErr
this[kClient][kOnError](parserErr)
}
return
}
@ -9472,8 +9534,10 @@ async function connectH1 (client, socket) {
const parser = this[kParser]
if (parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
util.destroy(this, parserErr)
}
return
}
@ -9483,10 +9547,11 @@ async function connectH1 (client, socket) {
const client = this[kClient]
const parser = this[kParser]
clearIdleSocketValidation(this)
if (parser) {
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
this[kError] = parser.finish() || this[kError]
}
this[kParser].destroy()
@ -9549,7 +9614,7 @@ async function connectH1 (client, socket) {
return socket.destroyed
},
busy (request) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking]) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) {
return true
}
@ -9587,6 +9652,31 @@ async function connectH1 (client, socket) {
}
}
function clearIdleSocketValidation (socket) {
if (socket[kIdleSocketValidationTimeout]) {
clearTimeout(socket[kIdleSocketValidationTimeout])
socket[kIdleSocketValidationTimeout] = null
}
socket[kIdleSocketValidation] = 0
}
function scheduleIdleSocketValidation (client, socket) {
socket[kIdleSocketValidation] = 1
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
socket[kIdleSocketValidationTimeout] = null
socket[kIdleSocketValidation] = 2
if (client[kSocket] === socket && !socket.destroyed) {
client[kResume]()
}
}, 0)
socket[kIdleSocketValidationTimeout].unref?.()
}
/**
* @param {import('./client.js')} client
*/
function resumeH1 (client) {
const socket = client[kSocket]
@ -9601,6 +9691,32 @@ function resumeH1 (client) {
socket[kNoRef] = false
}
if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) {
if (socket[kIdleSocketValidation] === 0) {
scheduleIdleSocketValidation(client, socket)
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
if (socket[kIdleSocketValidation] === 1) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
}
if (client[kRunning] === 0) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
}
if (client[kSize] === 0) {
if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
@ -9694,6 +9810,7 @@ function writeH1 (client, request) {
}
const socket = client[kSocket]
clearIdleSocketValidation(socket)
const abort = (err) => {
if (request.aborted || request.completed) {
@ -11013,9 +11130,10 @@ class Client extends DispatcherBase {
autoSelectFamilyAttemptTimeout,
// h2
maxConcurrentStreams,
allowH2
allowH2,
webSocket
} = {}) {
super()
super({ webSocket })
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@ -11547,15 +11665,24 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = __nc
const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed')
const kInterceptedDispatch = Symbol('Intercepted Dispatch')
const kWebSocketOptions = Symbol('webSocketOptions')
class DispatcherBase extends Dispatcher {
constructor () {
constructor (opts) {
super()
this[kDestroyed] = false
this[kOnDestroyed] = null
this[kClosed] = false
this[kOnClosed] = []
this[kWebSocketOptions] = opts?.webSocket ?? {}
}
get webSocketOptions () {
return {
maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024
}
}
get destroyed () {
@ -12115,8 +12242,8 @@ const kRemoveClient = Symbol('remove client')
const kStats = Symbol('stats')
class PoolBase extends DispatcherBase {
constructor () {
super()
constructor (opts) {
super(opts)
this[kQueue] = new FixedQueue()
this[kClients] = []
@ -12375,8 +12502,6 @@ class Pool extends PoolBase {
allowH2,
...options
} = {}) {
super()
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
throw new InvalidArgumentError('invalid connections')
}
@ -12401,6 +12526,8 @@ class Pool extends PoolBase {
})
}
super(options)
this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool)
? options.interceptors.Pool
: []
@ -17453,32 +17580,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
// If the attribute-name case-insensitively matches the string
// "SameSite", the user agent MUST process the cookie-av as follows:
// 1. Let enforcement be "Default".
let enforcement = 'Default'
const attributeValueLowercase = attributeValue.toLowerCase()
// 2. If cookie-av's attribute-value is a case-insensitive match for
// "None", set enforcement to "None".
if (attributeValueLowercase.includes('none')) {
enforcement = 'None'
}
// 3. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", set enforcement to "Strict".
if (attributeValueLowercase.includes('strict')) {
enforcement = 'Strict'
// 1. If cookie-av's attribute-value is a case-insensitive match for
// "None", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "None".
if (attributeValueLowercase === 'none') {
cookieAttributeList.sameSite = 'None'
} else if (attributeValueLowercase === 'strict') {
// 2. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", append an attribute to the cookie-attribute-list with
// an attribute-name of "SameSite" and an attribute-value of
// "Strict".
cookieAttributeList.sameSite = 'Strict'
} else if (attributeValueLowercase === 'lax') {
// 3. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "Lax".
cookieAttributeList.sameSite = 'Lax'
}
// 4. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", set enforcement to "Lax".
if (attributeValueLowercase.includes('lax')) {
enforcement = 'Lax'
}
// 5. Append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of
// enforcement.
cookieAttributeList.sameSite = enforcement
} else {
cookieAttributeList.unparsed ??= []
@ -30155,40 +30275,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
const kBuffer = Symbol('kBuffer')
const kLength = Symbol('kLength')
// Default maximum decompressed message size: 4 MB
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
class PerMessageDeflate {
/** @type {import('node:zlib').InflateRaw} */
#inflate
#options = {}
/** @type {boolean} */
#aborted = false
/** @type {Function|null} */
#currentCallback = null
#maxPayloadSize = 0
/**
* @param {Map<string, string>} extensions
*/
constructor (extensions) {
constructor (extensions, options) {
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
this.#maxPayloadSize = options.maxPayloadSize
}
/**
* Decompress a compressed payload.
* @param {Buffer} chunk Compressed data
* @param {boolean} fin Final fragment flag
* @param {Function} callback Callback function
*/
decompress (chunk, fin, callback) {
// An endpoint uses the following algorithm to decompress a message.
// 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
// payload of the message.
// 2. Decompress the resulting data using DEFLATE.
if (this.#aborted) {
callback(new MessageSizeExceededError())
return
}
if (!this.#inflate) {
let windowBits = Z_DEFAULT_WINDOWBITS
@ -30211,23 +30326,12 @@ class PerMessageDeflate {
this.#inflate[kLength] = 0
this.#inflate.on('data', (data) => {
if (this.#aborted) {
return
}
this.#inflate[kLength] += data.length
if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
this.#aborted = true
if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
callback(new MessageSizeExceededError())
this.#inflate.removeAllListeners()
this.#inflate.destroy()
this.#inflate = null
if (this.#currentCallback) {
const cb = this.#currentCallback
this.#currentCallback = null
cb(new MessageSizeExceededError())
}
return
}
@ -30240,14 +30344,13 @@ class PerMessageDeflate {
})
}
this.#currentCallback = callback
this.#inflate.write(chunk)
if (fin) {
this.#inflate.write(tail)
}
this.#inflate.flush(() => {
if (this.#aborted || !this.#inflate) {
if (!this.#inflate) {
return
}
@ -30255,7 +30358,6 @@ class PerMessageDeflate {
this.#inflate[kBuffer].length = 0
this.#inflate[kLength] = 0
this.#currentCallback = null
callback(null, full)
})
@ -30290,6 +30392,12 @@ const {
const { WebsocketFrameSend } = __nccwpck_require__(3264)
const { closeWebSocketConnection } = __nccwpck_require__(6897)
const { PerMessageDeflate } = __nccwpck_require__(9469)
const { MessageSizeExceededError } = __nccwpck_require__(8707)
function failWebsocketConnectionWithCode (ws, code, reason) {
closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason))
failWebsocketConnection(ws, reason)
}
// This code was influenced by ws released under the MIT license.
// Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@ -30298,6 +30406,7 @@ const { PerMessageDeflate } = __nccwpck_require__(9469)
class ByteParser extends Writable {
#buffers = []
#fragmentsBytes = 0
#byteOffset = 0
#loop = false
@ -30309,18 +30418,27 @@ class ByteParser extends Writable {
/** @type {Map<string, PerMessageDeflate>} */
#extensions
/** @type {number} */
#maxFragments
/** @type {number} */
#maxPayloadSize
/**
* @param {import('./websocket').WebSocket} ws
* @param {Map<string, string>|null} extensions
* @param {{ maxFragments?: number, maxPayloadSize?: number }} [options]
*/
constructor (ws, extensions) {
constructor (ws, extensions, options = {}) {
super()
this.ws = ws
this.#extensions = extensions == null ? new Map() : extensions
this.#maxFragments = options.maxFragments ?? 0
this.#maxPayloadSize = options.maxPayloadSize ?? 0
if (this.#extensions.has('permessage-deflate')) {
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
}
}
@ -30336,6 +30454,19 @@ class ByteParser extends Writable {
this.run(callback)
}
#validatePayloadLength () {
if (
this.#maxPayloadSize > 0 &&
!isControlFrame(this.#info.opcode) &&
this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize
) {
failWebsocketConnectionWithCode(this.ws, 1009, 'Payload size exceeds maximum allowed size')
return false
}
return true
}
/**
* Runs whenever a new chunk is received.
* Callback is called whenever there are no more chunks buffering,
@ -30424,6 +30555,10 @@ class ByteParser extends Writable {
if (payloadLength <= 125) {
this.#info.payloadLength = payloadLength
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (payloadLength === 126) {
this.#state = parserStates.PAYLOADLENGTH_16
} else if (payloadLength === 127) {
@ -30448,6 +30583,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = buffer.readUInt16BE(0)
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.PAYLOADLENGTH_64) {
if (this.#byteOffset < 8) {
return callback()
@ -30470,6 +30609,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = lower
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.READ_DATA) {
if (this.#byteOffset < this.#info.payloadLength) {
return callback()
@ -30482,42 +30625,58 @@ class ByteParser extends Writable {
this.#state = parserStates.INFO
} else {
if (!this.#info.compressed) {
this.#fragments.push(body)
if (!this.writeFragments(body)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
// If the frame is not fragmented, a message has been received.
// If the frame is fragmented, it will terminate with a fin bit set
// and an opcode of 0 (continuation), therefore we handle that when
// parsing continuation frames, not here.
if (!this.#info.fragmented && this.#info.fin) {
const fullMessage = Buffer.concat(this.#fragments)
websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
this.#fragments.length = 0
websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
}
this.#state = parserStates.INFO
} else {
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
if (error) {
failWebsocketConnection(this.ws, error.message)
return
}
this.#extensions.get('permessage-deflate').decompress(
body,
this.#info.fin,
(error, data) => {
if (error) {
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
failWebsocketConnectionWithCode(this.ws, code, error.message)
return
}
this.#fragments.push(data)
if (!this.writeFragments(data)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.run(callback)
return
}
websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.#state = parserStates.INFO
this.run(callback)
return
}
websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments))
this.#loop = true
this.#state = parserStates.INFO
this.#fragments.length = 0
this.run(callback)
})
)
this.#loop = false
break
@ -30569,6 +30728,35 @@ class ByteParser extends Writable {
return buffer
}
writeFragments (fragment) {
if (
this.#maxFragments > 0 &&
this.#fragments.length === this.#maxFragments
) {
failWebsocketConnectionWithCode(this.ws, 1008, 'Too many message fragments')
return false
}
this.#fragmentsBytes += fragment.length
this.#fragments.push(fragment)
return true
}
consumeFragments () {
const fragments = this.#fragments
if (fragments.length === 1) {
this.#fragmentsBytes = 0
return fragments.shift()
}
const output = Buffer.concat(fragments, this.#fragmentsBytes)
this.#fragments = []
this.#fragmentsBytes = 0
return output
}
parseCloseBody (data) {
assert(data.length !== 1)
@ -31600,7 +31788,14 @@ class WebSocket extends EventTarget {
// once this happens, the connection is open
this[kResponse] = response
const parser = new ByteParser(this, parsedExtensions)
const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions
const maxFragments = webSocketOptions?.maxFragments
const maxPayloadSize = webSocketOptions?.maxPayloadSize
const parser = new ByteParser(this, parsedExtensions, {
maxFragments,
maxPayloadSize
})
parser.on('drain', onParserDrain)
parser.on('error', onParserError.bind(this))
@ -43860,8 +44055,11 @@ class InputService {
}) || null);
}
getServiceLogLevel() {
const configuredLevel = getInput(InputNames.ServiceLogLevel, { required: false });
if (configuredLevel && !Object.values(LogLevel).includes(configuredLevel)) {
const configuredLevel = getInput(InputNames.ServiceLogLevel, {
required: false,
});
if (configuredLevel &&
!Object.values(LogLevel).includes(configuredLevel)) {
throw new Error(`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`);
}
return configuredLevel || LogLevel.Debug;
@ -43943,12 +44141,12 @@ class DockerComposeService {
parts.push("Docker Compose command failed");
}
// Add error stream output if available
if (error.err && error.err.trim()) {
if (error.err?.trim()) {
parts.push("\nError output:");
parts.push(error.err.trim());
}
// Add standard output if available and different from error output
if (error.out && error.out.trim() && error.out !== error.err) {
if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:");
parts.push(error.out.trim());
}
@ -48237,14 +48435,17 @@ class DockerComposeInstallerService {
constructor(manualInstallerAdapter) {
this.manualInstallerAdapter = manualInstallerAdapter;
}
async install({ composeVersion, cwd, githubToken }) {
async install({ composeVersion, cwd, githubToken, }) {
const currentVersion = await this.version({ cwd });
const normalizedCurrentVersion = currentVersion ? this.normalizeVersion(currentVersion) : null;
const normalizedCurrentVersion = currentVersion
? this.normalizeVersion(currentVersion)
: null;
const normalizedRequestedVersion = composeVersion
? this.normalizeVersion(composeVersion)
: null;
const needsInstall = !currentVersion ||
(composeVersion && normalizedRequestedVersion !== normalizedCurrentVersion);
(composeVersion &&
normalizedRequestedVersion !== normalizedCurrentVersion);
if (!needsInstall) {
return currentVersion;
}
@ -48258,7 +48459,8 @@ class DockerComposeInstallerService {
await this.installVersion(targetVersion);
const installedVersion = await this.version({ cwd });
if (!installedVersion ||
this.normalizeVersion(installedVersion) !== this.normalizeVersion(targetVersion)) {
this.normalizeVersion(installedVersion) !==
this.normalizeVersion(targetVersion)) {
throw new Error(`Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"`);
}
return installedVersion;

426
dist/post.js generated vendored
View File

@ -4940,8 +4940,6 @@ function defaultFactory (origin, opts) {
class Agent extends DispatcherBase {
constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
super()
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
@ -4954,6 +4952,8 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('maxRedirections must be a positive number')
}
super(options)
if (connect && typeof connect !== 'function') {
connect = { ...connect }
}
@ -5325,6 +5325,9 @@ const EMPTY_BUF = Buffer.alloc(0)
const FastBuffer = Buffer[Symbol.species]
const addListener = util.addListener
const removeAllListeners = util.removeAllListeners
const kIdleSocketValidation = Symbol('kIdleSocketValidation')
const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
const kSocketUsed = Symbol('kSocketUsed')
let extractBody
@ -5547,29 +5550,71 @@ class Parser {
const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr
if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(data.slice(offset))
} else if (ret === constants.ERROR.PAUSED) {
this.paused = true
socket.unshift(data.slice(offset))
} else if (ret !== constants.ERROR.OK) {
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
/* istanbul ignore else: difficult to make a test case for */
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
if (ret !== constants.ERROR.OK) {
const body = data.subarray(offset)
if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(body)
} else if (ret === constants.ERROR.PAUSED) {
this.paused = true
socket.unshift(body)
} else {
throw this.createError(ret, body)
}
throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset))
}
} catch (err) {
util.destroy(socket, err)
}
}
finish () {
assert(currentParser === null)
assert(this.ptr != null)
assert(!this.paused)
const { llhttp } = this
let ret
try {
currentParser = this
ret = llhttp.llhttp_finish(this.ptr)
} finally {
currentParser = null
}
if (ret === constants.ERROR.OK) {
return null
}
if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
this.paused = true
return null
}
return this.createError(ret, EMPTY_BUF)
}
createError (ret, data) {
const { llhttp, contentLength, bytesRead } = this
if (contentLength && bytesRead !== parseInt(contentLength, 10)) {
return new ResponseContentLengthMismatchError()
}
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
}
return new HTTPParserError(message, constants.ERROR[ret], data)
}
destroy () {
assert(this.ptr != null)
assert(currentParser == null)
@ -5597,6 +5642,11 @@ class Parser {
return -1
}
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]]
if (!request) {
return -1
@ -5700,6 +5750,11 @@ class Parser {
return -1
}
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]]
/* istanbul ignore next: difficult to make a test case for */
@ -5873,6 +5928,7 @@ class Parser {
request.onComplete(headers)
client[kQueue][client[kRunningIdx]++] = null
socket[kSocketUsed] = true
if (socket[kWriting]) {
assert(client[kRunning] === 0)
@ -5931,6 +5987,9 @@ async function connectH1 (client, socket) {
socket[kWriting] = false
socket[kReset] = false
socket[kBlocking] = false
socket[kIdleSocketValidation] = 0
socket[kIdleSocketValidationTimeout] = null
socket[kSocketUsed] = false
socket[kParser] = new Parser(client, socket, llhttpInstance)
addListener(socket, 'error', function (err) {
@ -5941,8 +6000,11 @@ async function connectH1 (client, socket) {
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
// to the user.
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so for as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
this[kError] = parserErr
this[kClient][kOnError](parserErr)
}
return
}
@ -5961,8 +6023,10 @@ async function connectH1 (client, socket) {
const parser = this[kParser]
if (parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
util.destroy(this, parserErr)
}
return
}
@ -5972,10 +6036,11 @@ async function connectH1 (client, socket) {
const client = this[kClient]
const parser = this[kParser]
clearIdleSocketValidation(this)
if (parser) {
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
this[kError] = parser.finish() || this[kError]
}
this[kParser].destroy()
@ -6038,7 +6103,7 @@ async function connectH1 (client, socket) {
return socket.destroyed
},
busy (request) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking]) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) {
return true
}
@ -6076,6 +6141,31 @@ async function connectH1 (client, socket) {
}
}
function clearIdleSocketValidation (socket) {
if (socket[kIdleSocketValidationTimeout]) {
clearTimeout(socket[kIdleSocketValidationTimeout])
socket[kIdleSocketValidationTimeout] = null
}
socket[kIdleSocketValidation] = 0
}
function scheduleIdleSocketValidation (client, socket) {
socket[kIdleSocketValidation] = 1
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
socket[kIdleSocketValidationTimeout] = null
socket[kIdleSocketValidation] = 2
if (client[kSocket] === socket && !socket.destroyed) {
client[kResume]()
}
}, 0)
socket[kIdleSocketValidationTimeout].unref?.()
}
/**
* @param {import('./client.js')} client
*/
function resumeH1 (client) {
const socket = client[kSocket]
@ -6090,6 +6180,32 @@ function resumeH1 (client) {
socket[kNoRef] = false
}
if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) {
if (socket[kIdleSocketValidation] === 0) {
scheduleIdleSocketValidation(client, socket)
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
if (socket[kIdleSocketValidation] === 1) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
}
if (client[kRunning] === 0) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
}
if (client[kSize] === 0) {
if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
@ -6183,6 +6299,7 @@ function writeH1 (client, request) {
}
const socket = client[kSocket]
clearIdleSocketValidation(socket)
const abort = (err) => {
if (request.aborted || request.completed) {
@ -7502,9 +7619,10 @@ class Client extends DispatcherBase {
autoSelectFamilyAttemptTimeout,
// h2
maxConcurrentStreams,
allowH2
allowH2,
webSocket
} = {}) {
super()
super({ webSocket })
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@ -8036,15 +8154,24 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = __nc
const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed')
const kInterceptedDispatch = Symbol('Intercepted Dispatch')
const kWebSocketOptions = Symbol('webSocketOptions')
class DispatcherBase extends Dispatcher {
constructor () {
constructor (opts) {
super()
this[kDestroyed] = false
this[kOnDestroyed] = null
this[kClosed] = false
this[kOnClosed] = []
this[kWebSocketOptions] = opts?.webSocket ?? {}
}
get webSocketOptions () {
return {
maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024
}
}
get destroyed () {
@ -8604,8 +8731,8 @@ const kRemoveClient = Symbol('remove client')
const kStats = Symbol('stats')
class PoolBase extends DispatcherBase {
constructor () {
super()
constructor (opts) {
super(opts)
this[kQueue] = new FixedQueue()
this[kClients] = []
@ -8864,8 +8991,6 @@ class Pool extends PoolBase {
allowH2,
...options
} = {}) {
super()
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
throw new InvalidArgumentError('invalid connections')
}
@ -8890,6 +9015,8 @@ class Pool extends PoolBase {
})
}
super(options)
this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool)
? options.interceptors.Pool
: []
@ -13942,32 +14069,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
// If the attribute-name case-insensitively matches the string
// "SameSite", the user agent MUST process the cookie-av as follows:
// 1. Let enforcement be "Default".
let enforcement = 'Default'
const attributeValueLowercase = attributeValue.toLowerCase()
// 2. If cookie-av's attribute-value is a case-insensitive match for
// "None", set enforcement to "None".
if (attributeValueLowercase.includes('none')) {
enforcement = 'None'
}
// 3. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", set enforcement to "Strict".
if (attributeValueLowercase.includes('strict')) {
enforcement = 'Strict'
// 1. If cookie-av's attribute-value is a case-insensitive match for
// "None", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "None".
if (attributeValueLowercase === 'none') {
cookieAttributeList.sameSite = 'None'
} else if (attributeValueLowercase === 'strict') {
// 2. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", append an attribute to the cookie-attribute-list with
// an attribute-name of "SameSite" and an attribute-value of
// "Strict".
cookieAttributeList.sameSite = 'Strict'
} else if (attributeValueLowercase === 'lax') {
// 3. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "Lax".
cookieAttributeList.sameSite = 'Lax'
}
// 4. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", set enforcement to "Lax".
if (attributeValueLowercase.includes('lax')) {
enforcement = 'Lax'
}
// 5. Append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of
// enforcement.
cookieAttributeList.sameSite = enforcement
} else {
cookieAttributeList.unparsed ??= []
@ -26644,40 +26764,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
const kBuffer = Symbol('kBuffer')
const kLength = Symbol('kLength')
// Default maximum decompressed message size: 4 MB
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
class PerMessageDeflate {
/** @type {import('node:zlib').InflateRaw} */
#inflate
#options = {}
/** @type {boolean} */
#aborted = false
/** @type {Function|null} */
#currentCallback = null
#maxPayloadSize = 0
/**
* @param {Map<string, string>} extensions
*/
constructor (extensions) {
constructor (extensions, options) {
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
this.#maxPayloadSize = options.maxPayloadSize
}
/**
* Decompress a compressed payload.
* @param {Buffer} chunk Compressed data
* @param {boolean} fin Final fragment flag
* @param {Function} callback Callback function
*/
decompress (chunk, fin, callback) {
// An endpoint uses the following algorithm to decompress a message.
// 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
// payload of the message.
// 2. Decompress the resulting data using DEFLATE.
if (this.#aborted) {
callback(new MessageSizeExceededError())
return
}
if (!this.#inflate) {
let windowBits = Z_DEFAULT_WINDOWBITS
@ -26700,23 +26815,12 @@ class PerMessageDeflate {
this.#inflate[kLength] = 0
this.#inflate.on('data', (data) => {
if (this.#aborted) {
return
}
this.#inflate[kLength] += data.length
if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
this.#aborted = true
if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
callback(new MessageSizeExceededError())
this.#inflate.removeAllListeners()
this.#inflate.destroy()
this.#inflate = null
if (this.#currentCallback) {
const cb = this.#currentCallback
this.#currentCallback = null
cb(new MessageSizeExceededError())
}
return
}
@ -26729,14 +26833,13 @@ class PerMessageDeflate {
})
}
this.#currentCallback = callback
this.#inflate.write(chunk)
if (fin) {
this.#inflate.write(tail)
}
this.#inflate.flush(() => {
if (this.#aborted || !this.#inflate) {
if (!this.#inflate) {
return
}
@ -26744,7 +26847,6 @@ class PerMessageDeflate {
this.#inflate[kBuffer].length = 0
this.#inflate[kLength] = 0
this.#currentCallback = null
callback(null, full)
})
@ -26779,6 +26881,12 @@ const {
const { WebsocketFrameSend } = __nccwpck_require__(3264)
const { closeWebSocketConnection } = __nccwpck_require__(6897)
const { PerMessageDeflate } = __nccwpck_require__(9469)
const { MessageSizeExceededError } = __nccwpck_require__(8707)
function failWebsocketConnectionWithCode (ws, code, reason) {
closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason))
failWebsocketConnection(ws, reason)
}
// This code was influenced by ws released under the MIT license.
// Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@ -26787,6 +26895,7 @@ const { PerMessageDeflate } = __nccwpck_require__(9469)
class ByteParser extends Writable {
#buffers = []
#fragmentsBytes = 0
#byteOffset = 0
#loop = false
@ -26798,18 +26907,27 @@ class ByteParser extends Writable {
/** @type {Map<string, PerMessageDeflate>} */
#extensions
/** @type {number} */
#maxFragments
/** @type {number} */
#maxPayloadSize
/**
* @param {import('./websocket').WebSocket} ws
* @param {Map<string, string>|null} extensions
* @param {{ maxFragments?: number, maxPayloadSize?: number }} [options]
*/
constructor (ws, extensions) {
constructor (ws, extensions, options = {}) {
super()
this.ws = ws
this.#extensions = extensions == null ? new Map() : extensions
this.#maxFragments = options.maxFragments ?? 0
this.#maxPayloadSize = options.maxPayloadSize ?? 0
if (this.#extensions.has('permessage-deflate')) {
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
}
}
@ -26825,6 +26943,19 @@ class ByteParser extends Writable {
this.run(callback)
}
#validatePayloadLength () {
if (
this.#maxPayloadSize > 0 &&
!isControlFrame(this.#info.opcode) &&
this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize
) {
failWebsocketConnectionWithCode(this.ws, 1009, 'Payload size exceeds maximum allowed size')
return false
}
return true
}
/**
* Runs whenever a new chunk is received.
* Callback is called whenever there are no more chunks buffering,
@ -26913,6 +27044,10 @@ class ByteParser extends Writable {
if (payloadLength <= 125) {
this.#info.payloadLength = payloadLength
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (payloadLength === 126) {
this.#state = parserStates.PAYLOADLENGTH_16
} else if (payloadLength === 127) {
@ -26937,6 +27072,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = buffer.readUInt16BE(0)
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.PAYLOADLENGTH_64) {
if (this.#byteOffset < 8) {
return callback()
@ -26959,6 +27098,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = lower
this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.READ_DATA) {
if (this.#byteOffset < this.#info.payloadLength) {
return callback()
@ -26971,42 +27114,58 @@ class ByteParser extends Writable {
this.#state = parserStates.INFO
} else {
if (!this.#info.compressed) {
this.#fragments.push(body)
if (!this.writeFragments(body)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
// If the frame is not fragmented, a message has been received.
// If the frame is fragmented, it will terminate with a fin bit set
// and an opcode of 0 (continuation), therefore we handle that when
// parsing continuation frames, not here.
if (!this.#info.fragmented && this.#info.fin) {
const fullMessage = Buffer.concat(this.#fragments)
websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
this.#fragments.length = 0
websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
}
this.#state = parserStates.INFO
} else {
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
if (error) {
failWebsocketConnection(this.ws, error.message)
return
}
this.#extensions.get('permessage-deflate').decompress(
body,
this.#info.fin,
(error, data) => {
if (error) {
const code = error instanceof MessageSizeExceededError ? 1009 : 1007
failWebsocketConnectionWithCode(this.ws, code, error.message)
return
}
this.#fragments.push(data)
if (!this.writeFragments(data)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.run(callback)
return
}
websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
if (!this.#info.fin) {
this.#state = parserStates.INFO
this.#loop = true
this.#state = parserStates.INFO
this.run(callback)
return
}
websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments))
this.#loop = true
this.#state = parserStates.INFO
this.#fragments.length = 0
this.run(callback)
})
)
this.#loop = false
break
@ -27058,6 +27217,35 @@ class ByteParser extends Writable {
return buffer
}
writeFragments (fragment) {
if (
this.#maxFragments > 0 &&
this.#fragments.length === this.#maxFragments
) {
failWebsocketConnectionWithCode(this.ws, 1008, 'Too many message fragments')
return false
}
this.#fragmentsBytes += fragment.length
this.#fragments.push(fragment)
return true
}
consumeFragments () {
const fragments = this.#fragments
if (fragments.length === 1) {
this.#fragmentsBytes = 0
return fragments.shift()
}
const output = Buffer.concat(fragments, this.#fragmentsBytes)
this.#fragments = []
this.#fragmentsBytes = 0
return output
}
parseCloseBody (data) {
assert(data.length !== 1)
@ -28089,7 +28277,14 @@ class WebSocket extends EventTarget {
// once this happens, the connection is open
this[kResponse] = response
const parser = new ByteParser(this, parsedExtensions)
const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions
const maxFragments = webSocketOptions?.maxFragments
const maxPayloadSize = webSocketOptions?.maxPayloadSize
const parser = new ByteParser(this, parsedExtensions, {
maxFragments,
maxPayloadSize
})
parser.on('drain', onParserDrain)
parser.on('error', onParserError.bind(this))
@ -40112,8 +40307,11 @@ class InputService {
}) || null);
}
getServiceLogLevel() {
const configuredLevel = getInput(InputNames.ServiceLogLevel, { required: false });
if (configuredLevel && !Object.values(LogLevel).includes(configuredLevel)) {
const configuredLevel = getInput(InputNames.ServiceLogLevel, {
required: false,
});
if (configuredLevel &&
!Object.values(LogLevel).includes(configuredLevel)) {
throw new Error(`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`);
}
return configuredLevel || LogLevel.Debug;
@ -40195,12 +40393,12 @@ class DockerComposeService {
parts.push("Docker Compose command failed");
}
// Add error stream output if available
if (error.err && error.err.trim()) {
if (error.err?.trim()) {
parts.push("\nError output:");
parts.push(error.err.trim());
}
// Add standard output if available and different from error output
if (error.out && error.out.trim() && error.out !== error.err) {
if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:");
parts.push(error.out.trim());
}

View File

@ -1,3 +0,0 @@
import tsDevToolsCore from "@ts-dev-tools/core/dist/eslint-plugin-ts-dev-tools/index.js";
export default tsDevToolsCore.default;

13683
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,126 +1,64 @@
{
"name": "compose-action",
"description": "Docker Compose Action",
"version": "0.0.0",
"author": "hoverkraft",
"license": "MIT",
"homepage": "https://github.com/hoverkraft-tech/compose-action",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/hoverkraft-tech/compose-action.git"
},
"bugs": {
"url": "https://github.com/hoverkraft-tech/compose-action/issues"
},
"keywords": [
"actions",
"docker-compose"
],
"exports": {
".": "./dist/index.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/github": "^9.1.0",
"@actions/tool-cache": "^4.0.0",
"@octokit/action": "^8.0.4",
"docker-compose": "^1.4.2"
},
"devDependencies": {
"@ts-dev-tools/core": "^1.12.0",
"@vercel/ncc": "^0.38.4",
"eslint-plugin-github": "^6.0.0",
"eslint-plugin-jsonc": "^3.1.2"
},
"scripts": {
"package": "npm run package:index && npm run package:post",
"package:index": "ncc build src/index.ts -o dist --license licenses.txt",
"package:post": "ncc build src/post.ts -o dist/post && mv dist/post/index.js dist/post.js && rm -rf dist/post",
"package:watch": "npm run package -- --watch",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:ci": "npm run lint -- --output-file eslint-report.json --format json",
"all": "npm run format && npm run lint:ci && npm run test:ci && npm run package",
"build": "tsc --noEmit",
"format": "prettier --cache --write .",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit --maxWorkers=50%",
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --maxWorkers=25%",
"test:cov": "npm run test -- --coverage",
"test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --detectOpenHandles --forceExit",
"prepare": "ts-dev-tools install"
},
"jest": {
"preset": "ts-jest/presets/default-esm",
"verbose": true,
"clearMocks": true,
"testEnvironment": "node",
"extensionsToTreatAsEsm": [
".ts"
],
"moduleFileExtensions": [
"js",
"ts"
],
"testMatch": [
"**/*.test.ts",
"**/__tests__/**/*.[jt]s?(x)",
"**/?(*.)+(spec|test)?(.*).+(ts|tsx|js)"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"^.+\\.ts$": [
"ts-jest",
{
"useESM": true,
"tsconfig": "tsconfig.json"
}
]
},
"injectGlobals": true,
"coverageReporters": [
"json-summary",
"text",
"lcov"
],
"collectCoverage": true,
"collectCoverageFrom": [
"./src/**",
"**/src/**/*.[jt]s?(x)"
]
},
"prettier": {
"semi": true,
"printWidth": 100,
"trailingComma": "es5",
"plugins": []
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix"
]
},
"importSort": {
".js, .jsx, .ts, .tsx": {
"style": "module",
"parser": "typescript"
}
},
"tsDevTools": {
"version": "20250623095600-remove-prettier-oxc"
}
"name": "compose-action",
"description": "Docker Compose Action",
"version": "0.0.0",
"author": "hoverkraft",
"license": "MIT",
"homepage": "https://github.com/hoverkraft-tech/compose-action",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/hoverkraft-tech/compose-action.git"
},
"bugs": {
"url": "https://github.com/hoverkraft-tech/compose-action/issues"
},
"keywords": [
"actions",
"docker-compose"
],
"exports": {
".": "./dist/index.js"
},
"engines": {
"node": ">=24"
},
"dependencies": {
"@actions/core": "^3.0.1",
"@actions/github": "^9.1.1",
"@actions/tool-cache": "^4.0.0",
"@octokit/action": "^8.0.4",
"docker-compose": "^1.4.2"
},
"devDependencies": {
"@ts-dev-tools/core": "^1.12.4",
"@vercel/ncc": "^0.44.0"
},
"scripts": {
"package": "npm run package:index && npm run package:post",
"package:index": "ncc build src/index.ts -o dist --license licenses.txt",
"package:post": "ncc build src/post.ts -o dist/post && mv dist/post/index.js dist/post.js && rm -rf dist/post",
"package:watch": "npm run package -- --watch",
"lint": "biome lint --error-on-warnings .",
"lint:ci": "biome lint --error-on-warnings . --reporter=sarif | tee biome-report.sarif",
"all": "npm run format && npm run lint:ci && npm run test:ci && npm run package",
"build": "tsc --noEmit",
"format": "biome format --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --reporter=default --reporter=junit --outputFile=junit.xml --coverage.enabled --coverage.reporter=lcov --coverage.reporter=text",
"test:ci": "npm run test:cov",
"prepare": "ts-dev-tools install",
"check": "biome check --error-on-warnings --write .",
"vitest": "vitest"
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"tsDevTools": {
"version": "20260604100000-migrate-to-vitest"
}
}

View File

@ -1,193 +1,204 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core
const setFailedMock = jest.fn();
const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({
setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]),
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
vi.doMock("@actions/core", () => ({
setFailed: setFailedMock,
getInput: vi.fn().mockReturnValue(""),
getMultilineInput: vi.fn().mockReturnValue([]),
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
// Mock docker-compose
jest.unstable_mockModule("docker-compose", () => ({
upAll: jest.fn(),
upMany: jest.fn(),
down: jest.fn(),
logs: jest.fn(),
version: jest
.fn<() => Promise<{ data: { version: string } }>>()
.mockResolvedValue({ data: { version: "1.2.3" } }),
vi.doMock("docker-compose", () => ({
upAll: vi.fn(),
upMany: vi.fn(),
down: vi.fn(),
logs: vi.fn(),
version: vi
.fn<() => Promise<{ data: { version: string } }>>()
.mockResolvedValue({ data: { version: "1.2.3" } }),
}));
// Mock node:fs
jest.unstable_mockModule("node:fs", async () => {
const actualFs = await jest.requireActual<typeof import("node:fs")>("node:fs");
vi.doMock("node:fs", async () => {
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actualFs,
existsSync: jest.fn().mockReturnValue(true),
default: {
...actualFs,
existsSync: jest.fn().mockReturnValue(true),
},
};
return {
...actualFs,
existsSync: vi.fn().mockReturnValue(true),
default: {
...actualFs,
existsSync: vi.fn().mockReturnValue(true),
},
};
});
// Dynamic imports after mock setup
const { run } = await import("./index-runner.js");
const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js");
const { DockerComposeInstallerService } =
await import("./services/docker-compose-installer.service.js");
const { DockerComposeService } = await import("./services/docker-compose.service.js");
const { LoggerService, LogLevel } = await import(
"./services/logger.service.js"
);
const { DockerComposeInstallerService } = await import(
"./services/docker-compose-installer.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
describe("run", () => {
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>;
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>;
let installMock: jest.SpiedFunction<typeof DockerComposeInstallerService.prototype.install>;
let upMock: jest.SpiedFunction<typeof DockerComposeService.prototype.up>;
let infoMock: ReturnType<typeof vi.spyOn>;
let debugMock: ReturnType<typeof vi.spyOn>;
let getInputsMock: ReturnType<typeof vi.spyOn>;
let installMock: ReturnType<typeof vi.spyOn>;
let upMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {});
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {});
getInputsMock = jest.spyOn(InputService.prototype, "getInputs");
installMock = jest.spyOn(DockerComposeInstallerService.prototype, "install");
upMock = jest.spyOn(DockerComposeService.prototype, "up");
});
infoMock = vi
.spyOn(LoggerService.prototype, "info")
.mockImplementation(() => {});
debugMock = vi
.spyOn(LoggerService.prototype, "debug")
.mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
installMock = vi.spyOn(DockerComposeInstallerService.prototype, "install");
upMock = vi.spyOn(DockerComposeService.prototype, "up");
});
it("should install docker compose with specified version", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: "1.29.2",
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
it("should install docker compose with specified version", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: "1.29.2",
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
installMock.mockResolvedValue("1.29.2");
installMock.mockResolvedValue("1.29.2");
upMock.mockResolvedValue();
upMock.mockResolvedValue();
// Act
await run();
// Act
await run();
// Assert
expect(infoMock).toHaveBeenCalledWith("Setting up docker compose version 1.29.2");
// Assert
expect(infoMock).toHaveBeenCalledWith(
"Setting up docker compose version 1.29.2",
);
expect(debugMock).toHaveBeenCalledWith(
'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":"1.29.2","githubToken":null,"serviceLogLevel":"debug"}'
);
expect(debugMock).toHaveBeenCalledWith(
'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":"1.29.2","githubToken":null,"serviceLogLevel":"debug"}',
);
expect(installMock).toHaveBeenCalledWith({
composeVersion: "1.29.2",
cwd: "/current/working/dir",
githubToken: null,
});
expect(installMock).toHaveBeenCalledWith({
composeVersion: "1.29.2",
cwd: "/current/working/dir",
githubToken: null,
});
expect(upMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
upFlags: [],
services: [],
serviceLogger: debugMock,
});
expect(upMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
upFlags: [],
services: [],
serviceLogger: debugMock,
});
expect(setFailedMock).not.toHaveBeenCalled();
});
expect(setFailedMock).not.toHaveBeenCalled();
});
it("should bring up docker compose services", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
it("should bring up docker compose services", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
// Act
await run();
// Act
await run();
// Assert
expect(upMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
upFlags: [],
services: ["web"],
serviceLogger: debugMock,
});
expect(setFailedMock).not.toHaveBeenCalled();
});
// Assert
expect(upMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
upFlags: [],
services: ["web"],
serviceLogger: debugMock,
});
expect(setFailedMock).not.toHaveBeenCalled();
});
it("should handle errors and call setFailed", async () => {
// Arrange
const error = new Error("Test error");
upMock.mockRejectedValue(error);
it("should handle errors and call setFailed", async () => {
// Arrange
const error = new Error("Test error");
upMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
// Act
await run();
// Act
await run();
// Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: Test error");
});
// Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: Test error");
});
it("should handle unknown errors and call setFailed", async () => {
// Arrange
const error = "Test error";
upMock.mockRejectedValue(error);
it("should handle unknown errors and call setFailed", async () => {
// Arrange
const error = "Test error";
upMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
// Act
await run();
// Act
await run();
// Assert
expect(setFailedMock).toHaveBeenCalledWith('"Test error"');
});
// Assert
expect(setFailedMock).toHaveBeenCalledWith('"Test error"');
});
});

View File

@ -10,42 +10,42 @@ import { ManualInstallerAdapter } from "./services/installer-adapter/manual-inst
* @returns {Promise<void>} Resolves when the action is complete.
*/
export async function run(): Promise<void> {
try {
const loggerService = new LoggerService();
const inputService = new InputService();
const dockerComposeInstallerService = new DockerComposeInstallerService(
new ManualInstallerAdapter()
);
const dockerComposeService = new DockerComposeService();
try {
const loggerService = new LoggerService();
const inputService = new InputService();
const dockerComposeInstallerService = new DockerComposeInstallerService(
new ManualInstallerAdapter(),
);
const dockerComposeService = new DockerComposeService();
const inputs = inputService.getInputs();
loggerService.debug(`inputs: ${JSON.stringify(inputs)}`);
const inputs = inputService.getInputs();
loggerService.debug(`inputs: ${JSON.stringify(inputs)}`);
loggerService.info(
"Setting up docker compose" +
(inputs.composeVersion ? ` version ${inputs.composeVersion}` : "")
);
loggerService.info(
"Setting up docker compose" +
(inputs.composeVersion ? ` version ${inputs.composeVersion}` : ""),
);
const installedVersion = await dockerComposeInstallerService.install({
composeVersion: inputs.composeVersion,
cwd: inputs.cwd,
githubToken: inputs.githubToken,
});
const installedVersion = await dockerComposeInstallerService.install({
composeVersion: inputs.composeVersion,
cwd: inputs.cwd,
githubToken: inputs.githubToken,
});
loggerService.info(`docker compose version: ${installedVersion}`);
loggerService.info(`docker compose version: ${installedVersion}`);
loggerService.info("Bringing up docker compose service(s)");
await dockerComposeService.up({
dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
upFlags: inputs.upFlags,
services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
loggerService.info("docker compose service(s) are up");
} catch (error) {
setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`);
}
loggerService.info("Bringing up docker compose service(s)");
await dockerComposeService.up({
dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
upFlags: inputs.upFlags,
services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
loggerService.info("docker compose service(s) are up");
} catch (error) {
setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`);
}
}

View File

@ -1,109 +1,127 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core
const setFailedMock = jest.fn();
const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({
setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]),
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
vi.doMock("@actions/core", () => ({
setFailed: setFailedMock,
getInput: vi.fn().mockReturnValue(""),
getMultilineInput: vi.fn().mockReturnValue([]),
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
// Mock docker-compose
jest.unstable_mockModule("docker-compose", () => ({
upAll: jest.fn(),
upMany: jest.fn(),
down: jest.fn(),
logs: jest.fn(),
version: jest
.fn<() => Promise<{ data: { version: string } }>>()
.mockResolvedValue({ data: { version: "1.2.3" } }),
vi.doMock("docker-compose", () => ({
upAll: vi.fn(),
upMany: vi.fn(),
down: vi.fn(),
logs: vi.fn(),
version: vi
.fn<() => Promise<{ data: { version: string } }>>()
.mockResolvedValue({ data: { version: "1.2.3" } }),
}));
// Mock node:fs
jest.unstable_mockModule("node:fs", async () => {
const actualFs = await jest.requireActual<typeof import("node:fs")>("node:fs");
vi.doMock("node:fs", async () => {
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actualFs,
existsSync: jest.fn().mockReturnValue(true),
default: {
...actualFs,
existsSync: jest.fn().mockReturnValue(true),
},
};
return {
...actualFs,
existsSync: vi.fn().mockReturnValue(true),
default: {
...actualFs,
existsSync: vi.fn().mockReturnValue(true),
},
};
});
// Dynamic imports after mock setup
const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js");
const { DockerComposeInstallerService } =
await import("./services/docker-compose-installer.service.js");
const { DockerComposeService } = await import("./services/docker-compose.service.js");
const { LoggerService, LogLevel } = await import(
"./services/logger.service.js"
);
const { DockerComposeInstallerService } = await import(
"./services/docker-compose-installer.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>;
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>;
let installMock: jest.SpiedFunction<typeof DockerComposeInstallerService.prototype.install>;
let upMock: jest.SpiedFunction<typeof DockerComposeService.prototype.up>;
let getInputsMock: ReturnType<typeof vi.spyOn>;
let debugMock: ReturnType<typeof vi.spyOn>;
let infoMock: ReturnType<typeof vi.spyOn>;
let installMock: ReturnType<typeof vi.spyOn>;
let upMock: ReturnType<typeof vi.spyOn>;
describe("index", () => {
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {});
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {});
getInputsMock = jest.spyOn(InputService.prototype, "getInputs");
installMock = jest.spyOn(DockerComposeInstallerService.prototype, "install");
upMock = jest.spyOn(DockerComposeService.prototype, "up");
});
infoMock = vi
.spyOn(LoggerService.prototype, "info")
.mockImplementation(() => {});
debugMock = vi
.spyOn(LoggerService.prototype, "debug")
.mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
installMock = vi.spyOn(DockerComposeInstallerService.prototype, "install");
upMock = vi.spyOn(DockerComposeService.prototype, "up");
});
it("calls run when imported", async () => {
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
it("calls run when imported", async () => {
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
installMock.mockResolvedValue("1.2.3");
upMock.mockResolvedValueOnce();
installMock.mockResolvedValue("1.2.3");
upMock.mockResolvedValueOnce();
await import("./index.js");
await new Promise((resolve) => setTimeout(resolve, 0));
await import("./index.js");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(infoMock).toHaveBeenNthCalledWith(1, "Setting up docker compose");
expect(infoMock).toHaveBeenNthCalledWith(2, "docker compose version: 1.2.3");
expect(infoMock).toHaveBeenNthCalledWith(1, "Setting up docker compose");
expect(infoMock).toHaveBeenNthCalledWith(
2,
"docker compose version: 1.2.3",
);
// Verify that all of the functions were called correctly
expect(debugMock).toHaveBeenNthCalledWith(
1,
'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":null,"githubToken":null,"serviceLogLevel":"debug"}'
);
// Verify that all of the functions were called correctly
expect(debugMock).toHaveBeenNthCalledWith(
1,
'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":null,"githubToken":null,"serviceLogLevel":"debug"}',
);
expect(infoMock).toHaveBeenNthCalledWith(3, "Bringing up docker compose service(s)");
expect(infoMock).toHaveBeenNthCalledWith(
3,
"Bringing up docker compose service(s)",
);
expect(upMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
cwd: "/current/working/dir",
serviceLogger: debugMock,
});
expect(upMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
cwd: "/current/working/dir",
serviceLogger: debugMock,
});
expect(setFailedMock).not.toHaveBeenCalled();
expect(setFailedMock).not.toHaveBeenCalled();
expect(infoMock).toHaveBeenNthCalledWith(4, "docker compose service(s) are up");
});
expect(infoMock).toHaveBeenNthCalledWith(
4,
"docker compose service(s) are up",
);
});
});

View File

@ -1,193 +1,205 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core
const setFailedMock = jest.fn();
const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({
setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]),
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
vi.doMock("@actions/core", () => ({
setFailed: setFailedMock,
getInput: vi.fn().mockReturnValue(""),
getMultilineInput: vi.fn().mockReturnValue([]),
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
// Mock docker-compose
const logsMock = jest.fn();
const downMock = jest.fn();
const logsMock = vi.fn();
const downMock = vi.fn();
jest.unstable_mockModule("docker-compose", () => ({
logs: logsMock,
down: downMock,
upAll: jest.fn(),
upMany: jest.fn(),
vi.doMock("docker-compose", () => ({
logs: logsMock,
down: downMock,
upAll: vi.fn(),
upMany: vi.fn(),
}));
// Mock node:fs
jest.unstable_mockModule("node:fs", () => ({
existsSync: jest.fn().mockReturnValue(true),
default: { existsSync: jest.fn().mockReturnValue(true) },
vi.doMock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: vi.fn().mockReturnValue(true) },
}));
// Dynamic imports after mock setup
const { run } = await import("./post-runner.js");
const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js");
const { DockerComposeService } = await import("./services/docker-compose.service.js");
const { LoggerService, LogLevel } = await import(
"./services/logger.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
describe("run", () => {
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>;
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>;
let serviceDownMock: jest.SpiedFunction<typeof DockerComposeService.prototype.down>;
let serviceLogsMock: jest.SpiedFunction<typeof DockerComposeService.prototype.logs>;
let infoMock: ReturnType<typeof vi.spyOn>;
let debugMock: ReturnType<typeof vi.spyOn>;
let getInputsMock: ReturnType<typeof vi.spyOn>;
let serviceDownMock: ReturnType<typeof vi.spyOn>;
let serviceLogsMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {});
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {});
getInputsMock = jest.spyOn(InputService.prototype, "getInputs");
serviceDownMock = jest.spyOn(DockerComposeService.prototype, "down");
serviceLogsMock = jest.spyOn(DockerComposeService.prototype, "logs");
});
infoMock = vi
.spyOn(LoggerService.prototype, "info")
.mockImplementation(() => {});
debugMock = vi
.spyOn(LoggerService.prototype, "debug")
.mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down");
serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs");
});
it("should bring down docker compose service(s) and log output", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
it("should bring down docker compose service(s) and log output", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
serviceDownMock.mockResolvedValue();
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
serviceDownMock.mockResolvedValue();
// Act
await run();
// Act
await run();
// Assert
expect(serviceLogsMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
services: [],
serviceLogger: debugMock,
});
// Assert
expect(serviceLogsMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
services: [],
serviceLogger: debugMock,
});
expect(serviceDownMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
downFlags: [],
serviceLogger: debugMock,
});
expect(serviceDownMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
downFlags: [],
serviceLogger: debugMock,
});
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs");
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled();
});
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs");
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled();
});
it("should log docker composer errors if any", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
it("should log docker composer errors if any", async () => {
// Arrange
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
serviceLogsMock.mockResolvedValue({
error: "test logs error",
output: "test logs output",
});
serviceLogsMock.mockResolvedValue({
error: "test logs error",
output: "test logs output",
});
serviceDownMock.mockResolvedValue();
serviceDownMock.mockResolvedValue();
// Act
await run();
// Act
await run();
// Assert
expect(debugMock).toHaveBeenCalledWith("docker compose error:\ntest logs error");
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs output");
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
});
// Assert
expect(debugMock).toHaveBeenCalledWith(
"docker compose error:\ntest logs error",
);
expect(debugMock).toHaveBeenCalledWith(
"docker compose logs:\ntest logs output",
);
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
});
it("should set failed when an error occurs", async () => {
// Arrange
getInputsMock.mockImplementation(() => {
throw new Error("An error occurred");
});
it("should set failed when an error occurs", async () => {
// Arrange
getInputsMock.mockImplementation(() => {
throw new Error("An error occurred");
});
// Act
await run();
// Act
await run();
// Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: An error occurred");
});
// Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: An error occurred");
});
it("should handle errors and call setFailed", async () => {
// Arrange
const error = new Error("Test error");
serviceDownMock.mockRejectedValue(error);
it("should handle errors and call setFailed", async () => {
// Arrange
const error = new Error("Test error");
serviceDownMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
// Act
await run();
// Act
await run();
// Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: Test error");
});
// Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: Test error");
});
it("should handle unknown errors and call setFailed", async () => {
// Arrange
const error = "Test error";
serviceDownMock.mockRejectedValue(error);
it("should handle unknown errors and call setFailed", async () => {
// Arrange
const error = "Test error";
serviceDownMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
// Act
await run();
// Act
await run();
// Assert
expect(setFailedMock).toHaveBeenCalledWith('"Test error"');
});
// Assert
expect(setFailedMock).toHaveBeenCalledWith('"Test error"');
});
});

View File

@ -8,39 +8,39 @@ import { DockerComposeService } from "./services/docker-compose.service.js";
* @returns {Promise<void>} Resolves when the action is complete.
*/
export async function run(): Promise<void> {
try {
const loggerService = new LoggerService();
const inputService = new InputService();
const dockerComposeService = new DockerComposeService();
try {
const loggerService = new LoggerService();
const inputService = new InputService();
const dockerComposeService = new DockerComposeService();
const inputs = inputService.getInputs();
const inputs = inputService.getInputs();
const { error, output } = await dockerComposeService.logs({
dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
const { error, output } = await dockerComposeService.logs({
dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
if (error) {
loggerService.debug("docker compose error:\n" + error);
}
if (error) {
loggerService.debug("docker compose error:\n" + error);
}
loggerService.debug("docker compose logs:\n" + output);
loggerService.debug("docker compose logs:\n" + output);
await dockerComposeService.down({
dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
downFlags: inputs.downFlags,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
await dockerComposeService.down({
dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags,
cwd: inputs.cwd,
downFlags: inputs.downFlags,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
});
loggerService.info("docker compose is down");
} catch (error) {
setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`);
}
loggerService.info("docker compose is down");
} catch (error) {
setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`);
}
}

View File

@ -1,97 +1,108 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core
const setFailedMock = jest.fn();
const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({
setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]),
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
vi.doMock("@actions/core", () => ({
setFailed: setFailedMock,
getInput: vi.fn().mockReturnValue(""),
getMultilineInput: vi.fn().mockReturnValue([]),
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
// Mock docker-compose
const logsMock = jest.fn();
const downMock = jest.fn();
const logsMock = vi.fn();
const downMock = vi.fn();
jest.unstable_mockModule("docker-compose", () => ({
logs: logsMock,
down: downMock,
upAll: jest.fn(),
upMany: jest.fn(),
vi.doMock("docker-compose", () => ({
logs: logsMock,
down: downMock,
upAll: vi.fn(),
upMany: vi.fn(),
}));
// Mock node:fs
jest.unstable_mockModule("node:fs", () => ({
existsSync: jest.fn().mockReturnValue(true),
default: { existsSync: jest.fn().mockReturnValue(true) },
vi.doMock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: vi.fn().mockReturnValue(true) },
}));
// Dynamic imports after mock setup
const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js");
const { DockerComposeService } = await import("./services/docker-compose.service.js");
const { LoggerService, LogLevel } = await import(
"./services/logger.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>;
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>;
let serviceLogsMock: jest.SpiedFunction<typeof DockerComposeService.prototype.logs>;
let serviceDownMock: jest.SpiedFunction<typeof DockerComposeService.prototype.down>;
let getInputsMock: ReturnType<typeof vi.spyOn>;
let debugMock: ReturnType<typeof vi.spyOn>;
let infoMock: ReturnType<typeof vi.spyOn>;
let serviceLogsMock: ReturnType<typeof vi.spyOn>;
let serviceDownMock: ReturnType<typeof vi.spyOn>;
describe("post", () => {
beforeEach(() => {
jest.clearAllMocks();
beforeEach(() => {
vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {});
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {});
getInputsMock = jest.spyOn(InputService.prototype, "getInputs");
serviceLogsMock = jest.spyOn(DockerComposeService.prototype, "logs");
serviceDownMock = jest.spyOn(DockerComposeService.prototype, "down");
});
infoMock = vi
.spyOn(LoggerService.prototype, "info")
.mockImplementation(() => {});
debugMock = vi
.spyOn(LoggerService.prototype, "debug")
.mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs");
serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down");
});
it("calls run when imported", async () => {
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
it("calls run when imported", async () => {
getInputsMock.mockImplementation(() => ({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
services: [],
composeFlags: [],
upFlags: [],
downFlags: [],
cwd: "/current/working/dir",
composeVersion: null,
githubToken: null,
serviceLogLevel: LogLevel.Debug,
}));
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
serviceDownMock.mockResolvedValueOnce();
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
serviceDownMock.mockResolvedValueOnce();
await import("./post.js");
await new Promise((resolve) => setTimeout(resolve, 0));
await import("./post.js");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(serviceLogsMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
services: [],
serviceLogger: debugMock,
});
expect(serviceLogsMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
services: [],
serviceLogger: debugMock,
});
expect(serviceDownMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
downFlags: [],
serviceLogger: debugMock,
});
expect(serviceDownMock).toHaveBeenCalledWith({
dockerFlags: [],
composeFiles: ["docker-compose.yml"],
composeFlags: [],
cwd: "/current/working/dir",
downFlags: [],
serviceLogger: debugMock,
});
expect(debugMock).toHaveBeenNthCalledWith(1, "docker compose logs:\ntest logs");
expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down");
expect(debugMock).toHaveBeenNthCalledWith(
1,
"docker compose logs:\ntest logs",
);
expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled();
});
expect(setFailedMock).not.toHaveBeenCalled();
});
});

View File

@ -1,272 +1,302 @@
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { IDockerComposeResult } from "docker-compose";
import { MockAgent, setGlobalDispatcher } from "undici";
// Mock docker-compose before importing the module under test
const versionMock = jest.fn<() => Promise<IDockerComposeResult & { data: { version: string } }>>();
const versionMock =
vi.fn<() => Promise<IDockerComposeResult & { data: { version: string } }>>();
jest.unstable_mockModule("docker-compose", () => ({
version: versionMock,
vi.doMock("docker-compose", () => ({
version: versionMock,
}));
// Create manual installer adapter mock
const manualInstallerAdapterMock = {
install: jest.fn<(version: string) => Promise<void>>(),
install: vi.fn<(version: string) => Promise<void>>(),
};
// Dynamic import after mock setup
const { DockerComposeInstallerService } = await import("./docker-compose-installer.service.js");
const { DockerComposeInstallerService } = await import(
"./docker-compose-installer.service.js"
);
describe("DockerComposeInstallerService", () => {
let mockAgent: MockAgent;
let service: InstanceType<typeof DockerComposeInstallerService>;
let mockAgent: MockAgent;
let service: InstanceType<typeof DockerComposeInstallerService>;
const composeVersionResponse = (version: string) => ({
exitCode: 0,
out: "",
err: "",
data: {
version,
},
});
const composeVersionResponse = (version: string) => ({
exitCode: 0,
out: "",
err: "",
data: {
version,
},
});
const installCompose = (composeVersion: string | null, githubToken: string | null) =>
service.install({
composeVersion,
cwd: "/path/to/cwd",
githubToken,
});
const installCompose = (
composeVersion: string | null,
githubToken: string | null,
) =>
service.install({
composeVersion,
cwd: "/path/to/cwd",
githubToken,
});
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, "platform", {
value: platform,
});
};
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, "platform", {
value: platform,
});
};
const mockLatestRelease = (version: string) => {
const mockClient = mockAgent.get("https://api.github.com");
mockClient
.intercept({
path: "/repos/docker/compose/releases/latest",
method: "GET",
})
.reply(
200,
{
tag_name: version,
},
{
headers: {
"content-type": "application/json",
},
}
);
setGlobalDispatcher(mockClient);
Object.defineProperty(globalThis, "fetch", {
value: jest.fn(),
});
};
const mockLatestRelease = (version: string) => {
const mockClient = mockAgent.get("https://api.github.com");
mockClient
.intercept({
path: "/repos/docker/compose/releases/latest",
method: "GET",
})
.reply(
200,
{
tag_name: version,
},
{
headers: {
"content-type": "application/json",
},
},
);
setGlobalDispatcher(mockClient);
Object.defineProperty(globalThis, "fetch", {
value: vi.fn(),
});
};
beforeEach(() => {
jest.clearAllMocks();
mockAgent = new MockAgent();
mockAgent.disableNetConnect();
beforeEach(() => {
vi.clearAllMocks();
mockAgent = new MockAgent();
mockAgent.disableNetConnect();
service = new DockerComposeInstallerService(manualInstallerAdapterMock as never);
});
service = new DockerComposeInstallerService(
manualInstallerAdapterMock as never,
);
});
afterEach(() => {
jest.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("install", () => {
it("should install latest when compose version is not specified and Compose is missing", async () => {
// Arrange: first call to version() fails (Compose missing)
versionMock.mockRejectedValueOnce(new Error("version not installed"));
describe("install", () => {
it("should install latest when compose version is not specified and Compose is missing", async () => {
// Arrange: first call to version() fails (Compose missing)
versionMock.mockRejectedValueOnce(new Error("version not installed"));
const latestVersion = "v2.0.0";
mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux");
const latestVersion = "v2.0.0";
mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux");
// Act
const result = await installCompose(null, "token");
// Act
const result = await installCompose(null, "token");
// Assert
expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(latestVersion);
});
// Assert
expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
latestVersion,
);
});
it("should return current version when no version is provided", async () => {
// Arrange
versionMock.mockResolvedValue(composeVersionResponse("2.0.0"));
it("should return current version when no version is provided", async () => {
// Arrange
versionMock.mockResolvedValue(composeVersionResponse("2.0.0"));
// Act
const result = await installCompose(null, null);
// Act
const result = await installCompose(null, null);
// Assert
expect(result).toBe("2.0.0");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
// Assert
expect(result).toBe("2.0.0");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
it("should not install anything when expected version is already installed", async () => {
// Arrange
versionMock.mockResolvedValue(composeVersionResponse("1.2.3"));
it("should not install anything when expected version is already installed", async () => {
// Arrange
versionMock.mockResolvedValue(composeVersionResponse("1.2.3"));
// Act
const result = await installCompose("v1.2.3", null);
// Act
const result = await installCompose("v1.2.3", null);
// Assert
expect(result).toBe("1.2.3");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
// Assert
expect(result).toBe("1.2.3");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
it("should install the requested version if it is not already installed", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
it("should install the requested version if it is not already installed", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const expectedVersion = "1.3.0";
versionMock.mockResolvedValueOnce(composeVersionResponse(expectedVersion));
setPlatform("linux");
const expectedVersion = "1.3.0";
versionMock.mockResolvedValueOnce(
composeVersionResponse(expectedVersion),
);
setPlatform("linux");
// Act
const result = await installCompose(expectedVersion, null);
// Act
const result = await installCompose(expectedVersion, null);
// Assert
expect(result).toBe(expectedVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(expectedVersion);
});
// Assert
expect(result).toBe(expectedVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
expectedVersion,
);
});
it("should install the latest version if requested", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
it("should install the latest version if requested", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const latestVersion = "v1.4.0";
mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux");
const latestVersion = "v1.4.0";
mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux");
// Act
const result = await installCompose("latest", "token");
// Act
const result = await installCompose("latest", "token");
// Assert
expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(latestVersion);
});
// Assert
expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
latestVersion,
);
});
it("should throw an error if the latest version if requested and no Github token is provided", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
it("should throw an error if the latest version if requested and no Github token is provided", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
// Act & Assert
await expect(installCompose("latest", null)).rejects.toThrow(
"GitHub token is required to install the latest version"
);
});
// Act & Assert
await expect(installCompose("latest", null)).rejects.toThrow(
"GitHub token is required to install the latest version",
);
});
it("should throw an error on unsupported platforms", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
it("should throw an error on unsupported platforms", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const expectedVersion = "1.3.0";
versionMock.mockResolvedValueOnce(composeVersionResponse(expectedVersion));
setPlatform("win32");
const expectedVersion = "1.3.0";
versionMock.mockResolvedValueOnce(
composeVersionResponse(expectedVersion),
);
setPlatform("win32");
// Act & Assert
await expect(installCompose(expectedVersion, null)).rejects.toThrow(
`Unsupported platform: win32`
);
// Act & Assert
await expect(installCompose(expectedVersion, null)).rejects.toThrow(
`Unsupported platform: win32`,
);
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
it("should install when version check fails", async () => {
// Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version not installed"));
it("should install when version check fails", async () => {
// Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version not installed"));
const installedVersion = "2.0.0";
const installedVersion = "2.0.0";
// After installation, version() returns the new version
versionMock.mockResolvedValueOnce(composeVersionResponse(installedVersion));
setPlatform("linux");
// After installation, version() returns the new version
versionMock.mockResolvedValueOnce(
composeVersionResponse(installedVersion),
);
setPlatform("linux");
// Act
const result = await installCompose(installedVersion, "token");
// Act
const result = await installCompose(installedVersion, "token");
// Assert
expect(result).toBe(installedVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(installedVersion);
});
// Assert
expect(result).toBe(installedVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
installedVersion,
);
});
it("should install latest version when missing or unspecified", async () => {
// Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version check failed"));
// second call finds newly installed version
versionMock.mockResolvedValueOnce(composeVersionResponse("v1.4.0"));
it("should install latest version when missing or unspecified", async () => {
// Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version check failed"));
// second call finds newly installed version
versionMock.mockResolvedValueOnce(composeVersionResponse("v1.4.0"));
const latestVersion = "v1.4.0";
mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux");
const latestVersion = "v1.4.0";
mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux");
// Act
const result = await installCompose("latest", "token");
// Act
const result = await installCompose("latest", "token");
// Assert
expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(latestVersion);
});
// Assert
expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
latestVersion,
);
});
it("should throw if Compose is missing and no GitHub token is provided", async () => {
// Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version check failed"));
setPlatform("linux");
it("should throw if Compose is missing and no GitHub token is provided", async () => {
// Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version check failed"));
setPlatform("linux");
await expect(installCompose("latest", null)).rejects.toThrow(
"GitHub token is required to install the latest version"
);
});
await expect(installCompose("latest", null)).rejects.toThrow(
"GitHub token is required to install the latest version",
);
});
it("should not install when the version is already installed and no version is specified", async () => {
// Arrange
versionMock.mockResolvedValue(composeVersionResponse("1.2.3"));
it("should not install when the version is already installed and no version is specified", async () => {
// Arrange
versionMock.mockResolvedValue(composeVersionResponse("1.2.3"));
// Act
const result = await installCompose("", null);
// Act
const result = await installCompose("", null);
// Assert
expect(result).toBe("1.2.3");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
// Assert
expect(result).toBe("1.2.3");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
});
it("should throw when installed version does not match target", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
it("should throw when installed version does not match target", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const targetVersion = "v1.4.0";
versionMock.mockResolvedValueOnce(composeVersionResponse("1.3.0"));
setPlatform("linux");
const targetVersion = "v1.4.0";
versionMock.mockResolvedValueOnce(composeVersionResponse("1.3.0"));
setPlatform("linux");
// Act & Assert
await expect(installCompose(targetVersion, "token")).rejects.toThrow(
`Failed to install Docker Compose version "${targetVersion}", installed version is "1.3.0"`
);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(targetVersion);
});
// Act & Assert
await expect(installCompose(targetVersion, "token")).rejects.toThrow(
`Failed to install Docker Compose version "${targetVersion}", installed version is "1.3.0"`,
);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
targetVersion,
);
});
it("should throw with unknown installed version when post-install version check fails", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
it("should throw with unknown installed version when post-install version check fails", async () => {
// Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const targetVersion = "v1.4.0";
versionMock.mockRejectedValueOnce(new Error("version check failed after install"));
setPlatform("linux");
const targetVersion = "v1.4.0";
versionMock.mockRejectedValueOnce(
new Error("version check failed after install"),
);
setPlatform("linux");
// Act & Assert
await expect(installCompose(targetVersion, "token")).rejects.toThrow(
`Failed to install Docker Compose version "${targetVersion}", installed version is "unknown"`
);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(targetVersion);
});
});
// Act & Assert
await expect(installCompose(targetVersion, "token")).rejects.toThrow(
`Failed to install Docker Compose version "${targetVersion}", installed version is "unknown"`,
);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
targetVersion,
);
});
});
});

View File

@ -1,96 +1,108 @@
import * as github from "@actions/github";
import { version } from "docker-compose";
import { COMPOSE_VERSION_LATEST, Inputs } from "./input.service.js";
import { ManualInstallerAdapter } from "./installer-adapter/manual-installer-adapter.js";
import { COMPOSE_VERSION_LATEST, type Inputs } from "./input.service.js";
import type { ManualInstallerAdapter } from "./installer-adapter/manual-installer-adapter.js";
export type InstallInputs = {
composeVersion: Inputs["composeVersion"];
cwd: Inputs["cwd"];
githubToken: Inputs["githubToken"];
composeVersion: Inputs["composeVersion"];
cwd: Inputs["cwd"];
githubToken: Inputs["githubToken"];
};
export type VersionInputs = {
cwd: Inputs["cwd"];
cwd: Inputs["cwd"];
};
export class DockerComposeInstallerService {
constructor(private readonly manualInstallerAdapter: ManualInstallerAdapter) {}
constructor(
private readonly manualInstallerAdapter: ManualInstallerAdapter,
) {}
async install({ composeVersion, cwd, githubToken }: InstallInputs): Promise<string> {
const currentVersion = await this.version({ cwd });
async install({
composeVersion,
cwd,
githubToken,
}: InstallInputs): Promise<string> {
const currentVersion = await this.version({ cwd });
const normalizedCurrentVersion = currentVersion ? this.normalizeVersion(currentVersion) : null;
const normalizedRequestedVersion = composeVersion
? this.normalizeVersion(composeVersion)
: null;
const normalizedCurrentVersion = currentVersion
? this.normalizeVersion(currentVersion)
: null;
const normalizedRequestedVersion = composeVersion
? this.normalizeVersion(composeVersion)
: null;
const needsInstall =
!currentVersion ||
(composeVersion && normalizedRequestedVersion !== normalizedCurrentVersion);
if (!needsInstall) {
return currentVersion;
}
const needsInstall =
!currentVersion ||
(composeVersion &&
normalizedRequestedVersion !== normalizedCurrentVersion);
if (!needsInstall) {
return currentVersion;
}
let targetVersion = composeVersion || COMPOSE_VERSION_LATEST;
let targetVersion = composeVersion || COMPOSE_VERSION_LATEST;
if (targetVersion === COMPOSE_VERSION_LATEST) {
if (!githubToken) {
throw new Error("GitHub token is required to install the latest version");
}
targetVersion = await this.getLatestVersion(githubToken);
}
if (targetVersion === COMPOSE_VERSION_LATEST) {
if (!githubToken) {
throw new Error(
"GitHub token is required to install the latest version",
);
}
targetVersion = await this.getLatestVersion(githubToken);
}
await this.installVersion(targetVersion);
await this.installVersion(targetVersion);
const installedVersion = await this.version({ cwd });
const installedVersion = await this.version({ cwd });
if (
!installedVersion ||
this.normalizeVersion(installedVersion) !== this.normalizeVersion(targetVersion)
) {
throw new Error(
`Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"`
);
}
if (
!installedVersion ||
this.normalizeVersion(installedVersion) !==
this.normalizeVersion(targetVersion)
) {
throw new Error(
`Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"`,
);
}
return installedVersion;
}
return installedVersion;
}
private async version({ cwd }: VersionInputs): Promise<string | null> {
try {
const result = await version({
cwd,
});
return result.data.version;
} catch {
// If version check fails (e.g., Docker Compose not installed), return null
return null;
}
}
private async version({ cwd }: VersionInputs): Promise<string | null> {
try {
const result = await version({
cwd,
});
return result.data.version;
} catch {
// If version check fails (e.g., Docker Compose not installed), return null
return null;
}
}
private async getLatestVersion(githubToken: string): Promise<string> {
const octokit = github.getOctokit(githubToken);
private async getLatestVersion(githubToken: string): Promise<string> {
const octokit = github.getOctokit(githubToken);
const response = await octokit.rest.repos.getLatestRelease({
owner: "docker",
repo: "compose",
});
const response = await octokit.rest.repos.getLatestRelease({
owner: "docker",
repo: "compose",
});
return response.data.tag_name;
}
return response.data.tag_name;
}
private normalizeVersion(version: string): string {
return version.replace(/^v/i, "");
}
private normalizeVersion(version: string): string {
return version.replace(/^v/i, "");
}
private async installVersion(version: string): Promise<void> {
switch (process.platform) {
case "linux":
case "darwin":
await this.manualInstallerAdapter.install(version);
break;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
private async installVersion(version: string): Promise<void> {
switch (process.platform) {
case "linux":
case "darwin":
await this.manualInstallerAdapter.install(version);
break;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
}

View File

@ -1,340 +1,391 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
import type {
IDockerComposeLogOptions,
IDockerComposeOptions,
IDockerComposeResult,
IDockerComposeLogOptions,
IDockerComposeOptions,
IDockerComposeResult,
} from "docker-compose";
// Mock docker-compose before importing the module under test
const upAllMock = jest.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const upAllMock =
vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const upManyMock =
jest.fn<(services: string[], options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const downMock = jest.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
vi.fn<
(
services: string[],
options: IDockerComposeOptions,
) => Promise<IDockerComposeResult>
>();
const downMock =
vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const logsMock =
jest.fn<
(services: string[], options: IDockerComposeLogOptions) => Promise<IDockerComposeResult>
>();
vi.fn<
(
services: string[],
options: IDockerComposeLogOptions,
) => Promise<IDockerComposeResult>
>();
jest.unstable_mockModule("docker-compose", () => ({
upAll: upAllMock,
upMany: upManyMock,
down: downMock,
logs: logsMock,
vi.doMock("docker-compose", () => ({
upAll: upAllMock,
upMany: upManyMock,
down: downMock,
logs: logsMock,
}));
// Dynamic import after mock setup
const { DockerComposeService } = await import("./docker-compose.service.js");
describe("DockerComposeService", () => {
let service: InstanceType<typeof DockerComposeService>;
let service: InstanceType<typeof DockerComposeService>;
beforeEach(() => {
jest.clearAllMocks();
service = new DockerComposeService();
});
beforeEach(() => {
vi.clearAllMocks();
service = new DockerComposeService();
});
describe("up", () => {
it("should call up with correct options", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
describe("up", () => {
it("should call up with correct options", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
await service.up(upInputs);
await service.up(upInputs);
expect(upAllMock).toHaveBeenCalledWith({
composeOptions: [],
commandOptions: [],
config: ["docker-compose.yml"],
executable: {
executablePath: "docker",
options: [],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
expect(upAllMock).toHaveBeenCalledWith({
composeOptions: [],
commandOptions: [],
config: ["docker-compose.yml"],
executable: {
executablePath: "docker",
options: [],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
// Ensure callback is calling the service logger
const callback = (upAllMock.mock.calls[0][0] as IDockerComposeOptions)?.callback;
expect(callback).toBeDefined();
// Ensure callback is calling the service logger
const callback = (upAllMock.mock.calls[0][0] as IDockerComposeOptions)
?.callback;
expect(callback).toBeDefined();
const message = "test log output";
const message = "test log output";
if (callback) {
callback(Buffer.from(message));
}
if (callback) {
callback(Buffer.from(message));
}
expect(upInputs.serviceLogger).toHaveBeenCalledWith("test log output");
});
expect(upInputs.serviceLogger).toHaveBeenCalledWith("test log output");
});
it("should call up with specific docker flags", async () => {
const upInputs = {
dockerFlags: ["--context", "dev"],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
it("should call up with specific docker flags", async () => {
const upInputs = {
dockerFlags: ["--context", "dev"],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
await service.up(upInputs);
await service.up(upInputs);
expect(upAllMock).toHaveBeenCalledWith({
composeOptions: [],
commandOptions: [],
config: ["docker-compose.yml"],
executable: {
executablePath: "docker",
options: ["--context", "dev"],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
});
expect(upAllMock).toHaveBeenCalledWith({
composeOptions: [],
commandOptions: [],
config: ["docker-compose.yml"],
executable: {
executablePath: "docker",
options: ["--context", "dev"],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
});
it("should call up with specific services", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[],
upFlags: ["--build"],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
it("should call up with specific services", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[],
upFlags: ["--build"],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
upManyMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
upManyMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
await service.up(upInputs);
await service.up(upInputs);
expect(upManyMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
composeOptions: [],
commandOptions: ["--build"],
config: ["docker-compose.yml"],
cwd: "/current/working/dir",
callback: expect.any(Function),
executable: {
executablePath: "docker",
options: [],
},
});
});
expect(upManyMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
composeOptions: [],
commandOptions: ["--build"],
config: ["docker-compose.yml"],
cwd: "/current/working/dir",
callback: expect.any(Function),
executable: {
executablePath: "docker",
options: [],
},
});
});
it("should throw formatted error when upAll fails with docker-compose result", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
it("should throw formatted error when upAll fails with docker-compose result", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const dockerComposeError = {
exitCode: 1,
err: "Error: unable to pull image\nfailed to resolve reference",
out: "",
};
const dockerComposeError = {
exitCode: 1,
err: "Error: unable to pull image\nfailed to resolve reference",
out: "",
};
upAllMock.mockRejectedValue(dockerComposeError);
upAllMock.mockRejectedValue(dockerComposeError);
await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1"
);
await expect(service.up(upInputs)).rejects.toThrow("unable to pull image");
});
await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1",
);
await expect(service.up(upInputs)).rejects.toThrow(
"unable to pull image",
);
});
it("should throw formatted error when upMany fails with docker-compose result", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
it("should throw formatted error when upMany fails with docker-compose result", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["web"],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const dockerComposeError = {
exitCode: 1,
err: "Service 'web' failed to start",
out: "Starting web...",
};
const dockerComposeError = {
exitCode: 1,
err: "Service 'web' failed to start",
out: "Starting web...",
};
upManyMock.mockRejectedValue(dockerComposeError);
upManyMock.mockRejectedValue(dockerComposeError);
await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1"
);
await expect(service.up(upInputs)).rejects.toThrow("Service 'web' failed to start");
await expect(service.up(upInputs)).rejects.toThrow("Starting web...");
});
await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1",
);
await expect(service.up(upInputs)).rejects.toThrow(
"Service 'web' failed to start",
);
await expect(service.up(upInputs)).rejects.toThrow("Starting web...");
});
it("should pass through docker-compose result without exit code", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
it("should pass through docker-compose result without exit code", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const dockerComposeError = {
exitCode: null,
err: "Some error without exit code",
out: "",
};
const dockerComposeError = {
exitCode: null,
err: "Some error without exit code",
out: "",
};
upAllMock.mockRejectedValue(dockerComposeError);
upAllMock.mockRejectedValue(dockerComposeError);
await expect(service.up(upInputs)).rejects.toThrow("Some error without exit code");
});
await expect(service.up(upInputs)).rejects.toThrow(
"Some error without exit code",
);
});
it("should pass through standard Error objects", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
it("should format docker-compose result when streams are undefined", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const standardError = new Error("Standard error message");
upAllMock.mockRejectedValue(standardError);
const dockerComposeError = {
exitCode: 1,
err: undefined,
out: undefined,
};
await expect(service.up(upInputs)).rejects.toThrow("Standard error message");
});
upAllMock.mockRejectedValue(dockerComposeError);
it("should pass through error strings", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1",
);
await expect(service.up(upInputs)).rejects.not.toThrow("Error output:");
await expect(service.up(upInputs)).rejects.not.toThrow(
"Standard output:",
);
});
const unknownError = "Some unknown error";
upAllMock.mockRejectedValue(unknownError);
it("should pass through standard Error objects", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow("Some unknown error");
});
const standardError = new Error("Standard error message");
upAllMock.mockRejectedValue(standardError);
it("should handle unknown error types gracefully", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow(
"Standard error message",
);
});
const unknownError = { unexpected: "error format" };
upAllMock.mockRejectedValue(unknownError);
it("should pass through error strings", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow(JSON.stringify(unknownError));
});
});
const unknownError = "Some unknown error";
upAllMock.mockRejectedValue(unknownError);
describe("down", () => {
it("should call down with correct options", async () => {
const downInputs = {
dockerFlags: [] as string[],
composeFiles: [] as string[],
composeFlags: [] as string[],
downFlags: ["--volumes", "--remove-orphans"],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow("Some unknown error");
});
downMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
it("should handle unknown error types gracefully", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
await service.down(downInputs);
const unknownError = { unexpected: "error format" };
upAllMock.mockRejectedValue(unknownError);
expect(downMock).toHaveBeenCalledWith({
composeOptions: [],
commandOptions: ["--volumes", "--remove-orphans"],
config: [],
executable: {
executablePath: "docker",
options: [],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
});
await expect(service.up(upInputs)).rejects.toThrow(
JSON.stringify(unknownError),
);
});
});
it("should throw formatted error when down fails with docker-compose result", async () => {
const downInputs = {
dockerFlags: [] as string[],
composeFiles: [] as string[],
composeFlags: [] as string[],
downFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
describe("down", () => {
it("should call down with correct options", async () => {
const downInputs = {
dockerFlags: [] as string[],
composeFiles: [] as string[],
composeFlags: [] as string[],
downFlags: ["--volumes", "--remove-orphans"],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
const dockerComposeError = {
exitCode: 1,
err: "Error stopping containers",
out: "",
};
downMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
downMock.mockRejectedValue(dockerComposeError);
await service.down(downInputs);
await expect(service.down(downInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1"
);
await expect(service.down(downInputs)).rejects.toThrow("Error stopping containers");
});
});
expect(downMock).toHaveBeenCalledWith({
composeOptions: [],
commandOptions: ["--volumes", "--remove-orphans"],
config: [],
executable: {
executablePath: "docker",
options: [],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
});
describe("logs", () => {
it("should call logs with correct options", async () => {
const debugMock = jest.fn();
const logsInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: debugMock,
};
it("should throw formatted error when down fails with docker-compose result", async () => {
const downInputs = {
dockerFlags: [] as string[],
composeFiles: [] as string[],
composeFlags: [] as string[],
downFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
logsMock.mockResolvedValue({ exitCode: 0, err: "", out: "logs" });
const dockerComposeError = {
exitCode: 1,
err: "Error stopping containers",
out: "",
};
await service.logs(logsInputs);
downMock.mockRejectedValue(dockerComposeError);
expect(logsMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
composeOptions: [],
config: ["docker-compose.yml"],
cwd: "/current/working/dir",
executable: {
executablePath: "docker",
options: [],
},
follow: false,
callback: expect.any(Function),
});
});
});
await expect(service.down(downInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1",
);
await expect(service.down(downInputs)).rejects.toThrow(
"Error stopping containers",
);
});
});
describe("logs", () => {
it("should call logs with correct options", async () => {
const debugMock = vi.fn();
const logsInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: debugMock,
};
logsMock.mockResolvedValue({ exitCode: 0, err: "", out: "logs" });
await service.logs(logsInputs);
expect(logsMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
composeOptions: [],
config: ["docker-compose.yml"],
cwd: "/current/working/dir",
executable: {
executablePath: "docker",
options: [],
},
follow: false,
callback: expect.any(Function),
});
});
});
});

View File

@ -1,148 +1,153 @@
import {
down,
IDockerComposeLogOptions,
IDockerComposeOptions,
IDockerComposeResult,
logs,
upAll,
upMany,
down,
type IDockerComposeLogOptions,
type IDockerComposeOptions,
type IDockerComposeResult,
logs,
upAll,
upMany,
} from "docker-compose";
import { Inputs } from "./input.service.js";
import type { Inputs } from "./input.service.js";
type OptionsInputs = {
dockerFlags: Inputs["dockerFlags"];
composeFiles: Inputs["composeFiles"];
composeFlags: Inputs["composeFlags"];
cwd: Inputs["cwd"];
serviceLogger: (message: string) => void;
dockerFlags: Inputs["dockerFlags"];
composeFiles: Inputs["composeFiles"];
composeFlags: Inputs["composeFlags"];
cwd: Inputs["cwd"];
serviceLogger: (message: string) => void;
};
export type UpInputs = OptionsInputs & { upFlags: Inputs["upFlags"]; services: Inputs["services"] };
export type UpInputs = OptionsInputs & {
upFlags: Inputs["upFlags"];
services: Inputs["services"];
};
export type DownInputs = OptionsInputs & { downFlags: Inputs["downFlags"] };
export type LogsInputs = OptionsInputs & { services: Inputs["services"] };
export class DockerComposeService {
async up({ upFlags, services, ...optionsInputs }: UpInputs): Promise<void> {
const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs),
commandOptions: upFlags,
};
async up({ upFlags, services, ...optionsInputs }: UpInputs): Promise<void> {
const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs),
commandOptions: upFlags,
};
try {
if (services.length > 0) {
await upMany(services, options);
return;
}
try {
if (services.length > 0) {
await upMany(services, options);
return;
}
await upAll(options);
} catch (error) {
throw this.formatDockerComposeError(error);
}
}
await upAll(options);
} catch (error) {
throw this.formatDockerComposeError(error);
}
}
async down({ downFlags, ...optionsInputs }: DownInputs): Promise<void> {
const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs),
commandOptions: downFlags,
};
async down({ downFlags, ...optionsInputs }: DownInputs): Promise<void> {
const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs),
commandOptions: downFlags,
};
try {
await down(options);
} catch (error) {
throw this.formatDockerComposeError(error);
}
}
try {
await down(options);
} catch (error) {
throw this.formatDockerComposeError(error);
}
}
async logs({ services, ...optionsInputs }: LogsInputs): Promise<{
error: string;
output: string;
}> {
const options: IDockerComposeLogOptions = {
...this.getCommonOptions(optionsInputs),
follow: false,
};
async logs({ services, ...optionsInputs }: LogsInputs): Promise<{
error: string;
output: string;
}> {
const options: IDockerComposeLogOptions = {
...this.getCommonOptions(optionsInputs),
follow: false,
};
const { err, out } = await logs(services, options);
const { err, out } = await logs(services, options);
return {
error: err,
output: out,
};
}
return {
error: err,
output: out,
};
}
private getCommonOptions({
dockerFlags,
composeFiles,
composeFlags,
cwd,
serviceLogger,
}: OptionsInputs): IDockerComposeOptions {
return {
config: composeFiles,
composeOptions: composeFlags,
cwd: cwd,
callback: (chunk) => serviceLogger(chunk.toString()),
executable: {
executablePath: "docker",
options: dockerFlags,
},
};
}
private getCommonOptions({
dockerFlags,
composeFiles,
composeFlags,
cwd,
serviceLogger,
}: OptionsInputs): IDockerComposeOptions {
return {
config: composeFiles,
composeOptions: composeFlags,
cwd: cwd,
callback: (chunk) => serviceLogger(chunk.toString()),
executable: {
executablePath: "docker",
options: dockerFlags,
},
};
}
/**
* Formats docker-compose errors into proper Error objects with readable messages
*/
private formatDockerComposeError(error: unknown): Error {
// If it's already an Error, return it
if (error instanceof Error) {
return error;
}
/**
* Formats docker-compose errors into proper Error objects with readable messages
*/
private formatDockerComposeError(error: unknown): Error {
// If it's already an Error, return it
if (error instanceof Error) {
return error;
}
// Handle docker-compose result objects
if (this.isDockerComposeResult(error)) {
const parts: string[] = [];
// Handle docker-compose result objects
if (this.isDockerComposeResult(error)) {
const parts: string[] = [];
// Add exit code information
if (error.exitCode !== null) {
parts.push(`Docker Compose command failed with exit code ${error.exitCode}`);
} else {
parts.push("Docker Compose command failed");
}
// Add exit code information
if (error.exitCode !== null) {
parts.push(
`Docker Compose command failed with exit code ${error.exitCode}`,
);
} else {
parts.push("Docker Compose command failed");
}
// Add error stream output if available
if (error.err && error.err.trim()) {
parts.push("\nError output:");
parts.push(error.err.trim());
}
// Add error stream output if available
if (error.err?.trim()) {
parts.push("\nError output:");
parts.push(error.err.trim());
}
// Add standard output if available and different from error output
if (error.out && error.out.trim() && error.out !== error.err) {
parts.push("\nStandard output:");
parts.push(error.out.trim());
}
// Add standard output if available and different from error output
if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:");
parts.push(error.out.trim());
}
return new Error(parts.join("\n"));
}
return new Error(parts.join("\n"));
}
// Handle string errors
if (typeof error === "string") {
return new Error(error);
}
// Handle string errors
if (typeof error === "string") {
return new Error(error);
}
// Fallback for unknown error types
return new Error(JSON.stringify(error));
}
// Fallback for unknown error types
return new Error(JSON.stringify(error));
}
/**
* Type guard to check if an object is a docker-compose result
*/
private isDockerComposeResult(error: unknown): error is IDockerComposeResult {
return (
typeof error === "object" &&
error !== null &&
"exitCode" in error &&
"err" in error &&
"out" in error
);
}
/**
* Type guard to check if an object is a docker-compose result
*/
private isDockerComposeResult(error: unknown): error is IDockerComposeResult {
return (
typeof error === "object" &&
error !== null &&
"exitCode" in error &&
"err" in error &&
"out" in error
);
}
}

View File

@ -1,24 +1,25 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core before importing the module under test
const getInputMock = jest.fn<(name: string, options?: { required?: boolean }) => string>();
const getInputMock =
vi.fn<(name: string, options?: { required?: boolean }) => string>();
const getMultilineInputMock =
jest.fn<(name: string, options?: { required?: boolean }) => string[]>();
vi.fn<(name: string, options?: { required?: boolean }) => string[]>();
jest.unstable_mockModule("@actions/core", () => ({
getInput: getInputMock,
getMultilineInput: getMultilineInputMock,
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
vi.doMock("@actions/core", () => ({
getInput: getInputMock,
getMultilineInput: getMultilineInputMock,
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
// Mock node:fs
const existsSyncMock = jest.fn<(path: string) => boolean>();
const existsSyncMock = vi.fn<(path: string) => boolean>();
jest.unstable_mockModule("node:fs", () => ({
existsSync: existsSyncMock,
default: { existsSync: existsSyncMock },
vi.doMock("node:fs", () => ({
existsSync: existsSyncMock,
default: { existsSync: existsSyncMock },
}));
// Dynamic imports after mock setup
@ -26,369 +27,373 @@ const { InputService, InputNames } = await import("./input.service.js");
const { LogLevel } = await import("./logger.service.js");
describe("InputService", () => {
let service: InstanceType<typeof InputService>;
beforeEach(() => {
jest.clearAllMocks();
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["file1"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
service = new InputService();
});
describe("getInputs", () => {
describe("docker-flags", () => {
it("should return given docker-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.DockerFlags:
return "docker-flag1 docker-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.dockerFlags).toEqual(["docker-flag1", "docker-flag2"]);
});
it("should return empty array when no docker-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.dockerFlags).toEqual([]);
});
});
describe("composeFiles", () => {
it("should return given composeFiles input", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["file1", "file2"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["file1", "file2"]);
});
it("should ignore empty compose file entries", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return [" ", "file1"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["file1"]);
});
it("should accept compose file when it exists at the original path", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["./compose.yml"];
default:
return [];
}
});
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "/current/working/directory";
default:
return "";
}
});
existsSyncMock.mockImplementation((file) => file === "./compose.yml");
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["./compose.yml"]);
});
it("should accept OCI compose files without checking the file system", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["oci://docker.io/hoverkraft/compose-app:latest"];
default:
return [];
}
});
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "/current/working/directory";
default:
return "";
}
});
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["oci://docker.io/hoverkraft/compose-app:latest"]);
expect(existsSyncMock).not.toHaveBeenCalled();
});
it("should throws an error when a compose file does not exist", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["file1", "file2"];
default:
return [];
}
});
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "/current/working/directory";
default:
return "";
}
});
existsSyncMock.mockImplementation((file) => file === "/current/working/directory/file1");
expect(() => service.getInputs()).toThrow(
'Compose file not found in "/current/working/directory/file2", "file2"'
);
});
it("should throws an error when no composeFiles input", () => {
getMultilineInputMock.mockReturnValue([]);
getInputMock.mockReturnValue("");
expect(() => service.getInputs()).toThrow("No compose files found");
});
});
describe("services", () => {
it("should return given services input", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Services:
return ["service1", "service2"];
case InputNames.ComposeFile:
return ["file1"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.services).toEqual(["service1", "service2"]);
});
});
describe("compose-flags", () => {
it("should return given compose-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFlags:
return "compose-flag1 compose-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFlags).toEqual(["compose-flag1", "compose-flag2"]);
});
it("should return empty array when no compose-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFlags).toEqual([]);
});
});
describe("up-flags", () => {
it("should return given up-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.UpFlags:
return "up-flag1 up-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.upFlags).toEqual(["up-flag1", "up-flag2"]);
});
it("should return empty array when no up-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.upFlags).toEqual([]);
});
});
describe("down-flags", () => {
it("should return given down-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.DownFlags:
return "down-flag1 down-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.downFlags).toEqual(["down-flag1", "down-flag2"]);
});
it("should return empty array when no down-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.downFlags).toEqual([]);
});
});
describe("cwd", () => {
it("should return given cwd input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "cwd";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.cwd).toEqual("cwd");
});
});
describe("compose-version", () => {
it("should return given compose-version input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeVersion:
return "compose-version";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeVersion).toEqual("compose-version");
});
});
describe("services-log-level", () => {
it("should return given services-log-level input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ServiceLogLevel:
return "info";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.serviceLogLevel).toEqual(LogLevel.Info);
});
it("should return default services-log-level input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ServiceLogLevel:
return "";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.serviceLogLevel).toEqual(LogLevel.Debug);
});
it("should throw an error when services-log-level input is invalid", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ServiceLogLevel:
return "invalid-log-level";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
expect(() => service.getInputs()).toThrow(
'Invalid service log level "invalid-log-level". Valid values are: debug, info'
);
});
});
});
let service: InstanceType<typeof InputService>;
beforeEach(() => {
vi.clearAllMocks();
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["file1"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
service = new InputService();
});
describe("getInputs", () => {
describe("docker-flags", () => {
it("should return given docker-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.DockerFlags:
return "docker-flag1 docker-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.dockerFlags).toEqual(["docker-flag1", "docker-flag2"]);
});
it("should return empty array when no docker-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.dockerFlags).toEqual([]);
});
});
describe("composeFiles", () => {
it("should return given composeFiles input", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["file1", "file2"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["file1", "file2"]);
});
it("should ignore empty compose file entries", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return [" ", "file1"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["file1"]);
});
it("should accept compose file when it exists at the original path", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["./compose.yml"];
default:
return [];
}
});
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "/current/working/directory";
default:
return "";
}
});
existsSyncMock.mockImplementation((file) => file === "./compose.yml");
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["./compose.yml"]);
});
it("should accept OCI compose files without checking the file system", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["oci://docker.io/hoverkraft/compose-app:latest"];
default:
return [];
}
});
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "/current/working/directory";
default:
return "";
}
});
const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual([
"oci://docker.io/hoverkraft/compose-app:latest",
]);
expect(existsSyncMock).not.toHaveBeenCalled();
});
it("should throws an error when a compose file does not exist", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFile:
return ["file1", "file2"];
default:
return [];
}
});
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "/current/working/directory";
default:
return "";
}
});
existsSyncMock.mockImplementation(
(file) => file === "/current/working/directory/file1",
);
expect(() => service.getInputs()).toThrow(
'Compose file not found in "/current/working/directory/file2", "file2"',
);
});
it("should throws an error when no composeFiles input", () => {
getMultilineInputMock.mockReturnValue([]);
getInputMock.mockReturnValue("");
expect(() => service.getInputs()).toThrow("No compose files found");
});
});
describe("services", () => {
it("should return given services input", () => {
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Services:
return ["service1", "service2"];
case InputNames.ComposeFile:
return ["file1"];
default:
return [];
}
});
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.services).toEqual(["service1", "service2"]);
});
});
describe("compose-flags", () => {
it("should return given compose-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeFlags:
return "compose-flag1 compose-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFlags).toEqual(["compose-flag1", "compose-flag2"]);
});
it("should return empty array when no compose-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeFlags).toEqual([]);
});
});
describe("up-flags", () => {
it("should return given up-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.UpFlags:
return "up-flag1 up-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.upFlags).toEqual(["up-flag1", "up-flag2"]);
});
it("should return empty array when no up-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.upFlags).toEqual([]);
});
});
describe("down-flags", () => {
it("should return given down-flags input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.DownFlags:
return "down-flag1 down-flag2";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.downFlags).toEqual(["down-flag1", "down-flag2"]);
});
it("should return empty array when no down-flags input", () => {
getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.downFlags).toEqual([]);
});
});
describe("cwd", () => {
it("should return given cwd input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.Cwd:
return "cwd";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.cwd).toEqual("cwd");
});
});
describe("compose-version", () => {
it("should return given compose-version input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ComposeVersion:
return "compose-version";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.composeVersion).toEqual("compose-version");
});
});
describe("services-log-level", () => {
it("should return given services-log-level input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ServiceLogLevel:
return "info";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.serviceLogLevel).toEqual(LogLevel.Info);
});
it("should return default services-log-level input", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ServiceLogLevel:
return "";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs();
expect(inputs.serviceLogLevel).toEqual(LogLevel.Debug);
});
it("should throw an error when services-log-level input is invalid", () => {
getInputMock.mockImplementation((inputName) => {
switch (inputName) {
case InputNames.ServiceLogLevel:
return "invalid-log-level";
default:
return "";
}
});
existsSyncMock.mockReturnValue(true);
expect(() => service.getInputs()).toThrow(
'Invalid service log level "invalid-log-level". Valid values are: debug, info',
);
});
});
});
});

View File

@ -4,135 +4,144 @@ import { join } from "node:path";
import { LogLevel } from "./logger.service.js";
export type Inputs = {
dockerFlags: string[];
composeFiles: string[];
services: string[];
composeFlags: string[];
upFlags: string[];
downFlags: string[];
cwd: string;
composeVersion: string | null;
githubToken: string | null;
serviceLogLevel: LogLevel;
dockerFlags: string[];
composeFiles: string[];
services: string[];
composeFlags: string[];
upFlags: string[];
downFlags: string[];
cwd: string;
composeVersion: string | null;
githubToken: string | null;
serviceLogLevel: LogLevel;
};
export enum InputNames {
DockerFlags = "docker-flags",
ComposeFile = "compose-file",
Services = "services",
ComposeFlags = "compose-flags",
UpFlags = "up-flags",
DownFlags = "down-flags",
Cwd = "cwd",
ComposeVersion = "compose-version",
GithubToken = "github-token",
ServiceLogLevel = "services-log-level",
DockerFlags = "docker-flags",
ComposeFile = "compose-file",
Services = "services",
ComposeFlags = "compose-flags",
UpFlags = "up-flags",
DownFlags = "down-flags",
Cwd = "cwd",
ComposeVersion = "compose-version",
GithubToken = "github-token",
ServiceLogLevel = "services-log-level",
}
export const COMPOSE_VERSION_LATEST = "latest";
export class InputService {
getInputs(): Inputs {
return {
dockerFlags: this.getDockerFlags(),
composeFiles: this.getComposeFiles(),
services: this.getServices(),
composeFlags: this.getComposeFlags(),
upFlags: this.getUpFlags(),
downFlags: this.getDownFlags(),
cwd: this.getCwd(),
composeVersion: this.getComposeVersion(),
githubToken: this.getGithubToken(),
serviceLogLevel: this.getServiceLogLevel(),
};
}
getInputs(): Inputs {
return {
dockerFlags: this.getDockerFlags(),
composeFiles: this.getComposeFiles(),
services: this.getServices(),
composeFlags: this.getComposeFlags(),
upFlags: this.getUpFlags(),
downFlags: this.getDownFlags(),
cwd: this.getCwd(),
composeVersion: this.getComposeVersion(),
githubToken: this.getGithubToken(),
serviceLogLevel: this.getServiceLogLevel(),
};
}
private getDockerFlags(): string[] {
return this.parseFlags(getInput(InputNames.DockerFlags));
}
private getDockerFlags(): string[] {
return this.parseFlags(getInput(InputNames.DockerFlags));
}
private getComposeFiles(): string[] {
const cwd = this.getCwd();
const composeFiles = getMultilineInput(InputNames.ComposeFile).filter((composeFile: string) => {
const trimmedComposeFile = composeFile.trim();
private getComposeFiles(): string[] {
const cwd = this.getCwd();
const composeFiles = getMultilineInput(InputNames.ComposeFile).filter(
(composeFile: string) => {
const trimmedComposeFile = composeFile.trim();
if (!trimmedComposeFile.length) {
return false;
}
if (!trimmedComposeFile.length) {
return false;
}
if (trimmedComposeFile.startsWith("oci://")) {
return true;
}
if (trimmedComposeFile.startsWith("oci://")) {
return true;
}
const possiblePaths = [join(cwd, composeFile), composeFile];
const possiblePaths = [join(cwd, composeFile), composeFile];
for (const path of possiblePaths) {
if (existsSync(path)) {
return true;
}
}
for (const path of possiblePaths) {
if (existsSync(path)) {
return true;
}
}
throw new Error(`Compose file not found in "${possiblePaths.join('", "')}"`);
});
throw new Error(
`Compose file not found in "${possiblePaths.join('", "')}"`,
);
},
);
if (!composeFiles.length) {
throw new Error("No compose files found");
}
if (!composeFiles.length) {
throw new Error("No compose files found");
}
return composeFiles;
}
return composeFiles;
}
private getServices(): string[] {
return getMultilineInput(InputNames.Services, { required: false });
}
private getServices(): string[] {
return getMultilineInput(InputNames.Services, { required: false });
}
private getComposeFlags(): string[] {
return this.parseFlags(getInput(InputNames.ComposeFlags));
}
private getComposeFlags(): string[] {
return this.parseFlags(getInput(InputNames.ComposeFlags));
}
private getUpFlags(): string[] {
return this.parseFlags(getInput(InputNames.UpFlags));
}
private getUpFlags(): string[] {
return this.parseFlags(getInput(InputNames.UpFlags));
}
private getDownFlags(): string[] {
return this.parseFlags(getInput(InputNames.DownFlags));
}
private getDownFlags(): string[] {
return this.parseFlags(getInput(InputNames.DownFlags));
}
private parseFlags(flags: string | null): string[] {
if (!flags) {
return [];
}
private parseFlags(flags: string | null): string[] {
if (!flags) {
return [];
}
return flags.trim().split(" ");
}
return flags.trim().split(" ");
}
private getCwd(): string {
return getInput(InputNames.Cwd);
}
private getCwd(): string {
return getInput(InputNames.Cwd);
}
private getComposeVersion(): string | null {
return (
getInput(InputNames.ComposeVersion, {
required: false,
}) || null
);
}
private getComposeVersion(): string | null {
return (
getInput(InputNames.ComposeVersion, {
required: false,
}) || null
);
}
private getGithubToken(): string | null {
return (
getInput(InputNames.GithubToken, {
required: false,
}) || null
);
}
private getGithubToken(): string | null {
return (
getInput(InputNames.GithubToken, {
required: false,
}) || null
);
}
private getServiceLogLevel(): LogLevel {
const configuredLevel = getInput(InputNames.ServiceLogLevel, { required: false });
if (configuredLevel && !Object.values(LogLevel).includes(configuredLevel as LogLevel)) {
throw new Error(
`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`
);
}
return (configuredLevel as LogLevel) || LogLevel.Debug;
}
private getServiceLogLevel(): LogLevel {
const configuredLevel = getInput(InputNames.ServiceLogLevel, {
required: false,
});
if (
configuredLevel &&
!Object.values(LogLevel).includes(configuredLevel as LogLevel)
) {
throw new Error(
`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`,
);
}
return (configuredLevel as LogLevel) || LogLevel.Debug;
}
}

View File

@ -1,3 +1,3 @@
export interface DockerComposeInstallerAdapter {
install(version: string): Promise<void>;
install(version: string): Promise<void>;
}

View File

@ -1,196 +1,221 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
import type { ExecOptions } from "@actions/exec";
import type { OutgoingHttpHeaders } from "node:http";
// Mock @actions/exec
const execMock =
jest.fn<(command: string, args?: string[], options?: ExecOptions) => Promise<number>>();
vi.fn<
(command: string, args?: string[], options?: ExecOptions) => Promise<number>
>();
jest.unstable_mockModule("@actions/exec", () => ({
exec: execMock,
vi.doMock("@actions/exec", () => ({
exec: execMock,
}));
// Mock @actions/io
const mkdirPMock = jest.fn<(fsPath: string) => Promise<void>>();
const mkdirPMock = vi.fn<(fsPath: string) => Promise<void>>();
jest.unstable_mockModule("@actions/io", () => ({
mkdirP: mkdirPMock,
vi.doMock("@actions/io", () => ({
mkdirP: mkdirPMock,
}));
// Mock @actions/tool-cache
const cacheFileMock =
jest.fn<
(
sourceFile: string,
targetFile: string,
tool: string,
version: string,
arch?: string
) => Promise<string>
>();
vi.fn<
(
sourceFile: string,
targetFile: string,
tool: string,
version: string,
arch?: string,
) => Promise<string>
>();
const downloadToolMock =
jest.fn<
(url: string, dest?: string, auth?: string, headers?: OutgoingHttpHeaders) => Promise<string>
>();
vi.fn<
(
url: string,
dest?: string,
auth?: string,
headers?: OutgoingHttpHeaders,
) => Promise<string>
>();
jest.unstable_mockModule("@actions/tool-cache", () => ({
cacheFile: cacheFileMock,
downloadTool: downloadToolMock,
vi.doMock("@actions/tool-cache", () => ({
cacheFile: cacheFileMock,
downloadTool: downloadToolMock,
}));
// Dynamic import after mock setup
const { ManualInstallerAdapter } = await import("./manual-installer-adapter.js");
const { ManualInstallerAdapter } = await import(
"./manual-installer-adapter.js"
);
const originalHome = process.env.HOME;
const originalDockerConfig = process.env.DOCKER_CONFIG;
describe("ManualInstallerAdapter", () => {
let adapter: InstanceType<typeof ManualInstallerAdapter>;
let adapter: InstanceType<typeof ManualInstallerAdapter>;
beforeEach(() => {
jest.clearAllMocks();
delete process.env.DOCKER_CONFIG;
adapter = new ManualInstallerAdapter();
});
beforeEach(() => {
vi.resetAllMocks();
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalDockerConfig === undefined) {
delete process.env.DOCKER_CONFIG;
} else {
process.env.DOCKER_CONFIG = originalDockerConfig;
}
adapter = new ManualInstallerAdapter();
});
describe("install", () => {
it("should install docker compose correctly", async () => {
// Arrange
const version = "v2.29.0";
describe("install", () => {
it("should install docker compose correctly", async () => {
// Arrange
const version = "v2.29.0";
// Uname -s
execMock.mockResolvedValueOnce(0);
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
// Uname -m
execMock.mockResolvedValueOnce(0);
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
Object.defineProperty(process.env, "HOME", {
value: "/home/test",
});
process.env.HOME = "/home/test";
// Act
await adapter.install(version);
// Act
await adapter.install(version);
// Assert
expect(mkdirPMock).toHaveBeenCalledWith("docker-compose");
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) },
});
expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], {
listeners: { stdout: expect.any(Function) },
});
// Assert
expect(mkdirPMock).toHaveBeenCalledWith("docker-compose");
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) },
});
expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], {
listeners: { stdout: expect.any(Function) },
});
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose--",
"/home/test/.docker/cli-plugins/docker-compose"
);
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose",
);
expect(cacheFileMock).toHaveBeenCalledWith(
"/home/test/.docker/cli-plugins/docker-compose",
"docker-compose",
"docker-compose",
version
);
});
expect(cacheFileMock).toHaveBeenCalledWith(
"/home/test/.docker/cli-plugins/docker-compose",
"docker-compose",
"docker-compose",
version,
);
});
it("should use DOCKER_CONFIG when set", async () => {
// Arrange
const version = "v2.29.0";
it("should use DOCKER_CONFIG when set", async () => {
// Arrange
const version = "v2.29.0";
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
process.env.DOCKER_CONFIG = "/custom/docker";
process.env.DOCKER_CONFIG = "/custom/docker";
// Act
await adapter.install(version);
// Act
await adapter.install(version);
// Assert
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/custom/docker/cli-plugins/docker-compose"
);
});
// Assert
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/custom/docker/cli-plugins/docker-compose",
);
});
it("should handle version without 'v' prefix", async () => {
// Arrange
const version = "2.29.0";
it("should handle version without 'v' prefix", async () => {
// Arrange
const version = "2.29.0";
// Uname -s
execMock.mockResolvedValueOnce(0);
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
// Uname -m
execMock.mockResolvedValueOnce(0);
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
Object.defineProperty(process.env, "HOME", {
value: "/home/test",
});
process.env.HOME = "/home/test";
// Act
await adapter.install(version);
// Act
await adapter.install(version);
// Assert
expect(mkdirPMock).toHaveBeenCalledWith("docker-compose");
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) },
});
expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], {
listeners: { stdout: expect.any(Function) },
});
// Assert
expect(mkdirPMock).toHaveBeenCalledWith("docker-compose");
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) },
});
expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], {
listeners: { stdout: expect.any(Function) },
});
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose--",
"/home/test/.docker/cli-plugins/docker-compose"
);
});
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose",
);
});
it("should not add 'v' prefix for 1.x versions", async () => {
// Arrange
const version = "1.29.0";
it("should not add 'v' prefix for 1.x versions", async () => {
// Arrange
const version = "1.29.0";
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
delete process.env.DOCKER_CONFIG;
Object.defineProperty(process.env, "HOME", {
value: "/home/test",
});
delete process.env.DOCKER_CONFIG;
process.env.HOME = "/home/test";
// Act
await adapter.install(version);
// Act
await adapter.install(version);
// Assert
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/1.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose"
);
});
// Assert
expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/1.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose",
);
});
it("should throw an error if a command fails", async () => {
// Arrange
const version = "v2.29.0";
it("should throw an error if a command fails", async () => {
// Arrange
const version = "v2.29.0";
// Uname -s
execMock.mockResolvedValueOnce(1);
// Uname -s
execMock.mockResolvedValueOnce(1);
// Act
await expect(adapter.install(version)).rejects.toThrow("Failed to run command: uname -s");
// Act
await expect(adapter.install(version)).rejects.toThrow(
"Failed to run command: uname -s",
);
// Assert
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) },
});
});
});
// Assert
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) },
});
});
});
});

View File

@ -2,59 +2,68 @@ import { exec } from "@actions/exec";
import { mkdirP } from "@actions/io";
import { basename } from "node:path";
import { cacheFile, downloadTool } from "@actions/tool-cache";
import { DockerComposeInstallerAdapter } from "./docker-compose-installer-adapter.js";
import type { DockerComposeInstallerAdapter } from "./docker-compose-installer-adapter.js";
export class ManualInstallerAdapter implements DockerComposeInstallerAdapter {
async install(version: string): Promise<void> {
const dockerComposePluginPath = await this.getDockerComposePluginPath();
async install(version: string): Promise<void> {
const dockerComposePluginPath = await this.getDockerComposePluginPath();
// Create the directory if it doesn't exist
await mkdirP(basename(dockerComposePluginPath));
// Create the directory if it doesn't exist
await mkdirP(basename(dockerComposePluginPath));
await this.downloadFile(version, dockerComposePluginPath);
await exec(`chmod +x ${dockerComposePluginPath}`);
await cacheFile(dockerComposePluginPath, "docker-compose", "docker-compose", version);
}
await this.downloadFile(version, dockerComposePluginPath);
await exec(`chmod +x ${dockerComposePluginPath}`);
await cacheFile(
dockerComposePluginPath,
"docker-compose",
"docker-compose",
version,
);
}
private async getDockerComposePluginPath(): Promise<string> {
const dockerConfig = process.env.DOCKER_CONFIG || `${process.env.HOME}/.docker`;
private async getDockerComposePluginPath(): Promise<string> {
const dockerConfig =
process.env.DOCKER_CONFIG || `${process.env.HOME}/.docker`;
const dockerComposePluginPath = `${dockerConfig}/cli-plugins/docker-compose`;
return dockerComposePluginPath;
}
const dockerComposePluginPath = `${dockerConfig}/cli-plugins/docker-compose`;
return dockerComposePluginPath;
}
private async downloadFile(version: string, installerPath: string): Promise<void> {
if (!version.startsWith("v") && parseInt(version.split(".")[0], 10) >= 2) {
version = `v${version}`;
}
private async downloadFile(
version: string,
installerPath: string,
): Promise<void> {
if (!version.startsWith("v") && parseInt(version.split(".")[0], 10) >= 2) {
version = `v${version}`;
}
const system = await this.getSystem();
const hardware = await this.getHardware();
const system = await this.getSystem();
const hardware = await this.getHardware();
const url = `https://github.com/docker/compose/releases/download/${version}/docker-compose-${system}-${hardware}`;
await downloadTool(url, installerPath);
}
const url = `https://github.com/docker/compose/releases/download/${version}/docker-compose-${system}-${hardware}`;
await downloadTool(url, installerPath);
}
private async getSystem(): Promise<string> {
return this.runCommand("uname -s");
}
private async getSystem(): Promise<string> {
return this.runCommand("uname -s");
}
private async getHardware(): Promise<string> {
return this.runCommand("uname -m");
}
private async getHardware(): Promise<string> {
return this.runCommand("uname -m");
}
private async runCommand(command: string): Promise<string> {
let output = "";
const result = await exec(command, [], {
listeners: {
stdout: (data: Buffer) => {
output += data.toString();
},
},
});
if (result !== 0) {
throw new Error(`Failed to run command: ${command}`);
}
return output.trim();
}
private async runCommand(command: string): Promise<string> {
let output = "";
const result = await exec(command, [], {
listeners: {
stdout: (data: Buffer) => {
output += data.toString();
},
},
});
if (result !== 0) {
throw new Error(`Failed to run command: ${command}`);
}
return output.trim();
}
}

View File

@ -1,68 +1,68 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { describe, expect, it, beforeEach, vi } from "vitest";
// Import types directly from the module
import type { LogLevel as LogLevelType } from "./logger.service.js";
// Mock @actions/core before importing the module under test
const warningMock = jest.fn();
const infoMock = jest.fn();
const debugMock = jest.fn();
const warningMock = vi.fn();
const infoMock = vi.fn();
const debugMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({
warning: warningMock,
info: infoMock,
debug: debugMock,
vi.doMock("@actions/core", () => ({
warning: warningMock,
info: infoMock,
debug: debugMock,
}));
// Dynamic import after mock setup
const { LoggerService, LogLevel } = await import("./logger.service.js");
describe("LoggerService", () => {
let loggerService: InstanceType<typeof LoggerService>;
let loggerService: InstanceType<typeof LoggerService>;
beforeEach(() => {
jest.clearAllMocks();
loggerService = new LoggerService();
});
beforeEach(() => {
vi.clearAllMocks();
loggerService = new LoggerService();
});
describe("warn", () => {
it("should call warning with the correct message", () => {
const message = "This is a warning message";
loggerService.warn(message);
expect(warningMock).toHaveBeenCalledWith(message);
});
});
describe("warn", () => {
it("should call warning with the correct message", () => {
const message = "This is a warning message";
loggerService.warn(message);
expect(warningMock).toHaveBeenCalledWith(message);
});
});
describe("info", () => {
it("should call info with the correct message", () => {
const message = "This is an info message";
loggerService.info(message);
expect(infoMock).toHaveBeenCalledWith(message);
});
});
describe("info", () => {
it("should call info with the correct message", () => {
const message = "This is an info message";
loggerService.info(message);
expect(infoMock).toHaveBeenCalledWith(message);
});
});
describe("debug", () => {
it("should call debug with the correct message", () => {
const message = "This is a debug message";
loggerService.debug(message);
expect(debugMock).toHaveBeenCalledWith(message);
});
});
describe("debug", () => {
it("should call debug with the correct message", () => {
const message = "This is a debug message";
loggerService.debug(message);
expect(debugMock).toHaveBeenCalledWith(message);
});
});
describe("getServiceLogger", () => {
it("should return the correct logger function for debug level", () => {
const logger = loggerService.getServiceLogger(LogLevel.Debug);
expect(logger).toBe(loggerService.debug);
});
describe("getServiceLogger", () => {
it("should return the correct logger function for debug level", () => {
const logger = loggerService.getServiceLogger(LogLevel.Debug);
expect(logger).toBe(loggerService.debug);
});
it("should return the correct logger function for info level", () => {
const logger = loggerService.getServiceLogger(LogLevel.Info);
expect(logger).toBe(loggerService.info);
});
it("should return the correct logger function for info level", () => {
const logger = loggerService.getServiceLogger(LogLevel.Info);
expect(logger).toBe(loggerService.info);
});
it("should default to info level if an unknown level is provided", () => {
const logger = loggerService.getServiceLogger("unknown" as LogLevelType);
expect(logger).toBe(loggerService.info);
});
});
it("should default to info level if an unknown level is provided", () => {
const logger = loggerService.getServiceLogger("unknown" as LogLevelType);
expect(logger).toBe(loggerService.info);
});
});
});

View File

@ -1,31 +1,31 @@
import { debug, info, warning } from "@actions/core";
export class LoggerService {
warn(message: string): void {
warning(message);
}
warn(message: string): void {
warning(message);
}
info(message: string): void {
info(message);
}
info(message: string): void {
info(message);
}
debug(message: string) {
debug(message);
}
debug(message: string) {
debug(message);
}
getServiceLogger(level: LogLevel): (message: string) => void {
switch (level) {
case LogLevel.Debug:
return this.debug;
case LogLevel.Info:
return this.info;
default:
return this.info;
}
}
getServiceLogger(level: LogLevel): (message: string) => void {
switch (level) {
case LogLevel.Debug:
return this.debug;
case LogLevel.Info:
return this.info;
default:
return this.info;
}
}
}
export enum LogLevel {
Debug = "debug",
Info = "info",
Debug = "debug",
Info = "info",
}

View File

@ -6,6 +6,8 @@ WORKDIR /app
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
HEALTHCHECK CMD grep -qa "entrypoint.sh" /proc/1/cmdline || exit 1
CMD ["/bin/sh", "entrypoint.sh"]
USER 1000:1000

View File

@ -1,21 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"sourceMap": true,
"outDir": "./dist",
"noImplicitAny": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"newLine": "lf",
"noEmit": true
},
"exclude": ["./dist", "./node_modules", "./src/**/*.test.ts", "./coverage"],
"include": ["./src/**/*"]
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"sourceMap": true,
"outDir": "./dist",
"noImplicitAny": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"newLine": "lf",
"noEmit": true
},
"exclude": ["./dist", "./node_modules", "./src/**/*.test.ts", "./coverage"],
"include": ["./src/**/*"]
}

12
vitest.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
include: ["src/**/*.{ts,tsx,js,jsx}"],
provider: "v8",
},
environment: "node",
globals: true,
},
});