diff --git a/README.md b/README.md index 75f429cdf..eb2a20630 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ A similar [Azure DevOps Task](./docs/azure-devops-task.md) is also available! Note that this project builds on top of [@devcontainers/cli](https://www.npmjs.com/package/@devcontainers/cli) which can be used in other automation systems. ## Quick start -Here are three examples of using the Action for common scenarios. See the [documentation](./docs/github-action.md) for more details and a list of available [inputs](./docs/github-action.md#inputs). +Here are examples of using the Action for common scenarios. See the [documentation](./docs/github-action.md) for more details and a list of available [inputs](./docs/github-action.md#inputs). -**Pre-building an image:** +**Pre-building an image (legacy format):** ```yaml - name: Pre-build dev container image @@ -22,6 +22,28 @@ Here are three examples of using the Action for common scenarios. See the [docum push: always ``` +**Pre-building an image (new format with docker-metadata-action):** + +```yaml +- name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/example/example-devcontainer + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + +- name: Pre-build dev container image + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} + cacheFrom: ghcr.io/example/example-devcontainer + push: always +``` + **Using a Dev Container for a CI build:** ```yaml @@ -49,6 +71,47 @@ Here are three examples of using the Action for common scenarios. See the [docum runCmd: make ci-build ``` +**Using with docker-metadata-action for advanced tagging:** + +```yaml +- name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/example/example-devcontainer + example/example-devcontainer + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + +- name: Pre-build image and run make ci-build in dev container + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} + cacheFrom: ghcr.io/example/example-devcontainer + push: always + runCmd: make ci-build +``` + +## Input compatibility + +This action now supports two input formats for specifying images and tags: + +### New format (recommended) +- `images`: List of Docker images to use as base name for tags (compatible with docker-metadata-action) +- `tags`: List of tags (compatible with docker-metadata-action output) + +### Legacy format (still supported) +- `imageName`: Single image name (including registry) +- `imageTag`: Comma-separated list of image tags + +The new format takes precedence when both are provided. This ensures compatibility with docker-metadata-action while maintaining backwards compatibility with existing workflows. + ## CHANGELOG ### Version 0.3.0 (24th February 2023) diff --git a/action.yml b/action.yml index 3af8a63eb..b60510c0c 100644 --- a/action.yml +++ b/action.yml @@ -11,6 +11,12 @@ inputs: imageTag: required: false description: One or more comma-separated image tags (defaults to latest) + images: + required: false + description: List of Docker images to use as base name for tags. Takes precedence over imageName if both are provided. + tags: + required: false + description: List of tags. Takes precedence over imageTag if both are provided. Can accept output from docker/metadata-action. platform: require: false description: Platforms for which the image should be built. If omitted, defaults to the platform of the GitHub Actions Runner. Multiple platforms should be comma separated. @@ -66,6 +72,9 @@ inputs: cacheTo: required: false description: Specify the image to cache the built image to + mounts: + required: false + description: List of additional mounts to be added to the dev container outputs: runCmdOutput: description: The output of the command specified in the runCmd input diff --git a/docs/github-action.md b/docs/github-action.md index 806f5fc3f..0e1ffaea7 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -34,7 +34,7 @@ jobs: With the example above, each time the action runs it will rebuild the Docker image for the dev container. To save time in builds, you can push the dev container image to a container registry (e.g. [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)) and re-used the cached image in future builds. -To enable pushing the dev container image to a container registry you need to specify a qualified `imageName` and ensure that your GitHub workflow is signed in to that registry. +To enable pushing the dev container image to a container registry you need to specify image information and ensure that your GitHub workflow is signed in to that registry. The example below shows installing Docker BuildKit, logging in to GitHub Container Registry, and then building and running the dev container with the devcontainer-build-run action: @@ -95,6 +95,28 @@ The [`devcontainers/ci` action](https://github.com/marketplace/actions/devcontai push: always ``` +**Pre-building an image with docker-metadata-action:** + +```yaml +- name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/example/example-devcontainer + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + +- name: Pre-build dev container image + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} + cacheFrom: ghcr.io/example/example-devcontainer + push: always +``` + **Using a Dev Container for a CI build:** ```yaml @@ -122,20 +144,59 @@ The [`devcontainers/ci` action](https://github.com/marketplace/actions/devcontai runCmd: make ci-build ``` +**Advanced tagging with docker-metadata-action:** + +```yaml +- name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/example/example-devcontainer + example/example-devcontainer + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + +- name: Pre-build image and run make ci-build in dev container + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} + cacheFrom: ghcr.io/example/example-devcontainer + push: always + runCmd: make ci-build +``` ## Inputs +This action supports two input formats for specifying images and tags: + +### New format (recommended for new workflows) +The new format is compatible with outputs from [docker/metadata-action](https://github.com/docker/metadata-action) and follows the same pattern as [docker/build-push-action](https://github.com/docker/build-push-action). + +### Legacy format (backwards compatible) +The legacy format continues to work for existing workflows. + | Name | Required | Description | | ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| imageName | true | Image name to use when building the dev container image (including registry) | -| imageTag | false | One or more comma-separated image tags (defaults to `latest`) | +| **New format inputs** | | | +| images | false | List of Docker images to use as base name for tags. Takes precedence over `imageName` if both are provided. Compatible with docker-metadata-action outputs. | +| tags | false | List of tags. Takes precedence over `imageTag` if both are provided. Compatible with docker-metadata-action outputs. Can be newline-delimited string. | +| **Legacy format inputs** | | | +| imageName | false | Image name to use when building the dev container image (including registry). Superseded by `images` input if provided. | +| imageTag | false | One or more comma-separated image tags (defaults to `latest`). Superseded by `tags` input if provided. | +| **Other inputs** | | | | subFolder | false | Use this to specify the repo-relative path to the folder containing the dev container (i.e. the folder that contains the `.devcontainer` folder). Defaults to repo root | | configFile | false | Use this to specify the repo-relative path to the devcontainer.json file. Defaults to `./.devcontainer/devcontainer.json` and `./.devcontainer.json`. | -| runCmd | true | The command to run after building the dev container image | +| runCmd | false | The command to run after building the dev container image | | env | false | Specify environment variables to pass to the dev container when run | | inheritEnv | false | Inherit all environment variables of the runner CI machine | | checkoutPath | false | Only used for development/testing | -| push | false | Control when images are pushed. Options are `never`, `filter`, `always`. For `filter`, images are pushed if the `refFilterForPush` and `eventFilterForPush` conditions are met. Defaults to `filter` if `imageName` is set, `never` otherwise. | +| push | false | Control when images are pushed. Options are `never`, `filter`, `always`. For `filter`, images are pushed if the `refFilterForPush` and `eventFilterForPush` conditions are met. Defaults to `filter` if image inputs are set, `never` otherwise. | | refFilterForPush | false | Set the source branches (e.g. `refs/heads/main`) that are allowed to trigger a push of the dev container image. Leave empty to allow all (default) | | eventFilterForPush | false | Set the event names (e.g. `pull_request`, `push`) that are allowed to trigger a push of the dev container image. Defaults to `push`. Leave empty for all | | skipContainerUserIdUpdate | false | For non-root Dev Containers (i.e. where `remoteUser` is specified), the action attempts to make the container user UID and GID match those of the host user. Set this to true to skip this step (defaults to false) | @@ -143,6 +204,51 @@ The [`devcontainers/ci` action](https://github.com/marketplace/actions/devcontai | noCache | false | Builds the image with `--no-cache` (takes precedence over `cacheFrom`) | | cacheTo | false | Specify the image to cache the built image to | | platform | false | Platforms for which the image should be built. If omitted, defaults to the platform of the GitHub Actions Runner. Multiple platforms should be comma separated. | +| mounts | false | List of additional mounts to be added to the dev container | + +## Input format compatibility + +The action supports both legacy and new input formats with the following precedence: + +1. If `images` or `tags` inputs are provided, they take precedence over `imageName` and `imageTag` +2. If only legacy inputs (`imageName`, `imageTag`) are provided, they are used for backwards compatibility +3. The new format accepts newline-delimited strings (compatible with docker-metadata-action outputs) +4. The legacy format accepts comma-separated values for `imageTag` + +### Examples of input combinations + +**Using new format:** +```yaml +with: + images: | + ghcr.io/example/app + example/app + tags: | + v1.0.0 + latest +``` + +**Using legacy format:** +```yaml +with: + imageName: ghcr.io/example/app + imageTag: v1.0.0,latest +``` + +**Using docker-metadata-action outputs:** +```yaml +- name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/example/app + +- name: Build dev container + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} +``` ## Outputs @@ -237,3 +343,130 @@ You should set the `HELLO` environment variable using the `env` property on the ## Multi-Platform Builds Builds for multiple platforms have special considerations, detailed at [mutli-platform-builds.md](multi-platform-builds.md). + +## Complete workflow example with docker-metadata-action + +Here's a comprehensive example that demonstrates using this action with docker-metadata-action for advanced image management: + +```yaml +name: Dev Container CI + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and test in dev container + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} + cacheFrom: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + push: ${{ github.event_name != 'pull_request' && 'always' || 'never' }} + runCmd: | + npm test + npm run lint + npm run build + + multi-platform: + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + needs: test + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build multi-platform dev container + uses: devcontainers/ci@v0.3 + with: + images: ${{ steps.meta.outputs.images }} + tags: ${{ steps.meta.outputs.tags }} + platform: linux/amd64,linux/arm64 + cacheFrom: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + push: always +``` + +This example demonstrates: + +1. **Multi-registry support**: Pushes to both GitHub Container Registry and Docker Hub +2. **Conditional pushing**: Only pushes on non-PR events +3. **Comprehensive tagging**: Uses semantic versioning, branch names, and SHA-based tags +4. **Multi-platform builds**: Separate job for building ARM64 and AMD64 images +5. **Build caching**: Reuses previously built layers for faster builds +6. **Integration testing**: Runs tests inside the dev container before pushing + +The workflow creates tags like: +- `main` (for main branch pushes) +- `pr-123` (for pull request #123) +- `v1.2.3`, `v1.2`, `v1` (for version tags) +- `sha-abc1234` (for specific commits) diff --git a/github-action/src/main.ts b/github-action/src/main.ts index 1189788cd..900650825 100644 --- a/github-action/src/main.ts +++ b/github-action/src/main.ts @@ -22,6 +22,43 @@ const githubEnvs = { GITHUB_STEP_SUMMARY: '/mnt/github/step-summary', }; +// Helper function to parse multiline input and filter out empty lines +function parseMultilineInput(input: string): string[] { + return input + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); +} + +// Helper function to construct full image names from images and tags +function constructFullImageNames(images: string[], tags: string[]): string[] { + const fullImageNames: string[] = []; + + if (images.length === 0) { + // If no images specified, return tags as-is (they might be full image names) + return tags; + } + + if (tags.length === 0) { + // If no tags specified, default to 'latest' + tags = ['latest']; + } + + // Create cartesian product of images and tags + for (const image of images) { + for (const tag of tags) { + // Check if tag is already a full image name (contains ':') + if (tag.includes(':')) { + fullImageNames.push(tag); + } else { + fullImageNames.push(`${image}:${tag}`); + } + } + } + + return fullImageNames; +} + export async function runMain(): Promise { try { core.info('Starting...'); @@ -44,8 +81,40 @@ export async function runMain(): Promise { } const checkoutPath: string = core.getInput('checkoutPath'); + + // Get legacy inputs const imageName = emptyStringAsUndefined(core.getInput('imageName')); const imageTag = emptyStringAsUndefined(core.getInput('imageTag')); + + // Get new inputs (these take precedence) + const imagesInput = core.getInput('images'); + const tagsInput = core.getInput('tags'); + + // Parse and determine which inputs to use + let images: string[] = []; + let tags: string[] = []; + + if (imagesInput || tagsInput) { + // Use new input format + if (imagesInput) { + images = parseMultilineInput(imagesInput); + } + if (tagsInput) { + tags = parseMultilineInput(tagsInput); + } + } else if (imageName || imageTag) { + // Use legacy input format + if (imageName) { + images = [imageName]; + } + if (imageTag) { + tags = imageTag.split(/\s*,\s*/); + } + } + + // Construct full image names + const fullImageNameArray = constructFullImageNames(images, tags); + const platform = emptyStringAsUndefined(core.getInput('platform')); const subFolder: string = core.getInput('subFolder'); const relativeConfigFile = emptyStringAsUndefined( @@ -80,13 +149,8 @@ export async function runMain(): Promise { const configFile = relativeConfigFile && path.resolve(checkoutPath, relativeConfigFile); - const resolvedImageTag = imageTag ?? 'latest'; - const imageTagArray = resolvedImageTag.split(/\s*,\s*/); - const fullImageNameArray: string[] = []; - for (const tag of imageTagArray) { - fullImageNameArray.push(`${imageName}:${tag}`); - } - if (imageName) { + // Handle caching logic for full image names + if (fullImageNameArray.length > 0) { if (fullImageNameArray.length === 1) { if (!noCache && !cacheFrom.includes(fullImageNameArray[0])) { // If the cacheFrom options don't include the fullImageName, add it here @@ -106,12 +170,14 @@ export async function runMain(): Promise { ); } } else { + // Legacy warning message for backwards compatibility if (imageTag) { core.warning( 'imageTag specified without specifying imageName - ignoring imageTag', ); } } + const buildResult = await core.group('🏗️ build container', async () => { const args: DevContainerCliBuildArgs = { workspaceFolder, @@ -212,19 +278,50 @@ export async function runMain(): Promise { export async function runPost(): Promise { const pushOption = emptyStringAsUndefined(core.getInput('push')); - const imageName = emptyStringAsUndefined(core.getInput('imageName')); const refFilterForPush: string[] = core.getMultilineInput('refFilterForPush'); const eventFilterForPush: string[] = core.getMultilineInput('eventFilterForPush'); - // default to 'never' if not set and no imageName - if (pushOption === 'never' || (!pushOption && !imageName)) { + // Get legacy and new inputs for determining image names to push + const imageName = emptyStringAsUndefined(core.getInput('imageName')); + const imageTag = emptyStringAsUndefined(core.getInput('imageTag')); + const imagesInput = core.getInput('images'); + const tagsInput = core.getInput('tags'); + + // Parse and determine which inputs to use + let images: string[] = []; + let tags: string[] = []; + + if (imagesInput || tagsInput) { + // Use new input format + if (imagesInput) { + images = parseMultilineInput(imagesInput); + } + if (tagsInput) { + tags = parseMultilineInput(tagsInput); + } + } else if (imageName || imageTag) { + // Use legacy input format + if (imageName) { + images = [imageName]; + } + if (imageTag) { + tags = imageTag.split(/\s*,\s*/); + } + } + + // Construct full image names + const fullImageNameArray = constructFullImageNames(images, tags); + const hasImageNames = fullImageNameArray.length > 0; + + // default to 'never' if not set and no imageName/images + if (pushOption === 'never' || (!pushOption && !hasImageNames)) { core.info(`Image push skipped because 'push' is set to '${pushOption}'`); return; } - // default to 'filter' if not set and imageName is set - if (pushOption === 'filter' || (!pushOption && imageName)) { + // default to 'filter' if not set and imageName/images is set + if (pushOption === 'filter' || (!pushOption && hasImageNames)) { // https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables const ref = process.env.GITHUB_REF; if ( @@ -251,30 +348,38 @@ export async function runPost(): Promise { return; } - const imageTag = - emptyStringAsUndefined(core.getInput('imageTag')) ?? 'latest'; - const imageTagArray = imageTag.split(/\s*,\s*/); - if (!imageName) { + if (!hasImageNames) { if (pushOption) { - // pushOption was set (and not to "never") - give an error that imageName is required - core.error('imageName is required to push images'); + // pushOption was set (and not to "never") - give an error that imageName/images is required + core.error('imageName or images is required to push images'); } return; } const platform = emptyStringAsUndefined(core.getInput('platform')); if (platform) { - for (const tag of imageTagArray) { - core.info(`Copying multiplatform image '${imageName}:${tag}'...`); + // For multi-platform builds, fullImageNameArray contains full image:tag combinations + for (const fullImageName of fullImageNameArray) { + core.info(`Copying multiplatform image '${fullImageName}'...`); + const [imageName, tag] = fullImageName.split(':'); const imageSource = `oci-archive:/tmp/output.tar:${tag}`; - const imageDest = `docker://${imageName}:${tag}`; + const imageDest = `docker://${fullImageName}`; await copyImage(true, imageSource, imageDest); } } else { - for (const tag of imageTagArray) { - core.info(`Pushing image '${imageName}:${tag}'...`); - await pushImage(imageName, tag); + // For single-platform builds, parse image and tag for each full image name + for (const fullImageName of fullImageNameArray) { + core.info(`Pushing image '${fullImageName}'...`); + const lastColonIndex = fullImageName.lastIndexOf(':'); + if (lastColonIndex > -1) { + const imageName = fullImageName.substring(0, lastColonIndex); + const tag = fullImageName.substring(lastColonIndex + 1); + await pushImage(imageName, tag); + } else { + // Fallback: treat as image name with 'latest' tag + await pushImage(fullImageName, 'latest'); + } } } }