Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions __tests__/buildx/bake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,70 @@ afterEach(() => {
rimraf.sync(tmpDir);
});

describe('resolveContextTargets', () => {
const target = {context: '.', dockerfile: 'Dockerfile'};

it('includes the root and ignores non-target contexts and the Dockerfile stage', () => {
const definition: BakeDefinition = {
group: {},
target: {app: {...target, target: 'stage', contexts: {local: './src', image: 'docker-image://alpine', git: 'https://example.com/repo.git', empty: 'target:'}}}
};
expect(Bake.resolveContextTargets(definition, 'app')).toEqual(['app']);
});

it('traverses transitive and shared dependencies in discovery order without mutating the definition', () => {
const definition: BakeDefinition = {
group: {},
target: {
app: {...target, contexts: {first: 'target:left', second: 'target:right'}},
left: {...target, contexts: {base: 'target:base'}},
right: {...target, contexts: {base: 'target:base', left: 'target:left'}},
base: target,
unrelated: {...target, contexts: {missing: 'target:missing'}}
}
};
const original = JSON.stringify(definition);
expect(Bake.resolveContextTargets(definition, 'app')).toEqual(['app', 'left', 'right', 'base']);
expect(JSON.stringify(definition)).toBe(original);
});

it('terminates for self references and dependency cycles', () => {
const definition: BakeDefinition = {
group: {},
target: {
app: {...target, contexts: {self: 'target:app', dependency: 'target:base'}},
base: {...target, contexts: {back: 'target:app'}}
}
};
expect(Bake.resolveContextTargets(definition, 'app')).toEqual(['app', 'base']);
});

it('rejects empty definitions', () => {
expect(() => Bake.resolveContextTargets({group: {}, target: {}}, 'app')).toThrow('Bake definition does not contain any targets');
});

it.each(['missing', 'toString', '__proto__'])('rejects undeclared root %s', root => {
expect(() => Bake.resolveContextTargets({group: {}, target: {app: target}}, root)).toThrow(`Unable to resolve ${root} target, found: app`);
});

it('does not treat a group as a target', () => {
expect(() => Bake.resolveContextTargets({group: {all: {targets: ['app']}}, target: {app: target}}, 'all')).toThrow('Unable to resolve all target, found: app');
});

it.each(['missing', 'toString', '__proto__'])('rejects undeclared dependency %s', dependency => {
const definition: BakeDefinition = {group: {}, target: {app: {...target, contexts: {base: `target:${dependency}`}}}};
expect(() => Bake.resolveContextTargets(definition, 'app')).toThrow(`Target app uses unknown named context target ${dependency}`);
});

it('reports missing transitive dependencies against their parent', () => {
const definition: BakeDefinition = {
group: {},
target: {app: {...target, contexts: {base: 'target:base'}}, base: {...target, contexts: {missing: 'target:missing'}}}
};
expect(() => Bake.resolveContextTargets(definition, 'app')).toThrow('Target base uses unknown named context target missing');
});
});

