feat: enable customization of annotations+labels

Signed-off-by: Brendon Smith <bws@bws.bio>
This commit is contained in:
Brendon Smith 2026-01-18 15:59:08 -05:00
parent ed95091677
commit b0b9842728
No known key found for this signature in database
4 changed files with 320 additions and 100 deletions

221
README.md
View file

@ -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 '<format>' tz='<timezone>'}}`](#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

View file

@ -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();
}
});
});

97
src/annotation.ts Normal file
View file

@ -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;
}

View file

@ -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<string> {
return this.getOCIAnnotationsWithCustoms(this.inputs.labels);
return this.getOCIAnnotationsWithCustoms(this.labels);
}
public getAnnotations(): Array<string> {
return this.getOCIAnnotationsWithCustoms(this.inputs.annotations);
return this.getOCIAnnotationsWithCustoms(this.annotations);
}
private getOCIAnnotationsWithCustoms(extra: string[]): Array<string> {
private getOCIAnnotationsWithCustoms(annotations: acl.Annotation[]): Array<string> {
const res: Array<string> = [
`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<string, string>(
res