mirror of
https://github.com/docker/metadata-action.git
synced 2026-01-22 06:28:56 +08:00
feat: enable customization of annotations+labels
Signed-off-by: Brendon Smith <bws@bws.bio>
This commit is contained in:
parent
ed95091677
commit
b0b9842728
4 changed files with 320 additions and 100 deletions
221
README.md
221
README.md
|
|
@ -34,6 +34,11 @@ ___
|
||||||
* [`type=ref`](#typeref)
|
* [`type=ref`](#typeref)
|
||||||
* [`type=raw`](#typeraw)
|
* [`type=raw`](#typeraw)
|
||||||
* [`type=sha`](#typesha)
|
* [`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)
|
* [Notes](#notes)
|
||||||
* [Image name and tag sanitization](#image-name-and-tag-sanitization)
|
* [Image name and tag sanitization](#image-name-and-tag-sanitization)
|
||||||
* [Latest tag](#latest-tag)
|
* [Latest tag](#latest-tag)
|
||||||
|
|
@ -49,8 +54,6 @@ ___
|
||||||
* [`{{commit_date '<format>' tz='<timezone>'}}`](#commit_date-format-tztimezone)
|
* [`{{commit_date '<format>' tz='<timezone>'}}`](#commit_date-format-tztimezone)
|
||||||
* [Major version zero](#major-version-zero)
|
* [Major version zero](#major-version-zero)
|
||||||
* [JSON output object](#json-output-object)
|
* [JSON output object](#json-output-object)
|
||||||
* [Overwrite labels and annotations](#overwrite-labels-and-annotations)
|
|
||||||
* [Annotations](#annotations)
|
|
||||||
* [Contributing](#contributing)
|
* [Contributing](#contributing)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -745,6 +748,128 @@ tags: |
|
||||||
type=sha,enable=true,priority=100,prefix=sha-,suffix=,format=short
|
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
|
## Notes
|
||||||
|
|
||||||
### Image name and tag sanitization
|
### 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'] }}
|
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
|
## Contributing
|
||||||
|
|
||||||
Want to contribute? Awesome! You can find information about contributing to
|
Want to contribute? Awesome! You can find information about contributing to
|
||||||
|
|
|
||||||
83
__tests__/annotation.test.ts
Normal file
83
__tests__/annotation.test.ts
Normal 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
97
src/annotation.ts
Normal 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;
|
||||||
|
}
|
||||||
19
src/meta.ts
19
src/meta.ts
|
|
@ -12,6 +12,7 @@ import {Inputs, Context} from './context';
|
||||||
import * as icl from './image';
|
import * as icl from './image';
|
||||||
import * as tcl from './tag';
|
import * as tcl from './tag';
|
||||||
import * as fcl from './flavor';
|
import * as fcl from './flavor';
|
||||||
|
import * as acl from './annotation';
|
||||||
|
|
||||||
const defaultShortShaLength = 7;
|
const defaultShortShaLength = 7;
|
||||||
|
|
||||||
|
|
@ -30,6 +31,8 @@ export class Meta {
|
||||||
private readonly images: icl.Image[];
|
private readonly images: icl.Image[];
|
||||||
private readonly tags: tcl.Tag[];
|
private readonly tags: tcl.Tag[];
|
||||||
private readonly flavor: fcl.Flavor;
|
private readonly flavor: fcl.Flavor;
|
||||||
|
private readonly annotations: acl.Annotation[];
|
||||||
|
private readonly labels: acl.Annotation[];
|
||||||
private readonly date: Date;
|
private readonly date: Date;
|
||||||
|
|
||||||
constructor(inputs: Inputs, context: Context, repo: GitHubRepo) {
|
constructor(inputs: Inputs, context: Context, repo: GitHubRepo) {
|
||||||
|
|
@ -39,6 +42,8 @@ export class Meta {
|
||||||
this.images = icl.Transform(inputs.images);
|
this.images = icl.Transform(inputs.images);
|
||||||
this.tags = tcl.Transform(inputs.tags);
|
this.tags = tcl.Transform(inputs.tags);
|
||||||
this.flavor = fcl.Transform(inputs.flavor);
|
this.flavor = fcl.Transform(inputs.flavor);
|
||||||
|
this.annotations = acl.Transform(inputs.annotations);
|
||||||
|
this.labels = acl.Transform(inputs.labels);
|
||||||
this.date = new Date();
|
this.date = new Date();
|
||||||
this.version = this.getVersion();
|
this.version = this.getVersion();
|
||||||
}
|
}
|
||||||
|
|
@ -530,14 +535,14 @@ export class Meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLabels(): Array<string> {
|
public getLabels(): Array<string> {
|
||||||
return this.getOCIAnnotationsWithCustoms(this.inputs.labels);
|
return this.getOCIAnnotationsWithCustoms(this.labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAnnotations(): Array<string> {
|
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> = [
|
const res: Array<string> = [
|
||||||
`org.opencontainers.image.title=${this.repo.name || ''}`,
|
`org.opencontainers.image.title=${this.repo.name || ''}`,
|
||||||
`org.opencontainers.image.description=${this.repo.description || ''}`,
|
`org.opencontainers.image.description=${this.repo.description || ''}`,
|
||||||
|
|
@ -548,10 +553,12 @@ export class Meta {
|
||||||
`org.opencontainers.image.revision=${this.context.sha || ''}`,
|
`org.opencontainers.image.revision=${this.context.sha || ''}`,
|
||||||
`org.opencontainers.image.licenses=${this.repo.license?.spdx_id || ''}`
|
`org.opencontainers.image.licenses=${this.repo.license?.spdx_id || ''}`
|
||||||
];
|
];
|
||||||
extra.forEach(label => {
|
annotations.forEach(annotation => {
|
||||||
res.push(this.setGlobalExp(label));
|
if (annotation.enable && annotation.value != null) {
|
||||||
|
const expandedValue = this.setGlobalExp(annotation.value);
|
||||||
|
res.push(`${annotation.name}=${expandedValue}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Map<string, string>(
|
new Map<string, string>(
|
||||||
res
|
res
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue