diff --git a/README.md b/README.md index b997339..5fe3460 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ ___ * [`type=ref`](#typeref) * [`type=raw`](#typeraw) * [`type=sha`](#typesha) +* [`annotations` and `labels` inputs](#annotations-and-labels-inputs) + * [Default labels and annotations](#default-labels-and-annotations) + * [Customize labels and annotations](#customize-labels-and-annotations) + * [Annotation outputs](#annotation-outputs) + * [Annotation levels](#annotation-levels) * [Notes](#notes) * [Image name and tag sanitization](#image-name-and-tag-sanitization) * [Latest tag](#latest-tag) @@ -49,8 +54,6 @@ ___ * [`{{commit_date '' tz=''}}`](#commit_date-format-tztimezone) * [Major version zero](#major-version-zero) * [JSON output object](#json-output-object) - * [Overwrite labels and annotations](#overwrite-labels-and-annotations) - * [Annotations](#annotations) * [Contributing](#contributing) ## Usage @@ -745,6 +748,128 @@ tags: | type=sha,enable=true,priority=100,prefix=sha-,suffix=,format=short ``` +## `annotations` and `labels` inputs + +### Default labels and annotations + +The action will set the following default labels and annotations based on repository metadata: + +- `org.opencontainers.image.title` +- `org.opencontainers.image.description` +- `org.opencontainers.image.url` +- `org.opencontainers.image.source` +- `org.opencontainers.image.version` +- `org.opencontainers.image.created` +- `org.opencontainers.image.revision` +- `org.opencontainers.image.licenses` + +### Customize labels and annotations + +If some [OCI Image Format Specification](https://github.com/opencontainers/image-spec/blob/master/annotations.md) +generated are not suitable as labels/annotations, you can overwrite them like +this: + +```yaml + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: name/app + labels: | + maintainer=CrazyMax + org.opencontainers.image.title=MyCustomTitle + org.opencontainers.image.description=Another description + org.opencontainers.image.vendor=MyCompany +``` + +Alternatively, you may wish to omit certain labels and annotations from the action output. For example, you may have `LABEL` directives in a Dockerfile that you would prefer to use instead of the labels set by this action. You can omit labels and annotations with `enable=false`. + +```yaml + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: name/app + labels: | + name=org.opencontainers.image.url,enable=false +``` + +### Annotation outputs + +Since Buildx 0.12, it is possible to set annotations to your image through the +`--annotation` flag. + +With the [`build-push-action`](https://github.com/docker/build-push-action/), +you can set the `annotations` input with the value of the `annotations` output +of the `metadata-action`: + +```yaml + - + name: Docker meta + uses: docker/metadata-action@v5 + with: + images: name/app + - + name: Build and push + uses: docker/build-push-action@v6 + with: + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} +``` + +The same can be done with the [`bake-action`](https://github.com/docker/bake-action/): + +```yaml + - + name: Docker meta + uses: docker/metadata-action@v5 + with: + images: name/app + - + name: Build + uses: docker/bake-action@v6 + with: + files: | + ./docker-bake.hcl + cwd://${{ steps.meta.outputs.bake-file-tags }} + cwd://${{ steps.meta.outputs.bake-file-annotations }} + targets: build +``` + +### Annotation levels + +Note that annotations can be attached at many different levels within a manifest. +By default, the generated annotations will be attached to image manifests, +but different registries may expect annotations at different places; +a common practice is to read annotations at _image indexes_ if present, +which are often used by multi-arch builds to index platform-specific images. +If you want to specify level(s) for your annotations, you can use the +[`DOCKER_METADATA_ANNOTATIONS_LEVELS` environment variable](#environment-variables) +with a comma separated list of all levels the annotations should be attached to (defaults to `manifest`). +The following configuration demonstrates the ability to attach annotations to both image manifests and image indexes, +though your registry may only need annotations at the index level. (That is, `index` alone may be enough.) +Please consult the documentation of your registry. + +```yaml + - + name: Docker meta + uses: docker/metadata-action@v5 + with: + images: name/app + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + - + name: Build and push + uses: docker/build-push-action@v6 + with: + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} +``` + +More information about annotations in the [BuildKit documentation](https://github.com/moby/buildkit/blob/master/docs/annotations.md). + ## Notes ### Image name and tag sanitization @@ -955,98 +1080,6 @@ that you can reuse them further in your workflow using the [`fromJSON` function] REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} ``` -### Overwrite labels and annotations - -If some [OCI Image Format Specification](https://github.com/opencontainers/image-spec/blob/master/annotations.md) -generated are not suitable as labels/annotations, you can overwrite them like -this: - -```yaml - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: name/app - labels: | - maintainer=CrazyMax - org.opencontainers.image.title=MyCustomTitle - org.opencontainers.image.description=Another description - org.opencontainers.image.vendor=MyCompany -``` - -### Annotations - -Since Buildx 0.12, it is possible to set annotations to your image through the -`--annotation` flag. - -With the [`build-push-action`](https://github.com/docker/build-push-action/), -you can set the `annotations` input with the value of the `annotations` output -of the `metadata-action`: - -```yaml - - - name: Docker meta - uses: docker/metadata-action@v5 - with: - images: name/app - - - name: Build and push - uses: docker/build-push-action@v6 - with: - tags: ${{ steps.meta.outputs.tags }} - annotations: ${{ steps.meta.outputs.annotations }} -``` - -The same can be done with the [`bake-action`](https://github.com/docker/bake-action/): - -```yaml - - - name: Docker meta - uses: docker/metadata-action@v5 - with: - images: name/app - - - name: Build - uses: docker/bake-action@v6 - with: - files: | - ./docker-bake.hcl - cwd://${{ steps.meta.outputs.bake-file-tags }} - cwd://${{ steps.meta.outputs.bake-file-annotations }} - targets: build -``` - -Note that annotations can be attached at many different levels within a manifest. -By default, the generated annotations will be attached to image manifests, -but different registries may expect annotations at different places; -a common practice is to read annotations at _image indexes_ if present, -which are often used by multi-arch builds to index platform-specific images. -If you want to specify level(s) for your annotations, you can use the -[`DOCKER_METADATA_ANNOTATIONS_LEVELS` environment variable](#environment-variables) -with a comma separated list of all levels the annotations should be attached to (defaults to `manifest`). -The following configuration demonstrates the ability to attach annotations to both image manifests and image indexes, -though your registry may only need annotations at the index level. (That is, `index` alone may be enough.) -Please consult the documentation of your registry. - -```yaml - - - name: Docker meta - uses: docker/metadata-action@v5 - with: - images: name/app - env: - DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - - - name: Build and push - uses: docker/build-push-action@v6 - with: - tags: ${{ steps.meta.outputs.tags }} - annotations: ${{ steps.meta.outputs.annotations }} -``` - -More information about annotations in the [BuildKit documentation](https://github.com/moby/buildkit/blob/master/docs/annotations.md). - ## Contributing Want to contribute? Awesome! You can find information about contributing to diff --git a/__tests__/annotation.test.ts b/__tests__/annotation.test.ts new file mode 100644 index 0000000..6a778dc --- /dev/null +++ b/__tests__/annotation.test.ts @@ -0,0 +1,83 @@ +import {describe, expect, test} from '@jest/globals'; + +import {Transform, Annotation} from '../src/annotation'; + +describe('annotation transform', () => { + test.each([ + [ + [`org.opencontainers.image.version=1.1.1`], + [ + { + name: `org.opencontainers.image.version`, + value: `1.1.1`, + enable: true + } + ] as Annotation[], + false + ], + [ + [`name=my.annotation,value="my value",enable=true`], + [ + { + name: `my.annotation`, + value: `"my value"`, + enable: true + } + ] as Annotation[], + false + ], + [ + [`name=my.annotation,value=myvalue,enable=false`], + [ + { + name: `my.annotation`, + value: `myvalue`, + enable: false + } + ] as Annotation[], + false + ], + [ + [`my.annotation=my value`], + [ + { + name: `my.annotation`, + value: `my value`, + enable: true + } + ] as Annotation[], + false + ], + [ + [`name=,value=val`], // empty name + undefined, + true + ], + [ + [`name=org.opencontainers.image.url,enable=false`], // empty value + [ + { + name: `org.opencontainers.image.url`, + value: null, + enable: false + } + ] as Annotation[], + false + ], + [ + [`name=my.annotation,value=myvalue,enable=bar`], // invalid enable + undefined, + true + ] + ])('given %p', async (l: string[], expected: Annotation[] | undefined, invalid: boolean) => { + try { + const annotations = Transform(l); + expect(annotations).toEqual(expected); + } catch (err) { + if (!invalid) { + console.error(err); + } + expect(invalid).toBeTruthy(); + } + }); +}); diff --git a/src/annotation.ts b/src/annotation.ts new file mode 100644 index 0000000..e33b2f1 --- /dev/null +++ b/src/annotation.ts @@ -0,0 +1,97 @@ +import {parse} from 'csv-parse/sync'; +import * as core from '@actions/core'; + +export interface Annotation { + name: string; + value: string | null; + enable: boolean; +} + +export function Transform(inputs: string[]): Annotation[] { + let annotations: Annotation[] = []; + + for (const input of inputs) { + const annotation: Annotation = {name: '', value: null, enable: true}; + const fields = parse(input, { + relaxColumnCount: true, + relaxQuotes: true, + skipEmptyLines: true + })[0]; + let usesAttributes = false; + + for (const field of fields) { + const parts = field + .toString() + .split('=') + .map(item => item.trim()); + if (parts.length > 0) { + const key = parts[0].toLowerCase(); + if (['name', 'value', 'enable'].includes(key)) { + usesAttributes = true; + break; + } + } + } + + if (usesAttributes) { + for (const field of fields) { + const parts = field + .toString() + .split('=') + .map(item => item.trim()); + if (parts.length === 1) { + annotation.name = parts[0]; + } else { + const key = parts[0].toLowerCase(); + const value = parts.slice(1).join('='); // preserve '=' in values if any + switch (key) { + case 'name': { + annotation.name = value; + break; + } + case 'value': { + annotation.value = value; + break; + } + case 'enable': { + if (!['true', 'false'].includes(value.toLowerCase())) { + throw new Error(`Invalid enable attribute value: ${input}`); + } + annotation.enable = /true/i.test(value); + break; + } + default: { + throw new Error(`Unknown annotation attribute: ${input}`); + } + } + } + } + } else { + const idx = input.indexOf('='); + if (idx === -1) { + annotation.name = input.trim(); + } else { + annotation.name = input.substring(0, idx).trim(); + annotation.value = input.substring(idx + 1).trim(); + } + annotation.enable = true; + } + + if (annotation.name.length === 0) { + throw new Error(`Annotation name attribute empty: ${input}`); + } + + annotations.push(annotation); + } + + return output(annotations); +} + +function output(annotations: Annotation[]): Annotation[] { + core.startGroup(`Processing annotations input`); + for (const annotation of annotations) { + core.info(`name=${annotation.name},value=${annotation.value},enable=${annotation.enable}`); + } + core.endGroup(); + return annotations; +} diff --git a/src/meta.ts b/src/meta.ts index 5df7215..5f3979b 100644 --- a/src/meta.ts +++ b/src/meta.ts @@ -12,6 +12,7 @@ import {Inputs, Context} from './context'; import * as icl from './image'; import * as tcl from './tag'; import * as fcl from './flavor'; +import * as acl from './annotation'; const defaultShortShaLength = 7; @@ -30,6 +31,8 @@ export class Meta { private readonly images: icl.Image[]; private readonly tags: tcl.Tag[]; private readonly flavor: fcl.Flavor; + private readonly annotations: acl.Annotation[]; + private readonly labels: acl.Annotation[]; private readonly date: Date; constructor(inputs: Inputs, context: Context, repo: GitHubRepo) { @@ -39,6 +42,8 @@ export class Meta { this.images = icl.Transform(inputs.images); this.tags = tcl.Transform(inputs.tags); this.flavor = fcl.Transform(inputs.flavor); + this.annotations = acl.Transform(inputs.annotations); + this.labels = acl.Transform(inputs.labels); this.date = new Date(); this.version = this.getVersion(); } @@ -530,14 +535,14 @@ export class Meta { } public getLabels(): Array { - return this.getOCIAnnotationsWithCustoms(this.inputs.labels); + return this.getOCIAnnotationsWithCustoms(this.labels); } public getAnnotations(): Array { - return this.getOCIAnnotationsWithCustoms(this.inputs.annotations); + return this.getOCIAnnotationsWithCustoms(this.annotations); } - private getOCIAnnotationsWithCustoms(extra: string[]): Array { + private getOCIAnnotationsWithCustoms(annotations: acl.Annotation[]): Array { const res: Array = [ `org.opencontainers.image.title=${this.repo.name || ''}`, `org.opencontainers.image.description=${this.repo.description || ''}`, @@ -548,10 +553,12 @@ export class Meta { `org.opencontainers.image.revision=${this.context.sha || ''}`, `org.opencontainers.image.licenses=${this.repo.license?.spdx_id || ''}` ]; - extra.forEach(label => { - res.push(this.setGlobalExp(label)); + annotations.forEach(annotation => { + if (annotation.enable && annotation.value != null) { + const expandedValue = this.setGlobalExp(annotation.value); + res.push(`${annotation.name}=${expandedValue}`); + } }); - return Array.from( new Map( res