From 9aa5f34716e7cbb664d7cde2604317508d7fd670 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:43:26 +0200 Subject: [PATCH 1/4] github-builder: add file-backed build secrets helper Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .../github-builder/build-secrets.test.ts | 130 ++++++++++++++++++ src/github-builder/build-secrets.ts | 130 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 __tests__/github-builder/build-secrets.test.ts create mode 100644 src/github-builder/build-secrets.ts diff --git a/__tests__/github-builder/build-secrets.test.ts b/__tests__/github-builder/build-secrets.test.ts new file mode 100644 index 00000000..812a70f5 --- /dev/null +++ b/__tests__/github-builder/build-secrets.test.ts @@ -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 = []; +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); + }); +}); diff --git a/src/github-builder/build-secrets.ts b/src/github-builder/build-secrets.ts new file mode 100644 index 00000000..26630a6c --- /dev/null +++ b/src/github-builder/build-secrets.ts @@ -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 fs from 'fs'; +import path from 'path'; +import * as core from '@actions/core'; +import {FAILSAFE_SCHEMA, load, YAMLException} from 'js-yaml'; + +import {Context} from '../context.js'; + +export interface PreparedBuildSecrets { + // Persist as a step output for cleanup after the build, including on failure + directory: string; + // Join with newlines for Build secret-files or append to Bake set input + inputs: Array; +} + +interface Secret { + key: string; + value: string; +} + +// The github-builder build-secrets contract, backed by temporary files. +// Read the input with core.getInput('build-secrets', {trimWhitespace: false}). +export class BuildSecrets { + public static prepareBuild(input: string): PreparedBuildSecrets { + const secrets = Object.entries(BuildSecrets.parse(input)).map(([id, value]) => { + // secret-files is parsed as a comma-aware list of id=path entries. + if (!id || id !== id.trim() || /[\r\n=,"]/.test(id)) { + throw new Error('Build secret IDs must not be empty or contain surrounding whitespace, line breaks, commas, double quotes or "="'); + } + if (id === 'GIT_AUTH_TOKEN') { + throw new Error('Build secret id "GIT_AUTH_TOKEN" is reserved for Git context authentication'); + } + if (typeof value !== 'string') { + throw new Error(`build-secrets value for "${id}" must be a string`); + } + return {key: id, value}; + }); + return BuildSecrets.write(secrets, ''); + } + + public static prepareBake(input: string, defaultTarget: string, targets: Array): PreparedBuildSecrets { + const allowedTargets = new Set(targets); + const secrets = Object.entries(BuildSecrets.parse(input)).flatMap(([key, value]) => { + if (typeof value !== 'string' && (!value || typeof value !== 'object' || Array.isArray(value))) { + throw new Error('build-secrets entries must be secret strings or target mappings'); + } + const target = typeof value === 'string' ? defaultTarget : key; + if (!target || /[\r\n=]/.test(target)) { + throw new Error('Build secret targets must not be empty or contain line breaks or "="'); + } + if (!allowedTargets.has(target)) { + throw new Error(`Build secret target "${target}" is not part of the resolved Bake definition`); + } + // Only nested mappings qualify targets; dots in IDs are always literal. + const entries: Array<[string, unknown]> = typeof value === 'string' ? [[key, value]] : Object.entries(value); + return entries.map(([id, secret]) => { + if (!id || /[\r\n=]/.test(id)) { + throw new Error('Build secret IDs must not be empty or contain line breaks or "="'); + } + if (typeof secret !== 'string') { + throw new Error('build-secrets values within target mappings must be strings'); + } + return {key: `${target}.secret.${id}`, value: secret}; + }); + }); + return BuildSecrets.write(secrets, 'src='); + } + + // Call from the workflow always() cleanup step using the returned directory + public static cleanup(directory: string): void { + if (directory) { + fs.rmSync(directory, {recursive: true, force: true}); + } + } + + private static parse(input: string): Record { + if (!input.trim()) { + return {}; + } + let parsed: unknown; + try { + parsed = load(input, {schema: FAILSAFE_SCHEMA}); + } catch (err) { + // Messages and reasons can include secrets, even without a source excerpt. + const location = err instanceof YAMLException && err.mark ? ` at line ${err.mark.line + 1}, column ${err.mark.column + 1}` : ''; + throw new Error(`Failed to parse build-secrets YAML${location}`); + } + if (!parsed) { + return {}; + } + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('build-secrets must be a YAML object'); + } + return parsed as Record; + } + + private static write(secrets: Array, source: string): PreparedBuildSecrets { + if (!secrets.length) { + return {directory: '', inputs: []}; + } + secrets.forEach(secret => core.setSecret(secret.value)); + const directory = fs.mkdtempSync(path.join(Context.tmpDir(), 'build-secrets-')); + try { + const inputs = secrets.map(({key, value}, index) => { + const file = path.join(directory, String(index)); + fs.writeFileSync(file, value, {mode: 0o600}); + return `${key}=${source}${file}`; + }); + return {directory, inputs}; + } catch (err) { + BuildSecrets.cleanup(directory); + throw err; + } + } +} From 4e05564f6ab68e909bd9ba8c968b62fad314515e Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:45:51 +0200 Subject: [PATCH 2/4] github-builder: add runner mapping parser and resolver Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .../github-builder/runner-mapping.test.ts | 118 ++++++++++++++++++ src/github-builder/runner-mapping.ts | 100 +++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 __tests__/github-builder/runner-mapping.test.ts create mode 100644 src/github-builder/runner-mapping.ts diff --git a/__tests__/github-builder/runner-mapping.test.ts b/__tests__/github-builder/runner-mapping.test.ts new file mode 100644 index 00000000..abdce10e --- /dev/null +++ b/__tests__/github-builder/runner-mapping.test.ts @@ -0,0 +1,118 @@ +/** + * 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, vi} from 'vitest'; +import * as core from '@actions/core'; + +import {RunnerMapping} from '../../src/github-builder/runner-mapping.js'; + +vi.mock('@actions/core', () => ({warning: vi.fn()})); + +describe('parse', () => { + it('accepts a label and ignores blank lines without mutating the input', () => { + const input = [' ', ' ubuntu-24.04 ', '\t']; + expect(RunnerMapping.parse(input)).toEqual({defaultRunner: 'ubuntu-24.04', rules: []}); + expect(input).toEqual([' ', ' ubuntu-24.04 ', '\t']); + expect(core.warning).not.toHaveBeenCalled(); + }); + + it('parses mappings, preserving rule order and values after the first equals sign', () => { + expect(RunnerMapping.parse([' linux/arm = custom=arm ', ' default = ubuntu-24.04 ', 'linux = ubuntu-latest'])).toEqual({ + defaultRunner: 'ubuntu-24.04', + rules: [ + {pattern: 'linux/arm', runner: 'custom=arm'}, + {pattern: 'linux', runner: 'ubuntu-latest'} + ] + }); + }); + + it('uses the last default mapping', () => { + expect(RunnerMapping.parse(['default=first', 'default=last'])).toEqual({defaultRunner: 'last', rules: []}); + }); + + it.each([ + ['amd64', 'ubuntu-24.04'], + ['arm64', 'ubuntu-24.04-arm'] + ])('preserves deprecated alias %s and its warning', (alias, runner) => { + expect(RunnerMapping.parse([alias])).toEqual({defaultRunner: runner, rules: []}); + expect(core.warning).toHaveBeenCalledExactlyOnceWith(`The runner input value "${alias}" is deprecated; use runner=${runner} instead`); + }); + + it('preserves auto mappings and its warning', () => { + expect(RunnerMapping.parse(['auto'])).toEqual({ + defaultRunner: 'ubuntu-24.04', + rules: [ + {pattern: 'linux/arm', runner: 'ubuntu-24.04-arm'}, + {pattern: 'linux/arm64', runner: 'ubuntu-24.04-arm'} + ] + }); + expect(core.warning).toHaveBeenCalledExactlyOnceWith('The runner input value "auto" is deprecated; use a runner mapping with default=ubuntu-24.04, linux/arm=ubuntu-24.04-arm, and linux/arm64=ubuntu-24.04-arm instead'); + }); + + it('does not expand aliases used as mapping values', () => { + expect(RunnerMapping.parse(['default=auto', 'linux/arm=arm64'])).toEqual({defaultRunner: 'auto', rules: [{pattern: 'linux/arm', runner: 'arm64'}]}); + expect(core.warning).not.toHaveBeenCalled(); + }); + + it.each([ + [[], 'runner input cannot be empty'], + [[' ', '\t'], 'runner input cannot be empty'], + [['first', 'second'], 'Invalid runner mapping: first'], + [['default=runner', 'linux'], 'Invalid runner mapping: linux'], + [['=runner'], 'Runner mapping pattern cannot be empty'], + [['default= '], 'Runner mapping value cannot be empty for default'], + [['default=runner', 'linux= '], 'Runner mapping value cannot be empty for linux'], + [['linux=runner'], 'Runner mapping must define a default runner'], + [['default=runner', '/linux=runner'], 'Runner mapping pattern is not a valid platform prefix: /linux'], + [['default=runner', 'linux/=runner'], 'Runner mapping pattern is not a valid platform prefix: linux/'], + [['default=runner', 'linux//arm=runner'], 'Runner mapping pattern is not a valid platform prefix: linux//arm'] + ])('rejects invalid mapping %j', (input, message) => { + expect(() => RunnerMapping.parse(input)).toThrow(message); + }); +}); + +describe('resolve', () => { + const config = RunnerMapping.parse(['default=fallback', 'linux/arm/v7=armv7', 'linux/arm=arm', 'linux=linux', 'linux/arm64=arm64']); + + it.each([ + [undefined, 'fallback'], + ['', 'fallback'], + ['windows/amd64', 'fallback'], + ['linux', 'linux'], + ['linux/amd64', 'linux'], + ['linux/arm', 'arm'], + ['linux/arm/v6', 'arm'], + ['linux/arm/v7', 'armv7'], + ['linux/arm64', 'arm64'], + ['linux/arm64/v8', 'arm64'], + ['linux/arm64ish', 'linux'], + ['linuxish/arm', 'fallback'], + ['Linux/arm', 'fallback'] + ])('resolves %j to %s', (platform, expected) => { + expect(RunnerMapping.resolve(config, platform)).toBe(expected); + }); + + it('uses the later rule on ties, without overriding a more specific match', () => { + const config = RunnerMapping.parse(['default=fallback', 'linux/arm/v7=specific', 'linux/arm=first', 'linux/arm=last']); + expect(RunnerMapping.resolve(config, 'linux/arm')).toBe('last'); + expect(RunnerMapping.resolve(config, 'linux/arm/v7')).toBe('specific'); + expect(config.rules.map(rule => rule.runner)).toEqual(['specific', 'first', 'last']); + }); + + it('uses a single label for any platform', () => { + expect(RunnerMapping.resolve(RunnerMapping.parse(['custom-runner']), 'linux/arm64')).toBe('custom-runner'); + }); +}); diff --git a/src/github-builder/runner-mapping.ts b/src/github-builder/runner-mapping.ts new file mode 100644 index 00000000..dee70c3f --- /dev/null +++ b/src/github-builder/runner-mapping.ts @@ -0,0 +1,100 @@ +/** + * 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 * as core from '@actions/core'; + +export interface RunnerConfig { + defaultRunner: string; + rules: Array<{pattern: string; runner: string}>; +} + +// Parses github-builder runner input and selects runners by platform prefix +export class RunnerMapping { + public static parse(input: Array): RunnerConfig { + const lines = input.map(line => line.trim()).filter(line => line.length > 0); + if (lines.length === 0) { + throw new Error('runner input cannot be empty'); + } + if (lines.length === 1 && !lines[0].includes('=')) { + const label = lines[0]; + if (label === 'auto') { + core.warning('The runner input value "auto" is deprecated; use a runner mapping with default=ubuntu-24.04, linux/arm=ubuntu-24.04-arm, and linux/arm64=ubuntu-24.04-arm instead'); + return { + defaultRunner: 'ubuntu-24.04', + rules: [ + {pattern: 'linux/arm', runner: 'ubuntu-24.04-arm'}, + {pattern: 'linux/arm64', runner: 'ubuntu-24.04-arm'} + ] + }; + } + if (label === 'amd64' || label === 'arm64') { + const runner = label === 'amd64' ? 'ubuntu-24.04' : 'ubuntu-24.04-arm'; + core.warning(`The runner input value "${label}" is deprecated; use runner=${runner} instead`); + return {defaultRunner: runner, rules: []}; + } + return {defaultRunner: label, rules: []}; + } + const rules: RunnerConfig['rules'] = []; + let defaultRunner: string | undefined; + for (const line of lines) { + const idx = line.indexOf('='); + if (idx === -1) { + throw new Error(`Invalid runner mapping: ${line}`); + } + const pattern = line.substring(0, idx).trim(); + const runner = line.substring(idx + 1).trim(); + if (!pattern) { + throw new Error('Runner mapping pattern cannot be empty'); + } + if (!runner) { + throw new Error(`Runner mapping value cannot be empty for ${pattern}`); + } + if (pattern === 'default') { + defaultRunner = runner; + continue; + } + if (pattern.split('/').some(part => part.length === 0)) { + throw new Error(`Runner mapping pattern is not a valid platform prefix: ${pattern}`); + } + rules.push({pattern, runner}); + } + if (!defaultRunner) { + throw new Error('Runner mapping must define a default runner'); + } + return {defaultRunner, rules}; + } + + public static resolve(config: RunnerConfig, platform?: string): string { + if (!platform) { + return config.defaultRunner; + } + const platformParts = platform.split('/'); + let runner = config.defaultRunner; + let specificity = 0; + for (const rule of config.rules) { + const patternParts = rule.pattern.split('/'); + if (patternParts.length > platformParts.length || !patternParts.every((part, index) => part === platformParts[index])) { + continue; + } + // Prefer the most specific prefix; later rules win at equal specificity. + if (patternParts.length >= specificity) { + runner = rule.runner; + specificity = patternParts.length; + } + } + return runner; + } +} From aba57f5a9271d1a88797af30b9f029d06ae4e75f Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:49:48 +0200 Subject: [PATCH 3/4] github-builder: add registry identity parser Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .../registry-identities.test.ts | 111 ++++++++++++++++ src/github-builder/registry-identities.ts | 123 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 __tests__/github-builder/registry-identities.test.ts create mode 100644 src/github-builder/registry-identities.ts diff --git a/__tests__/github-builder/registry-identities.test.ts b/__tests__/github-builder/registry-identities.test.ts new file mode 100644 index 00000000..a1ec5d83 --- /dev/null +++ b/__tests__/github-builder/registry-identities.test.ts @@ -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]>)('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]>)('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+$/); + }); +}); diff --git a/src/github-builder/registry-identities.ts b/src/github-builder/registry-identities.ts new file mode 100644 index 00000000..e2f503ac --- /dev/null +++ b/src/github-builder/registry-identities.ts @@ -0,0 +1,123 @@ +/** + * 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 {load, YAMLException} from 'js-yaml'; + +export interface RegistryIdentityConfig { + awsEcr?: { + registry: string; + roleToAssume: string; + region: string; + }; + gcpWif?: { + registry: string; + workloadIdentityProvider: string; + serviceAccount: string; + projectId: string; + }; + dockerhubOidc?: { + registry: string; + username: string; + connectionID: string; + }; +} + +// Parses github-builder keyless registry identity configuration +export class RegistryIdentities { + public static parse(input: string): RegistryIdentityConfig { + if (!input.trim()) { + return {}; + } + let parsed: unknown; + try { + parsed = load(input); + } catch (err) { + // Do not include YAML excerpts or tag/alias names in diagnostics. + const location = err instanceof YAMLException && err.mark ? ` at line ${err.mark.line + 1}, column ${err.mark.column + 1}` : ''; + RegistryIdentities.fail(`Failed to parse YAML${location}`); + } + if (parsed === null || parsed === undefined) { + return {}; + } + const result: RegistryIdentityConfig = {}; + const entries = Array.isArray(parsed) ? parsed : [parsed]; + entries.forEach((entry, index) => { + const location = `registry-identities[${index}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + RegistryIdentities.fail(`${location} must be an object`); + } + const requireString = (key: string): string => { + const value = entry[key]; + if (typeof value !== 'string' || !value.trim()) { + RegistryIdentities.fail(`${location}.${key} must be a non-empty string`); + } + return value.trim(); + }; + const optionalString = (key: string, fallback: string): string => (Object.prototype.hasOwnProperty.call(entry, key) ? requireString(key) : fallback); + const type = requireString('type'); + const validateKeys = (keys: Array): void => { + for (const key of Object.keys(entry)) { + if (!keys.includes(key)) { + RegistryIdentities.fail(`${location}.${key} is not supported for ${type}`); + } + } + }; + switch (type) { + case 'aws-ecr': + validateKeys(['type', 'registry', 'role-to-assume', 'region']); + if (result.awsEcr) { + RegistryIdentities.fail('only one aws-ecr registry identity is supported'); + } + result.awsEcr = { + registry: requireString('registry'), + roleToAssume: requireString('role-to-assume'), + region: requireString('region') + }; + break; + case 'gcp-wif': + validateKeys(['type', 'registry', 'workload_identity_provider', 'service_account', 'project_id']); + if (result.gcpWif) { + RegistryIdentities.fail('only one gcp-wif registry identity is supported'); + } + result.gcpWif = { + registry: requireString('registry'), + workloadIdentityProvider: requireString('workload_identity_provider'), + serviceAccount: requireString('service_account'), + projectId: optionalString('project_id', '') + }; + break; + case 'dockerhub': + validateKeys(['type', 'registry', 'username', 'connection_id']); + if (result.dockerhubOidc) { + RegistryIdentities.fail('only one dockerhub registry identity is supported'); + } + result.dockerhubOidc = { + registry: optionalString('registry', 'docker.io'), + username: requireString('username'), + connectionID: requireString('connection_id') + }; + break; + default: + RegistryIdentities.fail(`${location}.type has unsupported provider ${type}`); + } + }); + return result; + } + + private static fail(message: string): never { + throw new Error(`Invalid registry-identities input: ${message}`); + } +} From d0a8af1937b2ba5005732ea6d7ae18bb90563a73 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:58:37 +0200 Subject: [PATCH 4/4] github-builder: validate Bake target graphs with shared traversal Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/buildx/bake.test.ts | 64 +++++++++++++++++++ __tests__/github-builder/bake-targets.test.ts | 61 ++++++++++++++++++ src/buildx/bake.ts | 29 +++++++++ src/github-builder/bake-targets.ts | 31 +++++++++ 4 files changed, 185 insertions(+) create mode 100644 __tests__/github-builder/bake-targets.test.ts create mode 100644 src/github-builder/bake-targets.ts diff --git a/__tests__/buildx/bake.test.ts b/__tests__/buildx/bake.test.ts index 2c8342f9..07f3531f 100644 --- a/__tests__/buildx/bake.test.ts +++ b/__tests__/buildx/bake.test.ts @@ -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(); diff --git a/__tests__/github-builder/bake-targets.test.ts b/__tests__/github-builder/bake-targets.test.ts new file mode 100644 index 00000000..a86af43a --- /dev/null +++ b/__tests__/github-builder/bake-targets.test.ts @@ -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']); + }); +}); diff --git a/src/buildx/bake.ts b/src/buildx/bake.ts index 2eaa3ff2..63a32162 100644 --- a/src/buildx/bake.ts +++ b/src/buildx/bake.ts @@ -62,6 +62,35 @@ export class Bake { return path.join(Context.tmpDir(), this.metadataFilename); } + public static resolveContextTargets(definition: BakeDefinition, target: string): Array { + const targetDefs = definition.target || {}; + const targets = Object.keys(targetDefs); + if (targets.length === 0) { + throw new Error('Bake definition does not contain any targets'); + } + if (!Object.prototype.hasOwnProperty.call(targetDefs, target)) { + throw new Error(`Unable to resolve ${target} target, found: ${targets.join(', ')}`); + } + const resolved = new Set([target]); + const stack = [target]; + while (stack.length > 0) { + const current = stack.pop()!; + for (const context of Object.values(targetDefs[current].contexts || {})) { + const dependency = context.match(/^target:(.+)$/)?.[1]; + if (!dependency || resolved.has(dependency)) { + continue; + } + if (!Object.prototype.hasOwnProperty.call(targetDefs, dependency)) { + throw new Error(`Target ${current} uses unknown named context target ${dependency}`); + } + // Visit each target once, including cycles; Buildx validates buildability. + resolved.add(dependency); + stack.push(dependency); + } + } + return [...resolved]; + } + public resolveMetadata(): BuildMetadata | undefined { const metadataFile = this.getMetadataFilePath(); if (!fs.existsSync(metadataFile)) { diff --git a/src/github-builder/bake-targets.ts b/src/github-builder/bake-targets.ts new file mode 100644 index 00000000..7dc7cf3f --- /dev/null +++ b/src/github-builder/bake-targets.ts @@ -0,0 +1,31 @@ +/** + * 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 {Bake} from '../buildx/bake.js'; +import {BakeDefinition} from '../types/buildx/bake.js'; + +export class BakeTargets { + // Enforces github-builder single-root policy on a resolved Bake definition + public static resolve(definition: BakeDefinition, target: string): Array { + const targets = Bake.resolveContextTargets(definition, target); + const allowedTargets = new Set(targets); + const unsupportedTargets = Object.keys(definition.target).filter(name => !allowedTargets.has(name)); + if (unsupportedTargets.length > 0) { + throw new Error(`Only one target can be built at once, found unsupported targets: ${unsupportedTargets.join(', ')}`); + } + return targets; + } +}