diff --git a/.github/linters/actionlint.yml b/.github/linters/actionlint.yml new file mode 100644 index 0000000..f516136 --- /dev/null +++ b/.github/linters/actionlint.yml @@ -0,0 +1,10 @@ +# FIXME: Temporary ignores to bypass actionlint limitations. See https://github.com/rhysd/actionlint/issues/590. +paths: + .github/workflows/continuous-integration.yml: + ignore: + - 'both "username" and "password" must be specified in "credentials" section' + - '"credentials" section is scalar node but mapping node is expected' + - '"container" section is alias node but mapping node is expected' + - '"env" section must be mapping node but got scalar node' + - '"ports" section must be sequence node but got scalar node' + - '"volumes" section must be sequence node but got scalar node' diff --git a/.github/workflows/__test-workflow-continuous-integration.yml b/.github/workflows/__test-workflow-continuous-integration.yml index 9c5e6ec..a3f4f38 100644 --- a/.github/workflows/__test-workflow-continuous-integration.yml +++ b/.github/workflows/__test-workflow-continuous-integration.yml @@ -109,3 +109,50 @@ jobs: - name: Check the build artifacts run: test -f ${{ runner.temp }}/usr/src/app/dist/test.txt + + act-with-container-advanced: + name: Act - Run the continuous integration workflow (with container and advanced options) + uses: ./.github/workflows/continuous-integration.yml + needs: arrange-with-container + permissions: + contents: read + pull-requests: write + security-events: write + # FIXME: This is a workaround for having workflow ref. See https://github.com/orgs/community/discussions/38659 + id-token: write + with: + container: | + { + "image": "${{ fromJSON(needs.arrange-with-container.outputs.built-images).ci-npm.images[0] }}", + "env": { + "NODE_ENV": "test", + "CI": "true" + }, + "options": "--cpus 1", + "credentials": { + "username": "${{ github.actor }}" + } + } + working-directory: /usr/src/app/ + build: | + { + "artifact": "dist" + } + test: | + {"coverage": "codecov"} + secrets: + container-password: ${{ secrets.GITHUB_TOKEN }} + + assert-with-container-advanced: + name: Assert - Ensure build artifact has been uploaded (with container advanced) + runs-on: ubuntu-latest + needs: act-with-container-advanced + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + artifact-ids: ${{ needs.act-with-container-advanced.outputs.build-artifact-id }} + path: ${{ runner.temp }} + + - name: Check the build artifacts + run: test -f ${{ runner.temp }}/usr/src/app/dist/test.txt diff --git a/.github/workflows/continuous-integration.md b/.github/workflows/continuous-integration.md index 8667311..bcef730 100644 --- a/.github/workflows/continuous-integration.md +++ b/.github/workflows/continuous-integration.md @@ -126,7 +126,29 @@ jobs: # Default: `.` working-directory: . - # Docker container image to run CI steps in. When specified, steps will execute inside this container instead of checking out code. The container should have the project code and dependencies pre-installed. + # Container configuration to run CI steps in. + # Accepts either a string (container image name) or a JSON object with container options. + # + # String format (simple): + # container: "node:18" + # + # JSON object format (advanced): + # container: | + # { + # "image": "node:18", + # "env": { + # "NODE_ENV": "production" + # }, + # "ports": [8080], + # "volumes": ["/tmp:/tmp"], + # "options": "--cpus 2" + # } + # + # All properties from GitHub's container specification are supported except credentials (use secrets instead). + # See https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/run-jobs-in-a-container + # + # When specified, steps will execute inside this container instead of checking out code. + # The container should have the project code and dependencies pre-installed. container: "" ```` @@ -162,20 +184,83 @@ jobs: | | Set to `null` or empty to disable. | | | | | | Accepts a JSON object for test options. See [test action](../actions/test/README.md). | | | | | **`working-directory`** | Working directory where the dependencies are installed. | **false** | **string** | `.` | -| **`container`** | Docker container image to run CI steps in. When specified, steps will execute inside this container instead of checking out code. The container should have the project code and dependencies pre-installed. | **false** | **string** | - | +| **`container`** | Container configuration to run CI steps in. Accepts string or JSON object. See Container Configuration below | **false** | **string** | - | +### Container Configuration + +The `container` input accepts either: + +**Simple string format** (image name only): + +```yaml +container: "node:18" +``` + +**Advanced JSON format** (with container options): + +```yaml +container: | + { + "image": "node:18", + "env": { + "NODE_ENV": "production" + }, + "options": "--cpus 2", + "ports": [8080, 3000], + "volumes": ["/tmp:/tmp", "/cache:/cache"], + "credentials": { + "username": "myusername" + } + } +``` + +**Supported properties:** + +- `image` (string, required) - Container image name +- `env` (object) - Environment variables +- `options` (string) - Additional Docker options +- `ports` (array) - Port mappings +- `volumes` (array) - Volume mounts +- `credentials` (object) - Registry credentials with `username` property + +#### Container Registry Credentials + +For private container images, specify the username in the container input's `credentials.username` property and pass the password via the `container-password` secret: + +```yaml +jobs: + continuous-integration: + uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@main + secrets: + container-password: ${{ secrets.REGISTRY_PASSWORD }} + with: + container: | + { + "image": "ghcr.io/myorg/my-private-image:latest", + "credentials": { + "username": "myusername" + } + } +``` + +See [GitHub's container specification](https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/run-jobs-in-a-container) for more details. + +When specified, steps will execute inside this container instead of checking out code. The container should have the project code and dependencies pre-installed. + ## Secrets -| **Secret** | **Description** | **Required** | -| ------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------ | -| **`build-secrets`** | Secrets to be used during the build step. | **false** | -| | Must be a multi-line env formatted string. | | -| | Example: | | -| |
SECRET_EXAMPLE=$\{{ secrets.SECRET_EXAMPLE }}
| | +| **Secret** | **Description** | **Required** | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| **`build-secrets`** | Secrets to be used during the build step. | **false** | +| | Must be a multi-line env formatted string. | | +| | Example: | | +| |
SECRET_EXAMPLE=$\{{ secrets.SECRET_EXAMPLE }}
| | +| **`container-password`** | Password or token for authenticating to the container registry. | **false** | +| | Required when using private container images. The username should be specified in the container input's `credentials.username` property. | | @@ -289,6 +374,50 @@ jobs: test: true ``` +### Continuous Integration with Advanced Container Options + +This example shows how to use advanced container options like environment variables, ports, volumes, credentials, and additional Docker options. + +```yaml +name: Continuous Integration - Advanced Container Options + +on: + push: + branches: [main] + +jobs: + continuous-integration: + uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@32a69b7b8fd5f7ab7bf656e7e88aa90ad235cf8d # 0.18.0 + permissions: + id-token: write + security-events: write + contents: read + secrets: + container-password: ${{ secrets.REGISTRY_PASSWORD }} + with: + container: | + { + "image": "ghcr.io/myorg/node-image:18-alpine", + "env": { + "NODE_ENV": "production", + "CI": "true" + }, + "options": "--cpus 2 --memory 4g", + "ports": [3000, 8080], + "volumes": ["/tmp:/tmp", "/cache:/workspace/cache"], + "credentials": { + "username": "myusername" + } + } + # When using container mode, code-ql and dependency-review are typically disabled + # as they require repository checkout + code-ql: "" + dependency-review: false + build: "build" + lint: true + test: true +``` + diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 60edee9..d2565c6 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -83,7 +83,37 @@ on: required: false default: "." container: - description: "Docker container image to run CI steps in. When specified, steps will execute inside this container instead of checking out code. The container should have the project code and dependencies pre-installed." + description: | + Container configuration to run CI steps in. + Accepts either a string (container image name) or a JSON object with container options. + + String format (simple): + ```yml + container: "node:18" + ``` + + JSON object format (advanced): + ```json + { + "image": "node:18", + "env": { + "NODE_ENV": "production" + }, + "options": "--cpus 2", + "ports": [8080, 3000], + "volumes": ["/tmp:/tmp", "/cache:/cache"], + "credentials": { + "username": "myusername" + } + } + ``` + + Supported properties: image (required), env (object), options (string), ports (array), volumes (array), credentials (object with username). + + See https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/run-jobs-in-a-container + + When specified, steps will execute inside this container instead of checking out code. + The container should have the project code and dependencies pre-installed. type: string required: false default: "" @@ -97,6 +127,12 @@ on: SECRET_EXAMPLE=$\{{ secrets.SECRET_EXAMPLE }} ``` required: false + container-password: + description: | + Password for container registry authentication, if required. + Used when the container image is hosted in a private registry. + See https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/run-jobs-in-a-container#defining-credentials-for-a-container-registry. + required: false outputs: build-artifact-id: description: "ID of the build artifact) uploaded during the build step." @@ -105,6 +141,88 @@ on: permissions: {} jobs: + prepare: + name: ๐Ÿ“ฆ Prepare configuration + runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }} + permissions: {} + outputs: + container-image: ${{ steps.parse.outputs.container-image }} + container-env: ${{ steps.parse.outputs.container-env }} + container-options: ${{ steps.parse.outputs.container-options }} + container-ports: ${{ steps.parse.outputs.container-ports }} + container-volumes: ${{ steps.parse.outputs.container-volumes }} + container-username: ${{ steps.parse.outputs.container-username }} + steps: + - id: parse + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + CONTAINER_INPUT: ${{ inputs.container }} + CONTAINER_PASSWORD: ${{ secrets.container-password }} + with: + script: | + const containerInput = process.env.CONTAINER_INPUT.trim(); + if (!containerInput) { + return; + } + core.debug(`Container input: ${containerInput}`); + + // Check if input is a JSON object or a simple string + const isJson = containerInput.startsWith('{'); + + let container = { + options: '--user root:root' + }; + + if (isJson) { + try { + const parsedContainer = JSON.parse(containerInput); + core.debug(`Parsed container input as JSON: ${JSON.stringify(parsedContainer)}`); + container = { + ...container, + ...parsedContainer, + options: `${container.options} ${parsedContainer.options || ''}`.trim() + }; + + } catch (error) { + return core.setFailed(`Failed to parse container input as JSON: ${error.message}`,{ cause: error }); + } + } else { + // Simple string format - just the image name + container.image = containerInput; + } + + core.debug(`Parsed container configuration: ${JSON.stringify(container)}`); + + if (!container.image) { + return core.setFailed('Container image must be specified in the container input.'); + } + core.setOutput('container-image', container.image); + + if (container.env) { + core.setOutput('container-env', JSON.stringify(container.env)); + } + + if (container.options) { + core.setOutput('container-options', container.options); + } + + if (container.ports) { + core.setOutput('container-ports', JSON.stringify(container.ports)); + } + + if (container.volumes) { + core.setOutput('container-volumes', JSON.stringify(container.volumes)); + } + + if (container.credentials?.username) { + core.setOutput('container-username', container.credentials.username); + if (!process.env.CONTAINER_PASSWORD) { + return core.setFailed('Container password must be provided when container credentials username is specified.'); + } + } else if (process.env.CONTAINER_PASSWORD) { + return core.setFailed('Container credentials username must be provided when container password is specified.'); + } + code-ql: name: ๐Ÿ›ก๏ธ CodeQL Analysis if: inputs.checks == true && inputs.code-ql != '' @@ -131,10 +249,14 @@ jobs: setup: name: โš™๏ธ Setup runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }} - container: - image: ${{ inputs.container != '' && inputs.container || null }} - # Root user is required to use GitHub Actions features inside the container - options: --user root:root + needs: prepare + container: &container-setup + image: ${{ needs.prepare.outputs.container-image || '' }} + env: ${{ fromJSON(needs.prepare.outputs.container-env || '{}') }} + options: ${{ needs.prepare.outputs.container-options || ' ' }} + ports: ${{ fromJSON(needs.prepare.outputs.container-ports || '[]') }} + volumes: ${{ fromJSON(needs.prepare.outputs.container-volumes || '[]') }} + credentials: ${{ fromJSON(needs.prepare.outputs.container-username && format('{{"username":{0},"password":{1}}}',toJSON(needs.prepare.outputs.container-username),toJSON(secrets.container-password)) || '{}') }} permissions: contents: read # FIXME: This is a workaround for having workflow ref. See https://github.com/orgs/community/discussions/38659 @@ -144,7 +266,7 @@ jobs: build-commands: ${{ steps.build-variables.outputs.commands }} build-artifact: ${{ steps.build-variables.outputs.artifact }} steps: - - if: inputs.container == '' + - if: needs.prepare.outputs.container-image == null uses: hoverkraft-tech/ci-github-common/actions/checkout@753288393de1f3d92f687a6761d236ca800f5306 # 0.28.1 - id: build-variables @@ -249,12 +371,11 @@ jobs: lint: name: ๐Ÿ‘• Lint if: inputs.checks == true && inputs.lint + needs: + - prepare + - setup runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }} - container: - image: ${{ inputs.container != '' && inputs.container || null }} - # Root user is required to use GitHub Actions features inside the container - options: --user root:root - needs: setup + container: *container-setup # jscpd:ignore-start permissions: contents: read @@ -262,9 +383,8 @@ jobs: id-token: write steps: - uses: hoverkraft-tech/ci-github-common/actions/checkout@753288393de1f3d92f687a6761d236ca800f5306 # 0.28.1 - if: inputs.container == '' + if: needs.prepare.outputs.container-image == null - # FIXME: This is a workaround for having workflow ref. See https://github.com/orgs/community/discussions/38659 - id: oidc uses: ChristopherHX/oidc@73eee1ff03fdfce10eda179f617131532209edbd # v3 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -300,18 +420,17 @@ jobs: - uses: ./self-workflow/actions/lint with: working-directory: ${{ inputs.working-directory }} - container: ${{ inputs.container != '' }} + container: ${{ needs.prepare.outputs.container-image && 'true' || 'false' }} build: name: ๐Ÿ—๏ธ Build if: inputs.checks == true runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }} + container: *container-setup # jscpd:ignore-start - container: - image: ${{ inputs.container != '' && inputs.container || null }} - # Root user is required to use GitHub Actions features inside the container - options: --user root:root - needs: setup + needs: + - prepare + - setup permissions: contents: read # FIXME: This is a workaround for having workflow ref. See https://github.com/orgs/community/discussions/38659 @@ -320,7 +439,7 @@ jobs: artifact-id: ${{ steps.build.outputs.artifact-id }} steps: - uses: hoverkraft-tech/ci-github-common/actions/checkout@753288393de1f3d92f687a6761d236ca800f5306 # 0.28.1 - if: needs.setup.outputs.build-commands && inputs.container == '' + if: needs.setup.outputs.build-commands && needs.prepare.outputs.container-image == null # FIXME: This is a workaround for having workflow ref. See https://github.com/orgs/community/discussions/38659 - id: oidc @@ -348,17 +467,15 @@ jobs: build-env: ${{ needs.setup.outputs.build-env }} build-secrets: ${{ secrets.build-secrets }} build-artifact: ${{ needs.setup.outputs.build-artifact }} - container: ${{ inputs.container != '' }} + container: ${{ needs.prepare.outputs.container-image && 'true' || 'false' }} test: name: ๐Ÿงช Test if: inputs.checks == true && inputs.test runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }} - container: - image: ${{ inputs.container != '' && inputs.container || null }} - # Root user is required to use GitHub Actions features inside the container - options: --user root:root + container: *container-setup needs: + - prepare - setup - build permissions: @@ -368,9 +485,9 @@ jobs: id-token: write steps: - uses: hoverkraft-tech/ci-github-common/actions/checkout@753288393de1f3d92f687a6761d236ca800f5306 # 0.28.1 - if: inputs.container == '' + if: needs.prepare.outputs.container-image == null - - if: needs.build.outputs.artifact-id && inputs.container == '' + - if: needs.build.outputs.artifact-id && needs.prepare.outputs.container-image == null uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: artifact-ids: ${{ needs.build.outputs.artifact-id }} @@ -419,7 +536,7 @@ jobs: - uses: ./self-workflow/actions/test with: working-directory: ${{ inputs.working-directory }} - container: ${{ inputs.container != '' }} + container: ${{ needs.prepare.outputs.container-image && 'true' || 'false' }} coverage: ${{ steps.prepare-test-options.outputs.coverage }} - coverage-files: ${{ steps.prepare-test-options.outputs['coverage-files'] }} + coverage-files: ${{ steps.prepare-test-options.outputs.coverage-files }} github-token: ${{ github.token }}