describe('resolveMetadata', () => {
it('matches', async () => {
const bake = new Bake();
Expand Down
61 changes: 61 additions & 0 deletions __tests__/github-builder/bake-targets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Copyright 2026 actions-toolkit authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {describe, expect, it} from 'vitest';

import {BakeTargets} from '../../src/github-builder/bake-targets.js';
import {BakeDefinition} from '../../src/types/buildx/bake.js';

describe('BakeTargets.resolve', () => {
const target = {context: '.', dockerfile: 'Dockerfile'};

it('accepts a single root with transitive named-context targets', () => {
const definition: BakeDefinition = {
group: {default: {targets: ['app']}},
target: {app: {...target, contexts: {base: 'target:base'}}, base: {...target, contexts: {source: 'target:source'}}, source: target}
};
expect(BakeTargets.resolve(definition, 'app')).toEqual(['app', 'base', 'source']);
});

it('accepts a single target without dependencies', () => {
expect(BakeTargets.resolve({group: {}, target: {app: target}}, 'app')).toEqual(['app']);
});

it('rejects targets outside the selected root graph', () => {
const definition: BakeDefinition = {
group: {},
target: {app: {...target, contexts: {base: 'target:base'}}, base: target, other: target, another: target}
};
expect(() => BakeTargets.resolve(definition, 'app')).toThrow('Only one target can be built at once, found unsupported targets: other, another');
});

it('does not expand a group, even if it contains just one target', () => {
expect(() => BakeTargets.resolve({group: {all: {targets: ['app']}}, target: {app: target}}, 'all')).toThrow('Unable to resolve all target, found: app');
});

it('propagates missing dependency errors before checking unrelated targets', () => {
const definition: BakeDefinition = {group: {}, target: {app: {...target, contexts: {base: 'target:missing'}}, unrelated: target}};
expect(() => BakeTargets.resolve(definition, 'app')).toThrow('Target app uses unknown named context target missing');
});

it('leaves cycle validation to Buildx', () => {
const definition: BakeDefinition = {
group: {},
target: {app: {...target, contexts: {base: 'target:base'}}, base: {...target, contexts: {app: 'target:app'}}}
};
expect(BakeTargets.resolve(definition, 'app')).toEqual(['app', 'base']);
});
});
130 changes: 130 additions & 0 deletions __tests__/github-builder/build-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Copyright 2026 actions-toolkit authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {afterEach, describe, expect, it, vi} from 'vitest';
import fs from 'fs';
import path from 'path';
import * as core from '@actions/core';

import {BuildSecrets} from '../../src/github-builder/build-secrets.js';
import {Context} from '../../src/context.js';

vi.mock('@actions/core', () => ({setSecret: vi.fn(), exportVariable: vi.fn(), setOutput: vi.fn()}));

const directories: Array<string> = [];
afterEach(() => {
vi.restoreAllMocks();
directories.splice(0).forEach(directory => BuildSecrets.cleanup(directory));
});

describe('BuildSecrets', () => {
it.each(['', ' \n', '---\n', '{}'])('does not create files for empty input %j', input => {
expect(BuildSecrets.prepareBuild(input)).toEqual({directory: '', inputs: []});
expect(BuildSecrets.prepareBake(input, 'app', ['app'])).toEqual({directory: '', inputs: []});
});

it.each(['[value]', 'value', 'key: [value]', 'key: {nested: value}'])('rejects invalid Build shape %j', input => {
expect(() => BuildSecrets.prepareBuild(input)).toThrow(/build-secrets/);
});

it.each(['key: [value]', 'app: {key: {nested: value}}'])('rejects invalid Bake shape %j', input => {
expect(() => BuildSecrets.prepareBake(input, 'app', ['app'])).toThrow(/build-secrets/);
});

it.each(['key: !PRIVATE value', 'key: *PRIVATE', 'key: PRIVATE\nkey: PRIVATE', 'key: [PRIVATE'])('does not expose YAML errors for %j', input => {
expect(() => BuildSecrets.prepareBuild(input)).toThrow(/^Failed to parse build-secrets YAML at line \d+, column \d+$/);
expect(() => BuildSecrets.prepareBake(input, 'app', ['app'])).toThrow(/^Failed to parse build-secrets YAML at line \d+, column \d+$/);
});

it.each(['', 'foo,bar', 'foo"bar', 'foo=bar', ' foo', 'foo ', 'foo\nbar', 'foo\rbar', 'GIT_AUTH_TOKEN'])('rejects Build ID %j', id => {
expect(() => BuildSecrets.prepareBuild(`${JSON.stringify(id)}: value`)).toThrow(/Build secret/);
});

it.each(['', 'foo=bar', 'foo\nbar', 'foo\rbar'])('rejects Bake ID %j', id => {
expect(() => BuildSecrets.prepareBake(`${JSON.stringify(id)}: value`, 'app', ['app'])).toThrow(/Build secret IDs/);
});

it.each(['empty: ""\nkeep: |+\n line\n\n', 'empty: ""\nkeep: "line\\n\\n"'])('preserves exact bytes and masks values for %j', input => {
const result = BuildSecrets.prepareBuild(input);
directories.push(result.directory);
expect(result.inputs).toEqual([`empty=${path.join(result.directory, '0')}`, `keep=${path.join(result.directory, '1')}`]);
expect(fs.readFileSync(path.join(result.directory, '0'), 'utf8')).toBe('');
expect(fs.readFileSync(path.join(result.directory, '1'), 'utf8')).toBe('line\n\n');
expect(core.setSecret).toHaveBeenCalledWith('');
expect(core.setSecret).toHaveBeenCalledWith('line\n\n');
expect(core.exportVariable).not.toHaveBeenCalled();
expect(core.setOutput).not.toHaveBeenCalled();
});

it('keeps scalar spellings and empty values without YAML coercion', () => {
const result = BuildSecrets.prepareBuild('yes: yes\nnumber: 123\nboolean: true\nnull: ~\nempty: ""');
directories.push(result.directory);
expect(result.inputs.map((_, index) => fs.readFileSync(path.join(result.directory, String(index)), 'utf8'))).toEqual(['yes', '123', 'true', '~', '']);
});

it('scopes nested mappings without interpreting dots or commas in Bake IDs', () => {
const result = BuildSecrets.prepareBake('release.token: first\n.npmrc: second\nrelease:\n aws.credentials: third\n foo,bar: fourth\n', 'app', ['app', 'release']);
directories.push(result.directory);
expect(result.inputs).toEqual(['app.secret.release.token', 'app.secret..npmrc', 'release.secret.aws.credentials', 'release.secret.foo,bar'].map((key, index) => `${key}=src=${path.join(result.directory, String(index))}`));
expect(result.inputs.map((_, index) => fs.readFileSync(path.join(result.directory, String(index)), 'utf8'))).toEqual(['first', 'second', 'third', 'fourth']);
});

it.each(['token: value', 'other:\n token: value'])('rejects unresolved targets before creating files for %j', input => {
const mkdir = vi.spyOn(fs, 'mkdtempSync');
expect(() => BuildSecrets.prepareBake(input, 'missing', ['app'])).toThrow(/not part of the resolved Bake definition/);
expect(mkdir).not.toHaveBeenCalled();
});

it('preserves override order for duplicate canonical Bake IDs', () => {
const result = BuildSecrets.prepareBake('token: first\napp:\n token: second', 'app', ['app']);
directories.push(result.directory);
expect(result.inputs).toEqual([0, 1].map(index => `app.secret.token=src=${path.join(result.directory, String(index))}`));
});

it('uses private files, unique directories and idempotent cleanup', () => {
const write = vi.spyOn(fs, 'writeFileSync');
const first = BuildSecrets.prepareBuild('token: value');
const second = BuildSecrets.prepareBuild('token: value');
directories.push(first.directory, second.directory);
expect(first.directory).not.toBe(second.directory);
expect(write).toHaveBeenCalledWith(path.join(first.directory, '0'), 'value', {mode: 0o600});
BuildSecrets.cleanup(first.directory);
BuildSecrets.cleanup(first.directory);
BuildSecrets.cleanup('');
expect(fs.existsSync(first.directory)).toBe(false);
expect(fs.existsSync(second.directory)).toBe(true);
});

it.skipIf(process.platform === 'win32')('sets restrictive POSIX permissions', () => {
const result = BuildSecrets.prepareBuild('token: value');
directories.push(result.directory);
expect(fs.statSync(result.directory).mode & 0o777).toBe(0o700);
expect(fs.statSync(path.join(result.directory, '0')).mode & 0o777).toBe(0o600);
});

it('removes partial files if a write fails', () => {
const before = fs.readdirSync(Context.tmpDir());
const write = fs.writeFileSync.bind(fs);
vi.spyOn(fs, 'writeFileSync').mockImplementation((file, data, options) => {
if (path.basename(String(file)) === '1') {
throw new Error('write failed');
}
return write(file, data, options);
});
expect(() => BuildSecrets.prepareBuild('first: value\nsecond: value')).toThrow('write failed');
expect(fs.readdirSync(Context.tmpDir())).toEqual(before);
});
});
111 changes: 111 additions & 0 deletions __tests__/github-builder/registry-identities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Copyright 2026 actions-toolkit authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {describe, expect, it} from 'vitest';

import {RegistryIdentities} from '../../src/github-builder/registry-identities.js';

const aws = {type: 'aws-ecr', registry: '123.dkr.ecr.us-east-1.amazonaws.com', 'role-to-assume': 'arn:aws:iam::123:role/build', region: 'us-east-1'};
const gcp = {type: 'gcp-wif', registry: 'us-docker.pkg.dev', workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/pool/providers/provider', service_account: 'build@example.iam.gserviceaccount.com'};
const hub = {type: 'dockerhub', username: 'builder', connection_id: 'connection'};

describe('RegistryIdentities.parse', () => {
it.each(['', ' \n\t', 'null', '~', '---\n', '[]'])('accepts empty configuration %j', input => {
expect(RegistryIdentities.parse(input)).toEqual({});
});

it('accepts a single AWS identity and trims field values', () => {
expect(RegistryIdentities.parse('type: " aws-ecr "\nregistry: " registry "\nrole-to-assume: " role "\nregion: " region "')).toEqual({
awsEcr: {registry: 'registry', roleToAssume: 'role', region: 'region'}
});
});

it('accepts all providers in a list with optional field defaults', () => {
expect(RegistryIdentities.parse(JSON.stringify([aws, gcp, hub]))).toEqual({
awsEcr: {registry: aws.registry, roleToAssume: aws['role-to-assume'], region: aws.region},
gcpWif: {registry: gcp.registry, workloadIdentityProvider: gcp.workload_identity_provider, serviceAccount: gcp.service_account, projectId: ''},
dockerhubOidc: {registry: 'docker.io', username: 'builder', connectionID: 'connection'}
});
});

it('preserves explicit optional fields, trimming whitespace', () => {
const result = RegistryIdentities.parse(
JSON.stringify([
{...gcp, project_id: ' project '},
{...hub, registry: ' index.docker.io '}
])
);
expect(result.gcpWif?.projectId).toBe('project');
expect(result.dockerhubOidc?.registry).toBe('index.docker.io');
});

it.each([aws, gcp, hub])('rejects duplicate provider $type', identity => {
expect(() => RegistryIdentities.parse(JSON.stringify([identity, identity]))).toThrow(`only one ${identity.type} registry identity is supported`);
});

it.each([aws, gcp, hub])('rejects unknown fields for $type', identity => {
expect(() => RegistryIdentities.parse(JSON.stringify({...identity, unexpected: 'value'}))).toThrow(`registry-identities[0].unexpected is not supported for ${identity.type}`);
});

it.each([
[aws, 'registry'],
[aws, 'role-to-assume'],
[aws, 'region'],
[gcp, 'registry'],
[gcp, 'workload_identity_provider'],
[gcp, 'service_account'],
[hub, 'username'],
[hub, 'connection_id'],
[hub, 'type']
] as Array<[Record<string, unknown>, string]>)('rejects missing or invalid required fields in %j: %s', (identity, key) => {
const missing = {...identity};
delete missing[key];
expect(() => RegistryIdentities.parse(JSON.stringify(missing))).toThrow(`registry-identities[0].${key} must be a non-empty string`);
for (const value of ['', ' ', null, true, 123, [], {}]) {
expect(() => RegistryIdentities.parse(JSON.stringify({...identity, [key]: value}))).toThrow(`registry-identities[0].${key} must be a non-empty string`);
}
});

it.each([
[gcp, 'project_id'],
[hub, 'registry']
] as Array<[Record<string, unknown>, string]>)('rejects invalid optional fields in %j: %s', (identity, key) => {
for (const value of ['', ' ', null, false, 123, [], {}]) {
expect(() => RegistryIdentities.parse(JSON.stringify({...identity, [key]: value}))).toThrow(`registry-identities[0].${key} must be a non-empty string`);
}
});

it.each(['text', 'true', '123', '[null]', '[[]]', '[text]'])('rejects non-object entries %j', input => {
expect(() => RegistryIdentities.parse(input)).toThrow('Invalid registry-identities input: registry-identities[0] must be an object');
});

it('reports the index of an invalid entry', () => {
expect(() => RegistryIdentities.parse(JSON.stringify([aws, null]))).toThrow('registry-identities[1] must be an object');
});

it('rejects unknown providers', () => {
expect(() => RegistryIdentities.parse('type: unknown')).toThrow('registry-identities[0].type has unsupported provider unknown');
});

it('retains default YAML scalar typing rather than coercing identity fields to strings', () => {
expect(() => RegistryIdentities.parse('type: dockerhub\nusername: user\nconnection_id: 123')).toThrow('connection_id must be a non-empty string');
expect(RegistryIdentities.parse('type: dockerhub\nusername: user\nconnection_id: "123"').dockerhubOidc?.connectionID).toBe('123');
});

it.each(['type: [PRIVATE', 'type: !PRIVATE value', 'type: *PRIVATE', 'type: PRIVATE\ntype: dockerhub'])('suppresses secret-bearing YAML errors for %j', input => {
expect(() => RegistryIdentities.parse(input)).toThrow(/^Invalid registry-identities input: Failed to parse YAML at line \d+, column \d+$/);
});
});
Loading
Loading