From 9eab38781154c880f35060e966596133c16eb57f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 00:00:08 +0000 Subject: [PATCH 1/6] feat(docker): Add support for multiple registries Extend the Docker target to support publishing to multiple container registries (Docker Hub, GHCR, GCR, etc.) with per-registry credentials. Key changes: - Auto-detect registry from target image path (e.g., ghcr.io/user/image) - Per-registry credentials via DOCKER__USERNAME/PASSWORD env vars - Built-in GHCR defaults using GITHUB_ACTOR/GITHUB_TOKEN for zero-config in GitHub Actions - Optional registry config override to share credentials across regions - Optional usernameVar/passwordVar for explicit env var names (no fallback) - Use --password-stdin for secure password handling Closes #591 --- README.md | 105 ++++++-- src/targets/__tests__/docker.test.ts | 369 +++++++++++++++++++++++++++ src/targets/docker.ts | 119 ++++++++- src/utils/system.ts | 16 +- 4 files changed, 570 insertions(+), 39 deletions(-) create mode 100644 src/targets/__tests__/docker.test.ts diff --git a/README.md b/README.md index 182fc7e5..9088f2f0 100644 --- a/README.md +++ b/README.md @@ -1064,44 +1064,101 @@ targets: ### Docker (`docker`) Copies an existing source image tagged with the revision SHA to a new target -tagged with the released version. No release assets are required for this target -except for the source image at the provided source image location so it would be -a good idea to add a status check that ensures the source image exists, otherwise -`craft publish` will fail at the copy step, causing an interrupted publish. -This is an issue for other, non-idempotent targets, not for the Docker target. +tagged with the released version. Supports multiple registries including Docker Hub, +GitHub Container Registry (ghcr.io), Google Container Registry (gcr.io), and other +OCI-compliant registries. + +No release assets are required for this target except for the source image at the +provided source image location so it would be a good idea to add a status check +that ensures the source image exists, otherwise `craft publish` will fail at the +copy step, causing an interrupted publish. This is an issue for other, non-idempotent +targets, not for the Docker target. **Environment** `docker` executable (or something equivalent) with BuildKit must be installed on the system. -| Name | Description | -| ----------------- | ------------------------------------------ | -| `DOCKER_USERNAME` | The username for the Docker registry. | -| `DOCKER_PASSWORD` | The personal access token for the account. | -| `DOCKER_BIN` | **optional**. Path to `docker` executable. | +Credentials are resolved in the following order: + +1. **Explicit env var override**: If `usernameVar` and `passwordVar` are configured, + only those environment variables are used (no fallback). +2. **Registry-derived env vars**: Based on the target registry, e.g., `DOCKER_GHCR_IO_USERNAME` + and `DOCKER_GHCR_IO_PASSWORD` for `ghcr.io`. +3. **Built-in defaults**: For `ghcr.io`, uses `GITHUB_ACTOR` and `GITHUB_TOKEN` which are + automatically available in GitHub Actions. +4. **Default**: `DOCKER_USERNAME` and `DOCKER_PASSWORD`. + +| Name | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------- | +| `DOCKER_USERNAME` | Default username for Docker registries. | +| `DOCKER_PASSWORD` | Default password/token for Docker registries. | +| `DOCKER__USERNAME` | Registry-specific username (e.g., `DOCKER_GHCR_IO_USERNAME` for `ghcr.io`). | +| `DOCKER__PASSWORD` | Registry-specific password (e.g., `DOCKER_GHCR_IO_PASSWORD` for `ghcr.io`). | +| `GITHUB_ACTOR` | Used as default username for `ghcr.io` (available in GitHub Actions). | +| `GITHUB_TOKEN` | Used as default password for `ghcr.io` (available in GitHub Actions). | +| `DOCKER_BIN` | **optional**. Path to `docker` executable. | **Configuration** -| Option | Description | -| -------------- | ------------------------------------------------------------------------ | -| `source` | Path to the source Docker image to be pulled | -| `sourceFormat` | Format for the source image name. Default: `{{{source}}}:{{{revision}}}` | -| `target` | Path to the target Docker image to be pushed | -| `targetFormat` | Format for the target image name. Default: `{{{target}}}:{{{version}}}` | +| Option | Description | +| -------------- | --------------------------------------------------------------------------------- | +| `source` | Path to the source Docker image to be pulled | +| `sourceFormat` | Format for the source image name. Default: `{{{source}}}:{{{revision}}}` | +| `target` | Path to the target Docker image to be pushed | +| `targetFormat` | Format for the target image name. Default: `{{{target}}}:{{{version}}}` | +| `registry` | **optional**. Override the registry for login (auto-detected from `target`) | +| `usernameVar` | **optional**. Env var name for username (must be used with `passwordVar`) | +| `passwordVar` | **optional**. Env var name for password (must be used with `usernameVar`) | -**Example** +**Examples** + +Publishing to Docker Hub (default): ```yaml targets: - name: docker - source: us.gcr.io/sentryio/craft + source: ghcr.io/getsentry/craft target: getsentry/craft -# Optional but strongly recommended -statusProvider: - name: github - config: - contexts: - - Travis CI - Branch # or whatever builds and pushes your source image +``` + +Publishing to GitHub Container Registry (zero-config in GitHub Actions): + +```yaml +targets: + # Uses GITHUB_ACTOR and GITHUB_TOKEN automatically + - name: docker + source: ghcr.io/getsentry/craft + target: ghcr.io/getsentry/craft +``` + +Publishing to multiple registries: + +```yaml +targets: + # Docker Hub + - name: docker + source: ghcr.io/getsentry/craft + target: getsentry/craft + # Uses DOCKER_USERNAME / DOCKER_PASSWORD + + # GHCR (auto-detected, zero-config in GitHub Actions) + - name: docker + source: ghcr.io/getsentry/craft + target: ghcr.io/getsentry/craft + # Uses DOCKER_GHCR_IO_* or GITHUB_ACTOR/GITHUB_TOKEN + + # GCR with shared credentials across regions + - name: docker + source: ghcr.io/getsentry/craft + target: us.gcr.io/my-project/craft + registry: gcr.io # Use DOCKER_GCR_IO_* instead of DOCKER_US_GCR_IO_* + + # Custom registry with explicit env vars + - name: docker + source: ghcr.io/getsentry/craft + target: custom.registry.io/image + usernameVar: MY_REGISTRY_USER + passwordVar: MY_REGISTRY_PASS ``` ### Ruby Gems Index (`gem`) diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts new file mode 100644 index 00000000..70ba79fa --- /dev/null +++ b/src/targets/__tests__/docker.test.ts @@ -0,0 +1,369 @@ +import { + DockerTarget, + extractRegistry, + registryToEnvPrefix, +} from '../docker'; +import { NoneArtifactProvider } from '../../artifact_providers/none'; +import * as system from '../../utils/system'; + +jest.mock('../../utils/system', () => ({ + ...jest.requireActual('../../utils/system'), + checkExecutableIsPresent: jest.fn(), + spawnProcess: jest.fn().mockResolvedValue(Buffer.from('')), +})); + +describe('extractRegistry', () => { + it('returns undefined for Docker Hub images (user/image)', () => { + expect(extractRegistry('user/image')).toBeUndefined(); + expect(extractRegistry('getsentry/craft')).toBeUndefined(); + }); + + it('returns undefined for simple image names', () => { + expect(extractRegistry('nginx')).toBeUndefined(); + expect(extractRegistry('ubuntu')).toBeUndefined(); + }); + + it('extracts ghcr.io registry', () => { + expect(extractRegistry('ghcr.io/user/image')).toBe('ghcr.io'); + expect(extractRegistry('ghcr.io/getsentry/craft')).toBe('ghcr.io'); + }); + + it('extracts gcr.io and regional variants', () => { + expect(extractRegistry('gcr.io/project/image')).toBe('gcr.io'); + expect(extractRegistry('us.gcr.io/project/image')).toBe('us.gcr.io'); + expect(extractRegistry('eu.gcr.io/project/image')).toBe('eu.gcr.io'); + expect(extractRegistry('asia.gcr.io/project/image')).toBe('asia.gcr.io'); + }); + + it('extracts other registries with dots', () => { + expect(extractRegistry('registry.example.com/image')).toBe( + 'registry.example.com' + ); + expect(extractRegistry('docker.io/library/nginx')).toBe('docker.io'); + }); + + it('extracts registries with ports', () => { + expect(extractRegistry('localhost:5000/image')).toBe('localhost:5000'); + expect(extractRegistry('myregistry:8080/user/image')).toBe( + 'myregistry:8080' + ); + }); +}); + +describe('registryToEnvPrefix', () => { + it('converts ghcr.io to GHCR_IO', () => { + expect(registryToEnvPrefix('ghcr.io')).toBe('GHCR_IO'); + }); + + it('converts gcr.io to GCR_IO', () => { + expect(registryToEnvPrefix('gcr.io')).toBe('GCR_IO'); + }); + + it('converts regional GCR to correct prefix', () => { + expect(registryToEnvPrefix('us.gcr.io')).toBe('US_GCR_IO'); + expect(registryToEnvPrefix('eu.gcr.io')).toBe('EU_GCR_IO'); + expect(registryToEnvPrefix('asia.gcr.io')).toBe('ASIA_GCR_IO'); + }); + + it('handles hyphens in registry names', () => { + expect(registryToEnvPrefix('my-registry.example.com')).toBe( + 'MY_REGISTRY_EXAMPLE_COM' + ); + }); + + it('handles ports in registry names', () => { + expect(registryToEnvPrefix('localhost:5000')).toBe('LOCALHOST_5000'); + }); +}); + +describe('DockerTarget', () => { + const oldEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + // Clear all Docker-related env vars + delete process.env.DOCKER_USERNAME; + delete process.env.DOCKER_PASSWORD; + delete process.env.DOCKER_GHCR_IO_USERNAME; + delete process.env.DOCKER_GHCR_IO_PASSWORD; + delete process.env.DOCKER_GCR_IO_USERNAME; + delete process.env.DOCKER_GCR_IO_PASSWORD; + delete process.env.GITHUB_ACTOR; + delete process.env.GITHUB_TOKEN; + }); + + afterAll(() => { + process.env = { ...oldEnv }; + }); + + describe('credential resolution', () => { + describe('Mode A: explicit usernameVar/passwordVar', () => { + it('uses explicit env vars when both are specified', () => { + process.env.MY_USER = 'custom-user'; + process.env.MY_PASS = 'custom-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + usernameVar: 'MY_USER', + passwordVar: 'MY_PASS', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('custom-user'); + expect(target.dockerConfig.password).toBe('custom-pass'); + }); + + it('throws if only usernameVar is specified', () => { + process.env.MY_USER = 'custom-user'; + + expect( + () => + new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + usernameVar: 'MY_USER', + }, + new NoneArtifactProvider() + ) + ).toThrow('Both usernameVar and passwordVar must be specified together'); + }); + + it('throws if only passwordVar is specified', () => { + process.env.MY_PASS = 'custom-pass'; + + expect( + () => + new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + passwordVar: 'MY_PASS', + }, + new NoneArtifactProvider() + ) + ).toThrow('Both usernameVar and passwordVar must be specified together'); + }); + + it('throws if explicit env vars are not set (no fallback)', () => { + // Ensure fallback vars are set but should NOT be used + process.env.DOCKER_USERNAME = 'fallback-user'; + process.env.DOCKER_PASSWORD = 'fallback-pass'; + + expect( + () => + new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + usernameVar: 'NONEXISTENT_USER', + passwordVar: 'NONEXISTENT_PASS', + }, + new NoneArtifactProvider() + ) + ).toThrow( + 'Missing credentials: NONEXISTENT_USER and/or NONEXISTENT_PASS environment variable(s) not set' + ); + }); + }); + + describe('Mode B: automatic resolution', () => { + it('uses registry-derived env vars first', () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + process.env.DOCKER_USERNAME = 'default-user'; + process.env.DOCKER_PASSWORD = 'default-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('ghcr-user'); + expect(target.dockerConfig.password).toBe('ghcr-pass'); + }); + + it('falls back to GHCR defaults (GITHUB_ACTOR/GITHUB_TOKEN) for ghcr.io', () => { + process.env.GITHUB_ACTOR = 'github-actor'; + process.env.GITHUB_TOKEN = 'github-token'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('github-actor'); + expect(target.dockerConfig.password).toBe('github-token'); + }); + + it('uses default DOCKER_* env vars for Docker Hub', () => { + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('dockerhub-user'); + expect(target.dockerConfig.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.registry).toBeUndefined(); + }); + + it('falls back to DOCKER_* when registry-specific vars are not set', () => { + process.env.DOCKER_USERNAME = 'default-user'; + process.env.DOCKER_PASSWORD = 'default-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'gcr.io/project/image', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('default-user'); + expect(target.dockerConfig.password).toBe('default-pass'); + }); + + it('throws when no credentials are available', () => { + expect( + () => + new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ) + ).toThrow('Cannot perform Docker release: missing credentials'); + }); + + it('includes registry-specific hint in error message', () => { + expect( + () => + new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'gcr.io/project/image', + }, + new NoneArtifactProvider() + ) + ).toThrow('DOCKER_GCR_IO_USERNAME/PASSWORD'); + }); + }); + + describe('registry config override', () => { + it('uses explicit registry config over auto-detection', () => { + process.env.DOCKER_GCR_IO_USERNAME = 'gcr-user'; + process.env.DOCKER_GCR_IO_PASSWORD = 'gcr-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'us.gcr.io/project/image', + registry: 'gcr.io', // Override to share creds across regions + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.registry).toBe('gcr.io'); + expect(target.dockerConfig.username).toBe('gcr-user'); + expect(target.dockerConfig.password).toBe('gcr-pass'); + }); + }); + }); + + describe('login', () => { + it('passes registry to docker login command', async () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'ghcr.io/org/image', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + expect(system.spawnProcess).toHaveBeenCalledWith( + 'docker', + ['login', '--username=user', '--password-stdin', 'ghcr.io'], + {}, + { stdin: 'pass' } + ); + }); + + it('omits registry for Docker Hub', async () => { + process.env.DOCKER_USERNAME = 'user'; + process.env.DOCKER_PASSWORD = 'pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + expect(system.spawnProcess).toHaveBeenCalledWith( + 'docker', + ['login', '--username=user', '--password-stdin'], + {}, + { stdin: 'pass' } + ); + }); + + it('uses password-stdin for security', async () => { + process.env.DOCKER_USERNAME = 'user'; + process.env.DOCKER_PASSWORD = 'secret-password'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Verify password is passed via stdin, not command line + const callArgs = (system.spawnProcess as jest.Mock).mock.calls[0]; + expect(callArgs[1]).not.toContain('--password=secret-password'); + expect(callArgs[1]).toContain('--password-stdin'); + expect(callArgs[3]).toEqual({ stdin: 'secret-password' }); + }); + }); +}); diff --git a/src/targets/docker.ts b/src/targets/docker.ts index 3545fb7b..e0c29352 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -12,6 +12,32 @@ const DEFAULT_DOCKER_BIN = 'docker'; */ const DOCKER_BIN = process.env.DOCKER_BIN || DEFAULT_DOCKER_BIN; +/** + * Extracts the registry host from a Docker image path. + * + * @param imagePath Docker image path (e.g., "ghcr.io/user/image" or "user/image") + * @returns The registry host if present (e.g., "ghcr.io"), undefined for Docker Hub + */ +export function extractRegistry(imagePath: string): string | undefined { + const parts = imagePath.split('/'); + // Registry hosts contain dots (ghcr.io, gcr.io, us.gcr.io, etc.) + // or colons for ports (localhost:5000) + if (parts.length >= 2 && (parts[0].includes('.') || parts[0].includes(':'))) { + return parts[0]; + } + return undefined; +} + +/** + * Converts a registry hostname to an environment variable prefix. + * + * @param registry Registry hostname (e.g., "ghcr.io", "us.gcr.io") + * @returns Environment variable prefix (e.g., "GHCR_IO", "US_GCR_IO") + */ +export function registryToEnvPrefix(registry: string): string { + return registry.toUpperCase().replace(/[.\-:]/g, '_'); +} + /** Options for "docker" target */ export interface DockerTargetOptions { username: string; @@ -24,10 +50,15 @@ export interface DockerTargetOptions { targetTemplate: string; /** Target image path, like `getsentry/craft` */ target: string; + /** Registry host for docker login (e.g., "ghcr.io"). Auto-detected from target if not specified. */ + registry?: string; } /** - * Target responsible for publishing releases on Docker Hub (https://hub.docker.com) + * Target responsible for publishing releases to Docker registries. + * + * Supports multiple registries including Docker Hub, GitHub Container Registry (ghcr.io), + * Google Container Registry (gcr.io), and other OCI-compliant registries. */ export class DockerTarget extends BaseTarget { /** Target name */ @@ -45,13 +76,73 @@ export class DockerTarget extends BaseTarget { } /** - * Extracts Docker target options from the environment + * Extracts Docker target options from the environment. + * + * Credential resolution follows two modes: + * + * Mode A (explicit env vars): If usernameVar or passwordVar is configured, + * both must be specified and the env vars must exist. No fallback for security. + * + * Mode B (automatic resolution): Tries in order: + * 1. Registry-derived env vars: DOCKER__USERNAME / DOCKER__PASSWORD + * 2. Built-in defaults for known registries (GHCR: GITHUB_ACTOR / GITHUB_TOKEN) + * 3. Default: DOCKER_USERNAME / DOCKER_PASSWORD */ public getDockerConfig(): DockerTargetOptions { - if (!process.env.DOCKER_USERNAME || !process.env.DOCKER_PASSWORD) { + const registry = + this.config.registry ?? extractRegistry(this.config.target); + + let username: string | undefined; + let password: string | undefined; + + // Mode A: Explicit env var override - no fallback for security + if (this.config.usernameVar || this.config.passwordVar) { + if (!this.config.usernameVar || !this.config.passwordVar) { + throw new ConfigurationError( + 'Both usernameVar and passwordVar must be specified together' + ); + } + username = process.env[this.config.usernameVar]; + password = process.env[this.config.passwordVar]; + + if (!username || !password) { + throw new ConfigurationError( + `Missing credentials: ${this.config.usernameVar} and/or ${this.config.passwordVar} environment variable(s) not set` + ); + } + } else { + // Mode B: Automatic resolution with fallback chain + + // 1. Registry-derived env vars + if (registry) { + const prefix = `DOCKER_${registryToEnvPrefix(registry)}_`; + username = process.env[`${prefix}USERNAME`]; + password = process.env[`${prefix}PASSWORD`]; + } + + // 2. Built-in defaults for known registries + if (!username || !password) { + if (registry === 'ghcr.io') { + // GHCR defaults: use GitHub Actions built-in env vars + // GITHUB_ACTOR and GITHUB_TOKEN are available by default in GitHub Actions + // See: https://docs.github.com/en/actions/reference/workflows-and-actions/variables + username = username ?? process.env.GITHUB_ACTOR; + password = password ?? process.env.GITHUB_TOKEN; + } + } + + // 3. Fallback to defaults + username = username ?? process.env.DOCKER_USERNAME; + password = password ?? process.env.DOCKER_PASSWORD; + } + + if (!username || !password) { + const registryHint = registry + ? `DOCKER_${registryToEnvPrefix(registry)}_USERNAME/PASSWORD or ` + : ''; throw new ConfigurationError( `Cannot perform Docker release: missing credentials. - Please use DOCKER_USERNAME and DOCKER_PASSWORD environment variables.`.replace( +Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variables.`.replace( /^\s+/gm, '' ) @@ -59,12 +150,13 @@ export class DockerTarget extends BaseTarget { } return { - password: process.env.DOCKER_PASSWORD, + password, source: this.config.source, target: this.config.target, sourceTemplate: this.config.sourceFormat || '{{{source}}}:{{{revision}}}', targetTemplate: this.config.targetFormat || '{{{target}}}:{{{version}}}', - username: process.env.DOCKER_USERNAME, + username, + registry, }; } @@ -74,12 +166,13 @@ export class DockerTarget extends BaseTarget { * NOTE: This may change the globally logged in Docker user on the system */ public async login(): Promise { - const { username, password } = this.dockerConfig; - return spawnProcess(DOCKER_BIN, [ - 'login', - `--username=${username}`, - `--password=${password}`, - ]); + const { username, password, registry } = this.dockerConfig; + const args = ['login', `--username=${username}`, '--password-stdin']; + if (registry) { + args.push(registry); + } + // Pass password via stdin for security (avoids exposure in ps/process list) + return spawnProcess(DOCKER_BIN, args, {}, { stdin: password }); } /** @@ -110,7 +203,7 @@ export class DockerTarget extends BaseTarget { } /** - * Pushes a source image to Docker Hub + * Publishes a source image to the target registry * * @param version The new version * @param revision The SHA revision of the new version diff --git a/src/utils/system.ts b/src/utils/system.ts index 9c443c77..4ef1f10d 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -106,6 +106,8 @@ export interface SpawnProcessOptions { showStdout?: boolean; /** Force the process to run in dry-run mode */ enableInDryRunMode?: boolean; + /** Data to write to stdin (process will receive 'pipe' for stdin instead of 'inherit') */ + stdin?: string; } /** @@ -159,13 +161,23 @@ export async function spawnProcess( replaceEnvVariable(arg, { ...process.env, ...options.env }) ); - // Allow child to accept input - options.stdio = ['inherit', 'pipe', 'pipe']; + // Allow child to accept input (use 'pipe' for stdin if we need to write to it) + options.stdio = [ + spawnProcessOptions.stdin !== undefined ? 'pipe' : 'inherit', + 'pipe', + 'pipe', + ]; child = spawn(command, processedArgs, options); if (!child.stdout || !child.stderr) { throw new Error('Invalid standard output or error for child process'); } + + // Write stdin data if provided + if (spawnProcessOptions.stdin !== undefined && child.stdin) { + child.stdin.write(spawnProcessOptions.stdin); + child.stdin.end(); + } child.on('exit', code => (code === 0 ? succeed() : fail({ code }))); child.on('error', fail); From e63f428d266cb03673ccc983d3ee8d0bb4e43bc1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 11 Dec 2025 11:15:29 +0000 Subject: [PATCH 2/6] fix(docker): Treat docker.io as Docker Hub registry Recognize docker.io, index.docker.io, and registry-1.docker.io as Docker Hub aliases that should use default DOCKER_USERNAME/PASSWORD credentials instead of looking for DOCKER_DOCKER_IO_* env vars. --- src/targets/__tests__/docker.test.ts | 47 +++++++++++++++++++++++++++- src/targets/docker.ts | 10 +++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts index 70ba79fa..96a5c923 100644 --- a/src/targets/__tests__/docker.test.ts +++ b/src/targets/__tests__/docker.test.ts @@ -39,7 +39,16 @@ describe('extractRegistry', () => { expect(extractRegistry('registry.example.com/image')).toBe( 'registry.example.com' ); - expect(extractRegistry('docker.io/library/nginx')).toBe('docker.io'); + }); + + it('treats docker.io variants as Docker Hub (returns undefined)', () => { + // docker.io is the canonical Docker Hub registry + expect(extractRegistry('docker.io/library/nginx')).toBeUndefined(); + expect(extractRegistry('docker.io/getsentry/craft')).toBeUndefined(); + // index.docker.io is the legacy Docker Hub registry + expect(extractRegistry('index.docker.io/library/nginx')).toBeUndefined(); + // registry-1.docker.io is another Docker Hub alias + expect(extractRegistry('registry-1.docker.io/user/image')).toBeUndefined(); }); it('extracts registries with ports', () => { @@ -229,6 +238,42 @@ describe('DockerTarget', () => { expect(target.dockerConfig.registry).toBeUndefined(); }); + it('treats docker.io as Docker Hub and uses default credentials', () => { + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'docker.io/getsentry/craft', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('dockerhub-user'); + expect(target.dockerConfig.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.registry).toBeUndefined(); + }); + + it('treats index.docker.io as Docker Hub and uses default credentials', () => { + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'index.docker.io/getsentry/craft', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.username).toBe('dockerhub-user'); + expect(target.dockerConfig.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.registry).toBeUndefined(); + }); + it('falls back to DOCKER_* when registry-specific vars are not set', () => { process.env.DOCKER_USERNAME = 'default-user'; process.env.DOCKER_PASSWORD = 'default-pass'; diff --git a/src/targets/docker.ts b/src/targets/docker.ts index e0c29352..4c20f5af 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -12,6 +12,9 @@ const DEFAULT_DOCKER_BIN = 'docker'; */ const DOCKER_BIN = process.env.DOCKER_BIN || DEFAULT_DOCKER_BIN; +/** Docker Hub registry hostnames that should be treated as the default registry */ +const DOCKER_HUB_REGISTRIES = ['docker.io', 'index.docker.io', 'registry-1.docker.io']; + /** * Extracts the registry host from a Docker image path. * @@ -23,7 +26,12 @@ export function extractRegistry(imagePath: string): string | undefined { // Registry hosts contain dots (ghcr.io, gcr.io, us.gcr.io, etc.) // or colons for ports (localhost:5000) if (parts.length >= 2 && (parts[0].includes('.') || parts[0].includes(':'))) { - return parts[0]; + const registry = parts[0]; + // Treat Docker Hub registries as the default (return undefined) + if (DOCKER_HUB_REGISTRIES.includes(registry)) { + return undefined; + } + return registry; } return undefined; } From 324a2c0c3a1f4807353fd203738e93c0dd21830b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 11 Dec 2025 11:23:37 +0000 Subject: [PATCH 3/6] feat(docker): Add source registry authentication for cross-registry publishing When source and target registries differ, credentials can now be resolved independently for each registry. This enables cross-registry publishing (e.g., pulling from private GHCR and pushing to Docker Hub). - Source credentials use registry-specific env vars (no default fallback) - Add sourceRegistry, sourceUsernameVar, sourcePasswordVar config options - Login to both registries when source credentials are available - If source credentials are not found, assume source is public --- README.md | 55 ++++-- src/targets/__tests__/docker.test.ts | 256 ++++++++++++++++++++++++--- src/targets/docker.ts | 158 +++++++++++++---- 3 files changed, 396 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9088f2f0..5611232c 100644 --- a/README.md +++ b/README.md @@ -1078,7 +1078,7 @@ targets, not for the Docker target. `docker` executable (or something equivalent) with BuildKit must be installed on the system. -Credentials are resolved in the following order: +**Target Registry Credentials** are resolved in the following order: 1. **Explicit env var override**: If `usernameVar` and `passwordVar` are configured, only those environment variables are used (no fallback). @@ -1088,27 +1088,36 @@ Credentials are resolved in the following order: automatically available in GitHub Actions. 4. **Default**: `DOCKER_USERNAME` and `DOCKER_PASSWORD`. +**Source Registry Credentials** (for cross-registry publishing) are resolved similarly but +without falling back to `DOCKER_USERNAME`/`DOCKER_PASSWORD`. If the source registry differs +from the target and requires authentication, set `DOCKER__USERNAME/PASSWORD` +or use `sourceUsernameVar`/`sourcePasswordVar`. If no source credentials are found, the +source is assumed to be public. + | Name | Description | | ------------------------------- | ----------------------------------------------------------------------------------------- | -| `DOCKER_USERNAME` | Default username for Docker registries. | -| `DOCKER_PASSWORD` | Default password/token for Docker registries. | +| `DOCKER_USERNAME` | Default username for target Docker registry. | +| `DOCKER_PASSWORD` | Default password/token for target Docker registry. | | `DOCKER__USERNAME` | Registry-specific username (e.g., `DOCKER_GHCR_IO_USERNAME` for `ghcr.io`). | | `DOCKER__PASSWORD` | Registry-specific password (e.g., `DOCKER_GHCR_IO_PASSWORD` for `ghcr.io`). | -| `GITHUB_ACTOR` | Used as default username for `ghcr.io` (available in GitHub Actions). | -| `GITHUB_TOKEN` | Used as default password for `ghcr.io` (available in GitHub Actions). | +| `GITHUB_ACTOR` | Used as default username for `ghcr.io` (available in GitHub Actions). | +| `GITHUB_TOKEN` | Used as default password for `ghcr.io` (available in GitHub Actions). | | `DOCKER_BIN` | **optional**. Path to `docker` executable. | **Configuration** -| Option | Description | -| -------------- | --------------------------------------------------------------------------------- | -| `source` | Path to the source Docker image to be pulled | -| `sourceFormat` | Format for the source image name. Default: `{{{source}}}:{{{revision}}}` | -| `target` | Path to the target Docker image to be pushed | -| `targetFormat` | Format for the target image name. Default: `{{{target}}}:{{{version}}}` | -| `registry` | **optional**. Override the registry for login (auto-detected from `target`) | -| `usernameVar` | **optional**. Env var name for username (must be used with `passwordVar`) | -| `passwordVar` | **optional**. Env var name for password (must be used with `usernameVar`) | +| Option | Description | +| ------------------- | --------------------------------------------------------------------------------- | +| `source` | Path to the source Docker image to be pulled | +| `sourceFormat` | Format for the source image name. Default: `{{{source}}}:{{{revision}}}` | +| `sourceRegistry` | **optional**. Override the source registry (auto-detected from `source`) | +| `sourceUsernameVar` | **optional**. Env var name for source username (must be used with `sourcePasswordVar`) | +| `sourcePasswordVar` | **optional**. Env var name for source password (must be used with `sourceUsernameVar`) | +| `target` | Path to the target Docker image to be pushed | +| `targetFormat` | Format for the target image name. Default: `{{{target}}}:{{{version}}}` | +| `registry` | **optional**. Override the target registry for login (auto-detected from `target`) | +| `usernameVar` | **optional**. Env var name for target username (must be used with `passwordVar`) | +| `passwordVar` | **optional**. Env var name for target password (must be used with `usernameVar`) | **Examples** @@ -1161,6 +1170,24 @@ targets: passwordVar: MY_REGISTRY_PASS ``` +Cross-registry publishing (private source to different target): + +```yaml +targets: + # Pull from private GHCR, push to Docker Hub + # Requires DOCKER_GHCR_IO_* for source and DOCKER_USERNAME/PASSWORD for target + - name: docker + source: ghcr.io/myorg/private-image + target: getsentry/craft + + # With explicit source credentials + - name: docker + source: private.registry.io/image + target: getsentry/craft + sourceUsernameVar: PRIVATE_REGISTRY_USER + sourcePasswordVar: PRIVATE_REGISTRY_PASS +``` + ### Ruby Gems Index (`gem`) Pushes a gem [Ruby Gems](https://rubygems.org). diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts index 96a5c923..7740e04a 100644 --- a/src/targets/__tests__/docker.test.ts +++ b/src/targets/__tests__/docker.test.ts @@ -105,7 +105,7 @@ describe('DockerTarget', () => { process.env = { ...oldEnv }; }); - describe('credential resolution', () => { + describe('target credential resolution', () => { describe('Mode A: explicit usernameVar/passwordVar', () => { it('uses explicit env vars when both are specified', () => { process.env.MY_USER = 'custom-user'; @@ -122,8 +122,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('custom-user'); - expect(target.dockerConfig.password).toBe('custom-pass'); + expect(target.dockerConfig.targetCredentials.username).toBe('custom-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('custom-pass'); }); it('throws if only usernameVar is specified', () => { @@ -199,8 +199,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('ghcr-user'); - expect(target.dockerConfig.password).toBe('ghcr-pass'); + expect(target.dockerConfig.targetCredentials.username).toBe('ghcr-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('ghcr-pass'); }); it('falls back to GHCR defaults (GITHUB_ACTOR/GITHUB_TOKEN) for ghcr.io', () => { @@ -216,8 +216,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('github-actor'); - expect(target.dockerConfig.password).toBe('github-token'); + expect(target.dockerConfig.targetCredentials.username).toBe('github-actor'); + expect(target.dockerConfig.targetCredentials.password).toBe('github-token'); }); it('uses default DOCKER_* env vars for Docker Hub', () => { @@ -233,9 +233,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('dockerhub-user'); - expect(target.dockerConfig.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.registry).toBeUndefined(); + expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); }); it('treats docker.io as Docker Hub and uses default credentials', () => { @@ -251,9 +251,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('dockerhub-user'); - expect(target.dockerConfig.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.registry).toBeUndefined(); + expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); }); it('treats index.docker.io as Docker Hub and uses default credentials', () => { @@ -269,9 +269,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('dockerhub-user'); - expect(target.dockerConfig.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.registry).toBeUndefined(); + expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); }); it('falls back to DOCKER_* when registry-specific vars are not set', () => { @@ -287,8 +287,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.username).toBe('default-user'); - expect(target.dockerConfig.password).toBe('default-pass'); + expect(target.dockerConfig.targetCredentials.username).toBe('default-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('default-pass'); }); it('throws when no credentials are available', () => { @@ -335,13 +335,137 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.registry).toBe('gcr.io'); - expect(target.dockerConfig.username).toBe('gcr-user'); - expect(target.dockerConfig.password).toBe('gcr-pass'); + expect(target.dockerConfig.targetCredentials.registry).toBe('gcr.io'); + expect(target.dockerConfig.targetCredentials.username).toBe('gcr-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('gcr-pass'); }); }); }); + describe('source credential resolution', () => { + it('resolves source credentials when source registry differs from target', () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + // Target should use Docker Hub credentials + expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); + expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); + + // Source should use GHCR credentials + expect(target.dockerConfig.sourceCredentials).toBeDefined(); + expect(target.dockerConfig.sourceCredentials?.username).toBe('ghcr-user'); + expect(target.dockerConfig.sourceCredentials?.password).toBe('ghcr-pass'); + expect(target.dockerConfig.sourceCredentials?.registry).toBe('ghcr.io'); + }); + + it('does not set source credentials when source and target registries are the same', () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/source-image', + target: 'ghcr.io/org/target-image', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.sourceCredentials).toBeUndefined(); + }); + + it('uses explicit sourceUsernameVar/sourcePasswordVar for source credentials', () => { + process.env.MY_SOURCE_USER = 'source-user'; + process.env.MY_SOURCE_PASS = 'source-pass'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + sourceUsernameVar: 'MY_SOURCE_USER', + sourcePasswordVar: 'MY_SOURCE_PASS', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.sourceCredentials?.username).toBe('source-user'); + expect(target.dockerConfig.sourceCredentials?.password).toBe('source-pass'); + }); + + it('throws if only sourceUsernameVar is specified', () => { + process.env.MY_SOURCE_USER = 'source-user'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + expect( + () => + new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + sourceUsernameVar: 'MY_SOURCE_USER', + }, + new NoneArtifactProvider() + ) + ).toThrow('Both usernameVar and passwordVar must be specified together'); + }); + + it('does not require source credentials if source is assumed public', () => { + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + // No GHCR credentials set - source assumed to be public + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/public-image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + // Should not throw, source credentials are optional + expect(target.dockerConfig.sourceCredentials).toBeUndefined(); + }); + + it('uses sourceRegistry config override', () => { + process.env.DOCKER_GCR_IO_USERNAME = 'gcr-user'; + process.env.DOCKER_GCR_IO_PASSWORD = 'gcr-pass'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'us.gcr.io/project/image', + target: 'getsentry/craft', + sourceRegistry: 'gcr.io', // Use gcr.io creds for us.gcr.io + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.sourceCredentials?.registry).toBe('gcr.io'); + expect(target.dockerConfig.sourceCredentials?.username).toBe('gcr-user'); + expect(target.dockerConfig.sourceCredentials?.password).toBe('gcr-pass'); + }); + }); + describe('login', () => { it('passes registry to docker login command', async () => { process.env.DOCKER_GHCR_IO_USERNAME = 'user'; @@ -410,5 +534,95 @@ describe('DockerTarget', () => { expect(callArgs[1]).toContain('--password-stdin'); expect(callArgs[3]).toEqual({ stdin: 'secret-password' }); }); + + it('logs into both source and target registries for cross-registry publishing', async () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should login to both registries + expect(system.spawnProcess).toHaveBeenCalledTimes(2); + + // First call: login to source (GHCR) + expect(system.spawnProcess).toHaveBeenNthCalledWith( + 1, + 'docker', + ['login', '--username=ghcr-user', '--password-stdin', 'ghcr.io'], + {}, + { stdin: 'ghcr-pass' } + ); + + // Second call: login to target (Docker Hub) + expect(system.spawnProcess).toHaveBeenNthCalledWith( + 2, + 'docker', + ['login', '--username=dockerhub-user', '--password-stdin'], + {}, + { stdin: 'dockerhub-pass' } + ); + }); + + it('only logs into target when source has no credentials (public source)', async () => { + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + // No GHCR credentials - source is public + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/public-image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should only login to Docker Hub + expect(system.spawnProcess).toHaveBeenCalledTimes(1); + expect(system.spawnProcess).toHaveBeenCalledWith( + 'docker', + ['login', '--username=dockerhub-user', '--password-stdin'], + {}, + { stdin: 'dockerhub-pass' } + ); + }); + + it('only logs in once when source and target are same registry', async () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/source-image', + target: 'ghcr.io/org/target-image', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should only login once + expect(system.spawnProcess).toHaveBeenCalledTimes(1); + expect(system.spawnProcess).toHaveBeenCalledWith( + 'docker', + ['login', '--username=ghcr-user', '--password-stdin', 'ghcr.io'], + {}, + { stdin: 'ghcr-pass' } + ); + }); }); }); diff --git a/src/targets/docker.ts b/src/targets/docker.ts index 4c20f5af..41875d47 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -46,10 +46,15 @@ export function registryToEnvPrefix(registry: string): string { return registry.toUpperCase().replace(/[.\-:]/g, '_'); } -/** Options for "docker" target */ -export interface DockerTargetOptions { +/** Credentials for a Docker registry */ +export interface RegistryCredentials { username: string; password: string; + registry?: string; +} + +/** Options for "docker" target */ +export interface DockerTargetOptions { /** Source image path, like `us.gcr.io/sentryio/craft` */ source: string; /** Full name template for the source image path, defaults to `{{{source}}}:{{{revision}}}` */ @@ -58,8 +63,10 @@ export interface DockerTargetOptions { targetTemplate: string; /** Target image path, like `getsentry/craft` */ target: string; - /** Registry host for docker login (e.g., "ghcr.io"). Auto-detected from target if not specified. */ - registry?: string; + /** Credentials for the target registry */ + targetCredentials: RegistryCredentials; + /** Credentials for the source registry (if different from target and requires auth) */ + sourceCredentials?: RegistryCredentials; } /** @@ -84,39 +91,52 @@ export class DockerTarget extends BaseTarget { } /** - * Extracts Docker target options from the environment. + * Resolves credentials for a registry. * * Credential resolution follows two modes: * - * Mode A (explicit env vars): If usernameVar or passwordVar is configured, - * both must be specified and the env vars must exist. No fallback for security. + * Mode A (explicit env vars): If usernameVar and passwordVar are provided, + * only those env vars are used. Throws if either is missing. * * Mode B (automatic resolution): Tries in order: * 1. Registry-derived env vars: DOCKER__USERNAME / DOCKER__PASSWORD * 2. Built-in defaults for known registries (GHCR: GITHUB_ACTOR / GITHUB_TOKEN) - * 3. Default: DOCKER_USERNAME / DOCKER_PASSWORD + * 3. Default: DOCKER_USERNAME / DOCKER_PASSWORD (only if useDefaultFallback is true) + * + * @param registry The registry host (e.g., "ghcr.io"), undefined for Docker Hub + * @param usernameVar Optional explicit env var name for username + * @param passwordVar Optional explicit env var name for password + * @param required Whether credentials are required (throws if missing) + * @param useDefaultFallback Whether to fall back to DOCKER_USERNAME/PASSWORD defaults + * @returns Credentials if found, undefined if not required and not found */ - public getDockerConfig(): DockerTargetOptions { - const registry = - this.config.registry ?? extractRegistry(this.config.target); - + private resolveCredentials( + registry: string | undefined, + usernameVar?: string, + passwordVar?: string, + required = true, + useDefaultFallback = true + ): RegistryCredentials | undefined { let username: string | undefined; let password: string | undefined; // Mode A: Explicit env var override - no fallback for security - if (this.config.usernameVar || this.config.passwordVar) { - if (!this.config.usernameVar || !this.config.passwordVar) { + if (usernameVar || passwordVar) { + if (!usernameVar || !passwordVar) { throw new ConfigurationError( 'Both usernameVar and passwordVar must be specified together' ); } - username = process.env[this.config.usernameVar]; - password = process.env[this.config.passwordVar]; + username = process.env[usernameVar]; + password = process.env[passwordVar]; if (!username || !password) { - throw new ConfigurationError( - `Missing credentials: ${this.config.usernameVar} and/or ${this.config.passwordVar} environment variable(s) not set` - ); + if (required) { + throw new ConfigurationError( + `Missing credentials: ${usernameVar} and/or ${passwordVar} environment variable(s) not set` + ); + } + return undefined; } } else { // Mode B: Automatic resolution with fallback chain @@ -139,48 +159,110 @@ export class DockerTarget extends BaseTarget { } } - // 3. Fallback to defaults - username = username ?? process.env.DOCKER_USERNAME; - password = password ?? process.env.DOCKER_PASSWORD; + // 3. Fallback to defaults (only for target registry, not for source) + if (useDefaultFallback) { + username = username ?? process.env.DOCKER_USERNAME; + password = password ?? process.env.DOCKER_PASSWORD; + } } if (!username || !password) { - const registryHint = registry - ? `DOCKER_${registryToEnvPrefix(registry)}_USERNAME/PASSWORD or ` - : ''; - throw new ConfigurationError( - `Cannot perform Docker release: missing credentials. + if (required) { + const registryHint = registry + ? `DOCKER_${registryToEnvPrefix(registry)}_USERNAME/PASSWORD or ` + : ''; + throw new ConfigurationError( + `Cannot perform Docker release: missing credentials. Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variables.`.replace( - /^\s+/gm, - '' - ) + /^\s+/gm, + '' + ) + ); + } + return undefined; + } + + return { username, password, registry }; + } + + /** + * Extracts Docker target options from the environment. + */ + public getDockerConfig(): DockerTargetOptions { + const targetRegistry = + this.config.registry ?? extractRegistry(this.config.target); + const sourceRegistry = + this.config.sourceRegistry ?? extractRegistry(this.config.source); + + // Resolve target credentials (required) + const targetCredentials = this.resolveCredentials( + targetRegistry, + this.config.usernameVar, + this.config.passwordVar, + true + )!; + + // Resolve source credentials if source registry differs from target + // Source credentials are optional - if not found, we assume the source is public + // We don't fall back to default DOCKER_* credentials for source (those are for target) + let sourceCredentials: RegistryCredentials | undefined; + if (sourceRegistry !== targetRegistry) { + sourceCredentials = this.resolveCredentials( + sourceRegistry, + this.config.sourceUsernameVar, + this.config.sourcePasswordVar, + // Only required if explicit source env vars are specified + !!(this.config.sourceUsernameVar || this.config.sourcePasswordVar), + // Don't fall back to DOCKER_USERNAME/PASSWORD for source + false ); } return { - password, source: this.config.source, target: this.config.target, sourceTemplate: this.config.sourceFormat || '{{{source}}}:{{{revision}}}', targetTemplate: this.config.targetFormat || '{{{target}}}:{{{version}}}', - username, - registry, + targetCredentials, + sourceCredentials, }; } /** - * Logs into docker client with the provided username and password in config + * Logs into a Docker registry with the provided credentials. * * NOTE: This may change the globally logged in Docker user on the system + * + * @param credentials The registry credentials to use */ - public async login(): Promise { - const { username, password, registry } = this.dockerConfig; + private async loginToRegistry(credentials: RegistryCredentials): Promise { + const { username, password, registry } = credentials; const args = ['login', `--username=${username}`, '--password-stdin']; if (registry) { args.push(registry); } + const registryName = registry || 'Docker Hub'; + this.logger.debug(`Logging into ${registryName}...`); // Pass password via stdin for security (avoids exposure in ps/process list) - return spawnProcess(DOCKER_BIN, args, {}, { stdin: password }); + await spawnProcess(DOCKER_BIN, args, {}, { stdin: password }); + } + + /** + * Logs into all required Docker registries (source and target). + * + * If the source registry differs from target and has credentials configured, + * logs into both. Otherwise, only logs into the target registry. + */ + public async login(): Promise { + const { sourceCredentials, targetCredentials } = this.dockerConfig; + + // Login to source registry first (if different from target and has credentials) + if (sourceCredentials) { + await this.loginToRegistry(sourceCredentials); + } + + // Login to target registry + await this.loginToRegistry(targetCredentials); } /** @@ -216,7 +298,7 @@ Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variab * @param version The new version * @param revision The SHA revision of the new version */ - public async publish(version: string, revision: string): Promise { + public async publish(version: string, revision: string): Promise { await this.login(); await this.copy(revision, version); From 0faa5a4a07651094ea03b1ba17d9f62c9f9b8fad Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 11 Dec 2025 23:37:25 +0000 Subject: [PATCH 4/6] refactor(docker): Support nested object format for source/target config Allow source and target to be specified as either a string (image path) or an object with additional options (image, registry, format, usernameVar, passwordVar). This provides a cleaner API that groups related configuration. - Add ImageRefConfig and ImageRef types - Add normalizeImageRef() for backwards compatibility with legacy flat config - Update getDockerConfig() to use normalized config - Support both new nested format and legacy flat format - Add comprehensive tests for new format (12 new tests) - Update README with new configuration documentation --- README.md | 99 ++++- src/targets/__tests__/docker.test.ts | 638 +++++++++++++++++++++++++-- src/targets/docker.ts | 386 ++++++++++++++-- 3 files changed, 1029 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 5611232c..37885b94 100644 --- a/README.md +++ b/README.md @@ -1106,18 +1106,38 @@ source is assumed to be public. **Configuration** +Both `source` and `target` can be specified as a string (image path) or an object with additional options: + +| Option | Description | +| -------- | --------------------------------------------------------------------------------- | +| `source` | Source image: string `"ghcr.io/org/image"` or object (see below) | +| `target` | Target image: string `"getsentry/craft"` or object (see below) | + +When `source` or `target` is an object: + +| Property | Description | +| ------------- | -------------------------------------------------------------------------- | +| `image` | Docker image path (e.g., `ghcr.io/org/image`) | +| `registry` | **optional**. Override the registry (auto-detected from `image`) | +| `format` | **optional**. Format template. Default: `{{{source}}}:{{{revision}}}` for source, `{{{target}}}:{{{version}}}` for target | +| `usernameVar` | **optional**. Env var name for username (must be used with `passwordVar`) | +| `passwordVar` | **optional**. Env var name for password (must be used with `usernameVar`) | +| `skipLogin` | **optional**. Skip `docker login` for this registry. Use when auth is configured externally (e.g., gcloud workload identity). | + +**Legacy options** (for backwards compatibility, prefer object format above): + | Option | Description | | ------------------- | --------------------------------------------------------------------------------- | -| `source` | Path to the source Docker image to be pulled | -| `sourceFormat` | Format for the source image name. Default: `{{{source}}}:{{{revision}}}` | -| `sourceRegistry` | **optional**. Override the source registry (auto-detected from `source`) | -| `sourceUsernameVar` | **optional**. Env var name for source username (must be used with `sourcePasswordVar`) | -| `sourcePasswordVar` | **optional**. Env var name for source password (must be used with `sourceUsernameVar`) | -| `target` | Path to the target Docker image to be pushed | -| `targetFormat` | Format for the target image name. Default: `{{{target}}}:{{{version}}}` | -| `registry` | **optional**. Override the target registry for login (auto-detected from `target`) | -| `usernameVar` | **optional**. Env var name for target username (must be used with `passwordVar`) | -| `passwordVar` | **optional**. Env var name for target password (must be used with `usernameVar`) | +| `sourceFormat` | Format for the source image name (use `source.format` instead) | +| `sourceRegistry` | Override the source registry (use `source.registry` instead) | +| `sourceUsernameVar` | Env var name for source username (use `source.usernameVar` instead) | +| `sourcePasswordVar` | Env var name for source password (use `source.passwordVar` instead) | +| `targetFormat` | Format for the target image name (use `target.format` instead) | +| `registry` | Override the target registry (use `target.registry` instead) | +| `usernameVar` | Env var name for target username (use `target.usernameVar` instead) | +| `passwordVar` | Env var name for target password (use `target.passwordVar` instead) | +| `skipLogin` | Skip login for target registry (use `target.skipLogin` instead) | +| `sourceSkipLogin` | Skip login for source registry (use `source.skipLogin` instead) | **Examples** @@ -1180,12 +1200,63 @@ targets: source: ghcr.io/myorg/private-image target: getsentry/craft - # With explicit source credentials + # Using object format with explicit source credentials - name: docker - source: private.registry.io/image + source: + image: private.registry.io/image + usernameVar: PRIVATE_REGISTRY_USER + passwordVar: PRIVATE_REGISTRY_PASS target: getsentry/craft - sourceUsernameVar: PRIVATE_REGISTRY_USER - sourcePasswordVar: PRIVATE_REGISTRY_PASS +``` + +Using nested object format (recommended for complex configs): + +```yaml +targets: + - name: docker + source: + image: ghcr.io/myorg/source-image + format: "{{{source}}}:sha-{{{revision}}}" + target: + image: us.gcr.io/my-project/craft + registry: gcr.io # Share creds across GCR regions + format: "{{{target}}}:v{{{version}}}" +``` + +Publishing to Google Cloud registries (GCR/Artifact Registry): + +Craft automatically detects Google Cloud registries (`gcr.io`, `*.gcr.io`, `*-docker.pkg.dev`) and configures Docker authentication using `gcloud auth configure-docker` when: +- gcloud credentials are available (via `GOOGLE_APPLICATION_CREDENTIALS`, `GOOGLE_GHA_CREDS_PATH`, or default ADC location) +- The `gcloud` CLI is installed + +This works seamlessly with [google-github-actions/auth](https://github.com/google-github-actions/auth): + +```yaml +# GitHub Actions workflow +- uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.SERVICE_ACCOUNT }} + +# Craft automatically runs: gcloud auth configure-docker us-docker.pkg.dev +- run: craft publish ... + +# .craft.yml - no credentials needed! +targets: + - name: docker + source: ghcr.io/myorg/image + target: us-docker.pkg.dev/my-project/repo/image +``` + +If you need to skip automatic detection (e.g., auth already configured), use `skipLogin`: + +```yaml +targets: + - name: docker + source: ghcr.io/myorg/image + target: + image: us-docker.pkg.dev/my-project/repo/image + skipLogin: true # Skip all auth, already configured ``` ### Ruby Gems Index (`gem`) diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts index 7740e04a..5c71604a 100644 --- a/src/targets/__tests__/docker.test.ts +++ b/src/targets/__tests__/docker.test.ts @@ -1,7 +1,13 @@ +import * as fs from 'fs'; +import * as os from 'os'; + import { DockerTarget, extractRegistry, registryToEnvPrefix, + normalizeImageRef, + isGoogleCloudRegistry, + hasGcloudCredentials, } from '../docker'; import { NoneArtifactProvider } from '../../artifact_providers/none'; import * as system from '../../utils/system'; @@ -12,6 +18,129 @@ jest.mock('../../utils/system', () => ({ spawnProcess: jest.fn().mockResolvedValue(Buffer.from('')), })); +jest.mock('fs'); +jest.mock('os'); + +describe('normalizeImageRef', () => { + it('normalizes string source to object with image property', () => { + const config = { source: 'ghcr.io/org/image' }; + const result = normalizeImageRef(config, 'source'); + expect(result).toEqual({ + image: 'ghcr.io/org/image', + format: undefined, + registry: undefined, + usernameVar: undefined, + passwordVar: undefined, + }); + }); + + it('normalizes string target to object with image property', () => { + const config = { target: 'getsentry/craft' }; + const result = normalizeImageRef(config, 'target'); + expect(result).toEqual({ + image: 'getsentry/craft', + format: undefined, + registry: undefined, + usernameVar: undefined, + passwordVar: undefined, + }); + }); + + it('passes through object form', () => { + const config = { + source: { + image: 'ghcr.io/org/image', + registry: 'ghcr.io', + format: '{{{source}}}:latest', + usernameVar: 'MY_USER', + passwordVar: 'MY_PASS', + }, + }; + const result = normalizeImageRef(config, 'source'); + expect(result).toEqual({ + image: 'ghcr.io/org/image', + registry: 'ghcr.io', + format: '{{{source}}}:latest', + usernameVar: 'MY_USER', + passwordVar: 'MY_PASS', + }); + }); + + it('uses legacy source params as fallback for string form', () => { + const config = { + source: 'ghcr.io/org/image', + sourceFormat: '{{{source}}}:custom', + sourceRegistry: 'custom.registry.io', + sourceUsernameVar: 'LEGACY_USER', + sourcePasswordVar: 'LEGACY_PASS', + }; + const result = normalizeImageRef(config, 'source'); + expect(result).toEqual({ + image: 'ghcr.io/org/image', + format: '{{{source}}}:custom', + registry: 'custom.registry.io', + usernameVar: 'LEGACY_USER', + passwordVar: 'LEGACY_PASS', + }); + }); + + it('uses legacy target params as fallback for string form', () => { + const config = { + target: 'getsentry/craft', + targetFormat: '{{{target}}}:v{{{version}}}', + registry: 'docker.io', + usernameVar: 'LEGACY_USER', + passwordVar: 'LEGACY_PASS', + }; + const result = normalizeImageRef(config, 'target'); + expect(result).toEqual({ + image: 'getsentry/craft', + format: '{{{target}}}:v{{{version}}}', + registry: 'docker.io', + usernameVar: 'LEGACY_USER', + passwordVar: 'LEGACY_PASS', + }); + }); + + it('prefers object properties over legacy params', () => { + const config = { + source: { + image: 'ghcr.io/org/image', + registry: 'new.registry.io', + format: '{{{source}}}:new', + }, + sourceFormat: '{{{source}}}:legacy', + sourceRegistry: 'legacy.registry.io', + sourceUsernameVar: 'LEGACY_USER', + sourcePasswordVar: 'LEGACY_PASS', + }; + const result = normalizeImageRef(config, 'source'); + expect(result).toEqual({ + image: 'ghcr.io/org/image', + registry: 'new.registry.io', + format: '{{{source}}}:new', + usernameVar: 'LEGACY_USER', // Falls back to legacy since not in object + passwordVar: 'LEGACY_PASS', + }); + }); + + it('allows partial object with legacy fallback', () => { + const config = { + source: { image: 'ghcr.io/org/image' }, + sourceFormat: '{{{source}}}:legacy', + sourceRegistry: 'legacy.registry.io', + }; + const result = normalizeImageRef(config, 'source'); + expect(result).toEqual({ + image: 'ghcr.io/org/image', + format: '{{{source}}}:legacy', + registry: 'legacy.registry.io', + usernameVar: undefined, + passwordVar: undefined, + }); + }); +}); + describe('extractRegistry', () => { it('returns undefined for Docker Hub images (user/image)', () => { expect(extractRegistry('user/image')).toBeUndefined(); @@ -85,11 +214,100 @@ describe('registryToEnvPrefix', () => { }); }); +describe('isGoogleCloudRegistry', () => { + it('returns true for gcr.io', () => { + expect(isGoogleCloudRegistry('gcr.io')).toBe(true); + }); + + it('returns true for regional GCR variants', () => { + expect(isGoogleCloudRegistry('us.gcr.io')).toBe(true); + expect(isGoogleCloudRegistry('eu.gcr.io')).toBe(true); + expect(isGoogleCloudRegistry('asia.gcr.io')).toBe(true); + }); + + it('returns true for Artifact Registry (pkg.dev)', () => { + expect(isGoogleCloudRegistry('us-docker.pkg.dev')).toBe(true); + expect(isGoogleCloudRegistry('europe-docker.pkg.dev')).toBe(true); + expect(isGoogleCloudRegistry('asia-docker.pkg.dev')).toBe(true); + }); + + it('returns false for non-Google registries', () => { + expect(isGoogleCloudRegistry('ghcr.io')).toBe(false); + expect(isGoogleCloudRegistry('docker.io')).toBe(false); + expect(isGoogleCloudRegistry('custom.registry.io')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isGoogleCloudRegistry(undefined)).toBe(false); + }); +}); + +describe('hasGcloudCredentials', () => { + const mockFs = fs as jest.Mocked; + const mockOs = os as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(false); + mockOs.homedir.mockReturnValue('/home/user'); + }); + + afterEach(() => { + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + delete process.env.GOOGLE_GHA_CREDS_PATH; + delete process.env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE; + }); + + it('returns true when GOOGLE_APPLICATION_CREDENTIALS points to existing file', () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json'; + mockFs.existsSync.mockImplementation( + (p: fs.PathLike) => p === '/path/to/creds.json' + ); + + expect(hasGcloudCredentials()).toBe(true); + }); + + it('returns true when GOOGLE_GHA_CREDS_PATH points to existing file', () => { + process.env.GOOGLE_GHA_CREDS_PATH = '/tmp/gha-creds.json'; + mockFs.existsSync.mockImplementation( + (p: fs.PathLike) => p === '/tmp/gha-creds.json' + ); + + expect(hasGcloudCredentials()).toBe(true); + }); + + it('returns true when CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE points to existing file', () => { + process.env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE = '/override/creds.json'; + mockFs.existsSync.mockImplementation( + (p: fs.PathLike) => p === '/override/creds.json' + ); + + expect(hasGcloudCredentials()).toBe(true); + }); + + it('returns true when default ADC file exists', () => { + mockFs.existsSync.mockImplementation( + (p: fs.PathLike) => + p === '/home/user/.config/gcloud/application_default_credentials.json' + ); + + expect(hasGcloudCredentials()).toBe(true); + }); + + it('returns false when no credentials are found', () => { + expect(hasGcloudCredentials()).toBe(false); + }); +}); + describe('DockerTarget', () => { const oldEnv = { ...process.env }; + const mockFs = fs as jest.Mocked; + const mockOs = os as jest.Mocked; beforeEach(() => { jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(false); + mockOs.homedir.mockReturnValue('/home/user'); // Clear all Docker-related env vars delete process.env.DOCKER_USERNAME; delete process.env.DOCKER_PASSWORD; @@ -122,8 +340,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('custom-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('custom-pass'); + expect(target.dockerConfig.target.credentials!.username).toBe('custom-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('custom-pass'); }); it('throws if only usernameVar is specified', () => { @@ -199,8 +417,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('ghcr-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('ghcr-pass'); + expect(target.dockerConfig.target.credentials!.username).toBe('ghcr-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('ghcr-pass'); }); it('falls back to GHCR defaults (GITHUB_ACTOR/GITHUB_TOKEN) for ghcr.io', () => { @@ -216,8 +434,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('github-actor'); - expect(target.dockerConfig.targetCredentials.password).toBe('github-token'); + expect(target.dockerConfig.target.credentials!.username).toBe('github-actor'); + expect(target.dockerConfig.target.credentials!.password).toBe('github-token'); }); it('uses default DOCKER_* env vars for Docker Hub', () => { @@ -233,9 +451,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); + expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.target.credentials!.registry).toBeUndefined(); }); it('treats docker.io as Docker Hub and uses default credentials', () => { @@ -251,9 +469,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); + expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.target.credentials!.registry).toBeUndefined(); }); it('treats index.docker.io as Docker Hub and uses default credentials', () => { @@ -269,9 +487,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); + expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.target.credentials!.registry).toBeUndefined(); }); it('falls back to DOCKER_* when registry-specific vars are not set', () => { @@ -287,8 +505,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.username).toBe('default-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('default-pass'); + expect(target.dockerConfig.target.credentials!.username).toBe('default-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('default-pass'); }); it('throws when no credentials are available', () => { @@ -306,17 +524,18 @@ describe('DockerTarget', () => { }); it('includes registry-specific hint in error message', () => { + // Use a non-Google Cloud registry that will require credentials expect( () => new DockerTarget( { name: 'docker', source: 'ghcr.io/org/image', - target: 'gcr.io/project/image', + target: 'custom.registry.io/project/image', }, new NoneArtifactProvider() ) - ).toThrow('DOCKER_GCR_IO_USERNAME/PASSWORD'); + ).toThrow('DOCKER_CUSTOM_REGISTRY_IO_USERNAME/PASSWORD'); }); }); @@ -335,9 +554,9 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.targetCredentials.registry).toBe('gcr.io'); - expect(target.dockerConfig.targetCredentials.username).toBe('gcr-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('gcr-pass'); + expect(target.dockerConfig.target.credentials!.registry).toBe('gcr.io'); + expect(target.dockerConfig.target.credentials!.username).toBe('gcr-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('gcr-pass'); }); }); }); @@ -359,15 +578,15 @@ describe('DockerTarget', () => { ); // Target should use Docker Hub credentials - expect(target.dockerConfig.targetCredentials.username).toBe('dockerhub-user'); - expect(target.dockerConfig.targetCredentials.password).toBe('dockerhub-pass'); - expect(target.dockerConfig.targetCredentials.registry).toBeUndefined(); + expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass'); + expect(target.dockerConfig.target.credentials!.registry).toBeUndefined(); // Source should use GHCR credentials - expect(target.dockerConfig.sourceCredentials).toBeDefined(); - expect(target.dockerConfig.sourceCredentials?.username).toBe('ghcr-user'); - expect(target.dockerConfig.sourceCredentials?.password).toBe('ghcr-pass'); - expect(target.dockerConfig.sourceCredentials?.registry).toBe('ghcr.io'); + expect(target.dockerConfig.source.credentials).toBeDefined(); + expect(target.dockerConfig.source.credentials?.username).toBe('ghcr-user'); + expect(target.dockerConfig.source.credentials?.password).toBe('ghcr-pass'); + expect(target.dockerConfig.source.credentials?.registry).toBe('ghcr.io'); }); it('does not set source credentials when source and target registries are the same', () => { @@ -383,7 +602,7 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.sourceCredentials).toBeUndefined(); + expect(target.dockerConfig.source.credentials).toBeUndefined(); }); it('uses explicit sourceUsernameVar/sourcePasswordVar for source credentials', () => { @@ -403,8 +622,8 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.sourceCredentials?.username).toBe('source-user'); - expect(target.dockerConfig.sourceCredentials?.password).toBe('source-pass'); + expect(target.dockerConfig.source.credentials?.username).toBe('source-user'); + expect(target.dockerConfig.source.credentials?.password).toBe('source-pass'); }); it('throws if only sourceUsernameVar is specified', () => { @@ -441,7 +660,7 @@ describe('DockerTarget', () => { ); // Should not throw, source credentials are optional - expect(target.dockerConfig.sourceCredentials).toBeUndefined(); + expect(target.dockerConfig.source.credentials).toBeUndefined(); }); it('uses sourceRegistry config override', () => { @@ -460,9 +679,163 @@ describe('DockerTarget', () => { new NoneArtifactProvider() ); - expect(target.dockerConfig.sourceCredentials?.registry).toBe('gcr.io'); - expect(target.dockerConfig.sourceCredentials?.username).toBe('gcr-user'); - expect(target.dockerConfig.sourceCredentials?.password).toBe('gcr-pass'); + expect(target.dockerConfig.source.credentials?.registry).toBe('gcr.io'); + expect(target.dockerConfig.source.credentials?.username).toBe('gcr-user'); + expect(target.dockerConfig.source.credentials?.password).toBe('gcr-pass'); + }); + }); + + describe('nested object config format', () => { + it('supports target as object with image property', () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/source-image', + target: { + image: 'ghcr.io/org/target-image', + }, + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.target.image).toBe('ghcr.io/org/target-image'); + expect(target.dockerConfig.target.credentials!.registry).toBe('ghcr.io'); + }); + + it('supports source as object with image property', () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: { + image: 'ghcr.io/org/source-image', + }, + target: 'ghcr.io/org/target-image', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.source.image).toBe('ghcr.io/org/source-image'); + }); + + it('supports both source and target as objects', () => { + process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user'; + process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: { + image: 'ghcr.io/org/source-image', + }, + target: { + image: 'getsentry/craft', + }, + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.source.image).toBe('ghcr.io/org/source-image'); + expect(target.dockerConfig.target.image).toBe('getsentry/craft'); + }); + + it('uses registry from object config', () => { + process.env.DOCKER_GCR_IO_USERNAME = 'gcr-user'; + process.env.DOCKER_GCR_IO_PASSWORD = 'gcr-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/source-image', + target: { + image: 'us.gcr.io/project/image', + registry: 'gcr.io', // Override to share creds across regions + }, + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.target.credentials!.registry).toBe('gcr.io'); + expect(target.dockerConfig.target.credentials!.username).toBe('gcr-user'); + }); + + it('uses usernameVar/passwordVar from object config', () => { + process.env.MY_TARGET_USER = 'target-user'; + process.env.MY_TARGET_PASS = 'target-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/source-image', + target: { + image: 'getsentry/craft', + usernameVar: 'MY_TARGET_USER', + passwordVar: 'MY_TARGET_PASS', + }, + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.target.credentials!.username).toBe('target-user'); + expect(target.dockerConfig.target.credentials!.password).toBe('target-pass'); + }); + + it('uses format from object config', () => { + process.env.DOCKER_USERNAME = 'user'; + process.env.DOCKER_PASSWORD = 'pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: { + image: 'ghcr.io/org/source-image', + format: '{{{source}}}:sha-{{{revision}}}', + }, + target: { + image: 'getsentry/craft', + format: '{{{target}}}:v{{{version}}}', + }, + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.source.format).toBe( + '{{{source}}}:sha-{{{revision}}}' + ); + expect(target.dockerConfig.target.format).toBe( + '{{{target}}}:v{{{version}}}' + ); + }); + + it('supports source object with credentials for cross-registry publishing', () => { + process.env.MY_SOURCE_USER = 'source-user'; + process.env.MY_SOURCE_PASS = 'source-pass'; + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: { + image: 'ghcr.io/org/private-image', + usernameVar: 'MY_SOURCE_USER', + passwordVar: 'MY_SOURCE_PASS', + }, + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.source.credentials?.username).toBe('source-user'); + expect(target.dockerConfig.source.credentials?.password).toBe('source-pass'); + expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user'); }); }); @@ -624,5 +997,198 @@ describe('DockerTarget', () => { { stdin: 'ghcr-pass' } ); }); + + it('skips login when target.skipLogin is true', async () => { + // No credentials set - would normally throw + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: { + image: 'us.gcr.io/project/image', + skipLogin: true, // Auth handled externally (e.g., gcloud workload identity) + }, + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should not attempt any login + expect(system.spawnProcess).not.toHaveBeenCalled(); + }); + + it('skips login when source.skipLogin is true', async () => { + process.env.DOCKER_USERNAME = 'dockerhub-user'; + process.env.DOCKER_PASSWORD = 'dockerhub-pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: { + image: 'us.gcr.io/project/image', + skipLogin: true, // Auth handled externally + }, + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should only login to target (Docker Hub) + expect(system.spawnProcess).toHaveBeenCalledTimes(1); + expect(system.spawnProcess).toHaveBeenCalledWith( + 'docker', + ['login', '--username=dockerhub-user', '--password-stdin'], + {}, + { stdin: 'dockerhub-pass' } + ); + }); + + it('skips login for both when both have skipLogin', async () => { + const target = new DockerTarget( + { + name: 'docker', + source: { + image: 'us.gcr.io/project/source', + skipLogin: true, + }, + target: { + image: 'us.gcr.io/project/target', + skipLogin: true, + }, + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should not attempt any login + expect(system.spawnProcess).not.toHaveBeenCalled(); + }); + + it('auto-configures gcloud for GCR registries when credentials are available', async () => { + // Set up gcloud credentials + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json'; + mockFs.existsSync.mockReturnValue(true); + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'gcr.io/project/image', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should call gcloud auth configure-docker + expect(system.spawnProcess).toHaveBeenCalledWith( + 'gcloud', + ['auth', 'configure-docker', 'gcr.io', '--quiet'], + {}, + {} + ); + }); + + it('auto-configures gcloud for Artifact Registry (pkg.dev)', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json'; + mockFs.existsSync.mockReturnValue(true); + + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'us-docker.pkg.dev/project/repo/image', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should call gcloud auth configure-docker with Artifact Registry + expect(system.spawnProcess).toHaveBeenCalledWith( + 'gcloud', + ['auth', 'configure-docker', 'us-docker.pkg.dev', '--quiet'], + {}, + {} + ); + }); + + it('configures multiple GCR registries in one call', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json'; + mockFs.existsSync.mockReturnValue(true); + + const target = new DockerTarget( + { + name: 'docker', + source: 'us.gcr.io/project/source', + target: 'eu.gcr.io/project/target', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should configure both registries in one call + expect(system.spawnProcess).toHaveBeenCalledWith( + 'gcloud', + ['auth', 'configure-docker', 'us.gcr.io,eu.gcr.io', '--quiet'], + {}, + {} + ); + }); + + it('skips gcloud configuration when no credentials are available', async () => { + // No credentials set, fs.existsSync returns false + mockFs.existsSync.mockReturnValue(false); + + // Use Docker Hub as target (requires DOCKER_USERNAME/PASSWORD) + process.env.DOCKER_USERNAME = 'user'; + process.env.DOCKER_PASSWORD = 'pass'; + + const target = new DockerTarget( + { + name: 'docker', + source: 'gcr.io/project/image', + target: 'getsentry/craft', + }, + new NoneArtifactProvider() + ); + + await target.login(); + + // Should not call gcloud, only docker login + expect(system.spawnProcess).not.toHaveBeenCalledWith( + 'gcloud', + expect.any(Array), + expect.any(Object), + expect.any(Object) + ); + expect(system.spawnProcess).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['login']), + {}, + expect.any(Object) + ); + }); + + it('does not require credentials for GCR registries at config time', () => { + // This should not throw even though no credentials are set + // because GCR registries can use gcloud auth + const target = new DockerTarget( + { + name: 'docker', + source: 'ghcr.io/org/image', + target: 'gcr.io/project/image', + }, + new NoneArtifactProvider() + ); + + expect(target.dockerConfig.target.credentials).toBeUndefined(); + }); }); }); diff --git a/src/targets/docker.ts b/src/targets/docker.ts index 41875d47..1c7fe6bf 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -1,3 +1,7 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + import { TargetConfig } from '../schemas/project_config'; import { BaseArtifactProvider } from '../artifact_providers/base'; import { ConfigurationError } from '../utils/errors'; @@ -15,6 +19,76 @@ const DOCKER_BIN = process.env.DOCKER_BIN || DEFAULT_DOCKER_BIN; /** Docker Hub registry hostnames that should be treated as the default registry */ const DOCKER_HUB_REGISTRIES = ['docker.io', 'index.docker.io', 'registry-1.docker.io']; +/** + * Google Cloud registry patterns. + * - gcr.io and regional variants (Container Registry - being deprecated) + * - *.pkg.dev (Artifact Registry - recommended) + */ +const GCR_REGISTRY_PATTERNS = [ + /^gcr\.io$/, + /^[a-z]+-gcr\.io$/, // us-gcr.io, eu-gcr.io, asia-gcr.io, etc. + /^[a-z]+\.gcr\.io$/, // us.gcr.io, eu.gcr.io, asia.gcr.io, etc. + /^[a-z]+-docker\.pkg\.dev$/, // us-docker.pkg.dev, europe-docker.pkg.dev, etc. +]; + +/** + * Checks if a registry is a Google Cloud registry (GCR or Artifact Registry). + */ +export function isGoogleCloudRegistry(registry: string | undefined): boolean { + if (!registry) return false; + return GCR_REGISTRY_PATTERNS.some(pattern => pattern.test(registry)); +} + +/** + * Checks if gcloud credentials are available in the environment. + * These are typically set by google-github-actions/auth or `gcloud auth login`. + * + * Detection methods: + * 1. GOOGLE_APPLICATION_CREDENTIALS env var pointing to a valid file + * 2. GOOGLE_GHA_CREDS_PATH env var (set by google-github-actions/auth) + * 3. CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE env var + * 4. Default ADC location: ~/.config/gcloud/application_default_credentials.json + */ +export function hasGcloudCredentials(): boolean { + // Check environment variables that point to credential files + const credPaths = [ + process.env.GOOGLE_APPLICATION_CREDENTIALS, + process.env.GOOGLE_GHA_CREDS_PATH, + process.env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE, + ]; + + for (const credPath of credPaths) { + if (credPath && fs.existsSync(credPath)) { + return true; + } + } + + // Check default Application Default Credentials location + const defaultAdcPath = path.join( + os.homedir(), + '.config', + 'gcloud', + 'application_default_credentials.json' + ); + if (fs.existsSync(defaultAdcPath)) { + return true; + } + + return false; +} + +/** + * Checks if the gcloud CLI is available. + */ +export async function isGcloudAvailable(): Promise { + try { + await spawnProcess('gcloud', ['--version'], {}, {}); + return true; + } catch { + return false; + } +} + /** * Extracts the registry host from a Docker image path. * @@ -53,20 +127,115 @@ export interface RegistryCredentials { registry?: string; } +/** + * Image reference configuration (object form). + * Can also be specified as a string shorthand for just the image path. + */ +export interface ImageRefConfig { + /** Docker image path (e.g., "ghcr.io/user/image" or "user/image") */ + image: string; + /** Override the registry for credentials (auto-detected from image if not specified) */ + registry?: string; + /** Format template for the image name */ + format?: string; + /** Env var name for username (must be used with passwordVar) */ + usernameVar?: string; + /** Env var name for password (must be used with usernameVar) */ + passwordVar?: string; + /** + * Skip docker login for this registry. + * Use when auth is configured externally (e.g., gcloud workload identity, service account). + * When true, craft assumes Docker is already authenticated to access this registry. + */ + skipLogin?: boolean; +} + +/** Image reference can be a string (image path) or full config object */ +export type ImageRef = string | ImageRefConfig; + +/** Legacy config keys for source and target */ +interface LegacyConfigKeys { + format: string; + registry: string; + usernameVar: string; + passwordVar: string; + skipLogin: string; +} + +const LEGACY_KEYS: Record<'source' | 'target', LegacyConfigKeys> = { + source: { + format: 'sourceFormat', + registry: 'sourceRegistry', + usernameVar: 'sourceUsernameVar', + passwordVar: 'sourcePasswordVar', + skipLogin: 'sourceSkipLogin', + }, + target: { + format: 'targetFormat', + registry: 'registry', + usernameVar: 'usernameVar', + passwordVar: 'passwordVar', + skipLogin: 'skipLogin', + }, +}; + +/** + * Normalizes an image reference to object form. + * Handles backwards compatibility with legacy flat config. + * + * @param config The full target config object + * @param type Whether this is 'source' or 'target' image reference + */ +export function normalizeImageRef( + config: Record, + type: 'source' | 'target' +): ImageRefConfig { + const ref = config[type] as ImageRef; + const keys = LEGACY_KEYS[type]; + + // Get legacy values from config + const legacyFormat = config[keys.format] as string | undefined; + const legacyRegistry = config[keys.registry] as string | undefined; + const legacyUsernameVar = config[keys.usernameVar] as string | undefined; + const legacyPasswordVar = config[keys.passwordVar] as string | undefined; + const legacySkipLogin = config[keys.skipLogin] as boolean | undefined; + + if (typeof ref === 'string') { + return { + image: ref, + format: legacyFormat, + registry: legacyRegistry, + usernameVar: legacyUsernameVar, + passwordVar: legacyPasswordVar, + skipLogin: legacySkipLogin, + }; + } + + // Object form - prefer object properties over legacy, but allow legacy as fallback + return { + image: ref.image, + format: ref.format ?? legacyFormat, + registry: ref.registry ?? legacyRegistry, + usernameVar: ref.usernameVar ?? legacyUsernameVar, + passwordVar: ref.passwordVar ?? legacyPasswordVar, + skipLogin: ref.skipLogin ?? legacySkipLogin, + }; +} + +/** Resolved image configuration with credentials */ +export interface ResolvedImageConfig extends ImageRefConfig { + /** Resolved format template (with defaults applied) */ + format: string; + /** Resolved credentials for this registry (undefined if public/same as other) */ + credentials?: RegistryCredentials; +} + /** Options for "docker" target */ export interface DockerTargetOptions { - /** Source image path, like `us.gcr.io/sentryio/craft` */ - source: string; - /** Full name template for the source image path, defaults to `{{{source}}}:{{{revision}}}` */ - sourceTemplate: string; - /** Full name template for the target image path, defaults to `{{{target}}}:{{{version}}}` */ - targetTemplate: string; - /** Target image path, like `getsentry/craft` */ - target: string; - /** Credentials for the target registry */ - targetCredentials: RegistryCredentials; - /** Credentials for the source registry (if different from target and requires auth) */ - sourceCredentials?: RegistryCredentials; + /** Source image configuration with resolved credentials */ + source: ResolvedImageConfig; + /** Target image configuration with resolved credentials (or skipLogin for external auth) */ + target: ResolvedImageConfig; } /** @@ -187,44 +356,70 @@ Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variab /** * Extracts Docker target options from the environment. + * + * Supports both new nested config format and legacy flat format: + * + * New format: + * source: { image: "ghcr.io/org/image", registry: "ghcr.io", usernameVar: "X" } + * target: "getsentry/craft" # string shorthand + * + * Legacy format: + * source: "ghcr.io/org/image" + * sourceRegistry: "ghcr.io" + * sourceUsernameVar: "X" */ public getDockerConfig(): DockerTargetOptions { - const targetRegistry = - this.config.registry ?? extractRegistry(this.config.target); - const sourceRegistry = - this.config.sourceRegistry ?? extractRegistry(this.config.source); - - // Resolve target credentials (required) - const targetCredentials = this.resolveCredentials( - targetRegistry, - this.config.usernameVar, - this.config.passwordVar, - true - )!; + // Normalize source and target configs (handles string vs object, legacy vs new) + const source = normalizeImageRef(this.config, 'source'); + const target = normalizeImageRef(this.config, 'target'); + + // Resolve registries (explicit config > auto-detected from image) + const targetRegistry = target.registry ?? extractRegistry(target.image); + const sourceRegistry = source.registry ?? extractRegistry(source.image); + + // Resolve target credentials + // - Skip if skipLogin is set (auth configured externally) + // - For Google Cloud registries, credentials are optional (can use gcloud auth) + // - For other registries, credentials are required + let targetCredentials: RegistryCredentials | undefined; + if (!target.skipLogin) { + const isGcrTarget = isGoogleCloudRegistry(targetRegistry); + targetCredentials = this.resolveCredentials( + targetRegistry, + target.usernameVar, + target.passwordVar, + // Required unless it's a GCR registry (which can use gcloud auth) + !isGcrTarget + ); + } // Resolve source credentials if source registry differs from target // Source credentials are optional - if not found, we assume the source is public // We don't fall back to default DOCKER_* credentials for source (those are for target) let sourceCredentials: RegistryCredentials | undefined; - if (sourceRegistry !== targetRegistry) { + if (!source.skipLogin && sourceRegistry !== targetRegistry) { sourceCredentials = this.resolveCredentials( sourceRegistry, - this.config.sourceUsernameVar, - this.config.sourcePasswordVar, + source.usernameVar, + source.passwordVar, // Only required if explicit source env vars are specified - !!(this.config.sourceUsernameVar || this.config.sourcePasswordVar), + !!(source.usernameVar || source.passwordVar), // Don't fall back to DOCKER_USERNAME/PASSWORD for source false ); } return { - source: this.config.source, - target: this.config.target, - sourceTemplate: this.config.sourceFormat || '{{{source}}}:{{{revision}}}', - targetTemplate: this.config.targetFormat || '{{{target}}}:{{{version}}}', - targetCredentials, - sourceCredentials, + source: { + ...source, + format: source.format || '{{{source}}}:{{{revision}}}', + credentials: sourceCredentials, + }, + target: { + ...target, + format: target.format || '{{{target}}}:{{{version}}}', + credentials: targetCredentials, + }, }; } @@ -247,22 +442,121 @@ Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variab await spawnProcess(DOCKER_BIN, args, {}, { stdin: password }); } + /** + * Configures Docker to use gcloud for authentication to Google Cloud registries. + * This runs `gcloud auth configure-docker` which sets up the credential helper. + * + * @param registries List of Google Cloud registries to configure + * @returns true if configuration was successful, false otherwise + */ + private async configureGcloudDocker(registries: string[]): Promise { + if (registries.length === 0) { + return false; + } + + // Check if gcloud credentials are available + if (!hasGcloudCredentials()) { + this.logger.debug('No gcloud credentials detected, skipping gcloud auth configure-docker'); + return false; + } + + // Check if gcloud is available + if (!(await isGcloudAvailable())) { + this.logger.debug('gcloud CLI not available, skipping gcloud auth configure-docker'); + return false; + } + + const registryList = registries.join(','); + this.logger.debug(`Configuring Docker for Google Cloud registries: ${registryList}`); + + try { + // Run gcloud auth configure-docker with the registries + // This configures Docker's credential helper to use gcloud for these registries + await spawnProcess('gcloud', ['auth', 'configure-docker', registryList, '--quiet'], {}, {}); + this.logger.info(`Configured Docker authentication for: ${registryList}`); + return true; + } catch (error) { + this.logger.warn(`Failed to configure gcloud Docker auth: ${error}`); + return false; + } + } + /** * Logs into all required Docker registries (source and target). * + * For Google Cloud registries (gcr.io, *.pkg.dev), automatically uses + * `gcloud auth configure-docker` if gcloud credentials are available. + * * If the source registry differs from target and has credentials configured, * logs into both. Otherwise, only logs into the target registry. */ public async login(): Promise { - const { sourceCredentials, targetCredentials } = this.dockerConfig; + const { source, target } = this.dockerConfig; + + // Resolve registries from the config + const sourceRegistry = source.registry ?? extractRegistry(source.image); + const targetRegistry = target.registry ?? extractRegistry(target.image); + + // Collect Google Cloud registries that need authentication + const gcrRegistries: string[] = []; + const gcrConfiguredRegistries = new Set(); + + // Check if source registry is a Google Cloud registry and needs auth + if ( + !source.skipLogin && + !source.credentials && + sourceRegistry && + isGoogleCloudRegistry(sourceRegistry) + ) { + gcrRegistries.push(sourceRegistry); + } - // Login to source registry first (if different from target and has credentials) - if (sourceCredentials) { - await this.loginToRegistry(sourceCredentials); + // Check if target registry is a Google Cloud registry and needs auth + if ( + !target.skipLogin && + !target.credentials && + targetRegistry && + isGoogleCloudRegistry(targetRegistry) + ) { + // Avoid duplicates + if (!gcrRegistries.includes(targetRegistry)) { + gcrRegistries.push(targetRegistry); + } + } + + // Try to configure gcloud for Google Cloud registries + if (gcrRegistries.length > 0) { + const configured = await this.configureGcloudDocker(gcrRegistries); + if (configured) { + gcrRegistries.forEach(r => gcrConfiguredRegistries.add(r)); + } + } + + // Login to source registry (if needed and not already configured via gcloud) + if (source.credentials) { + await this.loginToRegistry(source.credentials); + } else if ( + sourceRegistry && + !source.skipLogin && + !gcrConfiguredRegistries.has(sourceRegistry) + ) { + // Source registry needs auth but we couldn't configure it + // This is okay - source might be public or already authenticated + this.logger.debug(`No credentials for source registry ${sourceRegistry}, assuming public`); } - // Login to target registry - await this.loginToRegistry(targetCredentials); + // Login to target registry (if needed and not already configured via gcloud) + if (target.credentials) { + await this.loginToRegistry(target.credentials); + } else if (!target.skipLogin && !gcrConfiguredRegistries.has(targetRegistry || '')) { + // Target registry needs auth but we have no credentials and couldn't configure gcloud + // This will likely fail when pushing, but we let it proceed + if (targetRegistry) { + this.logger.warn( + `No credentials for target registry ${targetRegistry}. Push may fail.` + ); + } + } } /** @@ -274,12 +568,16 @@ Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variab * @param version The release version for the target image */ async copy(sourceRevision: string, version: string): Promise { - const sourceImage = renderTemplateSafe(this.dockerConfig.sourceTemplate, { - ...this.dockerConfig, + const { source, target } = this.dockerConfig; + + const sourceImage = renderTemplateSafe(source.format, { + source: source.image, + target: target.image, revision: sourceRevision, }); - const targetImage = renderTemplateSafe(this.dockerConfig.targetTemplate, { - ...this.dockerConfig, + const targetImage = renderTemplateSafe(target.format, { + source: source.image, + target: target.image, version, }); From f8b1ec6865e295b2b7dd0277e1cd0d07806c47e5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 12 Dec 2025 10:57:30 +0000 Subject: [PATCH 5/6] fix(docker): validate source and target properties are present Throws a clear ConfigurationError if source or target is missing from the Docker target configuration, instead of crashing with an unhelpful TypeError: Cannot read property 'image' of undefined. --- src/targets/__tests__/docker.test.ts | 14 ++++++++++++++ src/targets/docker.ts | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts index 5c71604a..13bdd834 100644 --- a/src/targets/__tests__/docker.test.ts +++ b/src/targets/__tests__/docker.test.ts @@ -139,6 +139,20 @@ describe('normalizeImageRef', () => { passwordVar: undefined, }); }); + + it('throws ConfigurationError when source is missing', () => { + const config = { target: 'getsentry/craft' }; + expect(() => normalizeImageRef(config, 'source')).toThrow( + "Docker target requires a 'source' property. Please specify the source image." + ); + }); + + it('throws ConfigurationError when target is missing', () => { + const config = { source: 'ghcr.io/org/image' }; + expect(() => normalizeImageRef(config, 'target')).toThrow( + "Docker target requires a 'target' property. Please specify the target image." + ); + }); }); describe('extractRegistry', () => { diff --git a/src/targets/docker.ts b/src/targets/docker.ts index 1c7fe6bf..ebd53649 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -191,6 +191,14 @@ export function normalizeImageRef( type: 'source' | 'target' ): ImageRefConfig { const ref = config[type] as ImageRef; + + // Validate that the required field is present + if (ref === undefined || ref === null) { + throw new ConfigurationError( + `Docker target requires a '${type}' property. Please specify the ${type} image.` + ); + } + const keys = LEGACY_KEYS[type]; // Get legacy values from config From 9d986e7129b5195145d5b1845e35db5bf3af45f3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 12 Dec 2025 19:25:14 +0000 Subject: [PATCH 6/6] refactor(docker): use node: prefix and named imports for Node.js built-ins - Use node:fs, node:os, node:path instead of fs, os, path - Use specific named imports (existsSync, homedir, join) instead of namespace imports --- src/targets/__tests__/docker.test.ts | 8 ++++---- src/targets/docker.ts | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts index 13bdd834..3ca331ab 100644 --- a/src/targets/__tests__/docker.test.ts +++ b/src/targets/__tests__/docker.test.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs'; -import * as os from 'os'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; import { DockerTarget, @@ -18,8 +18,8 @@ jest.mock('../../utils/system', () => ({ spawnProcess: jest.fn().mockResolvedValue(Buffer.from('')), })); -jest.mock('fs'); -jest.mock('os'); +jest.mock('node:fs'); +jest.mock('node:os'); describe('normalizeImageRef', () => { it('normalizes string source to object with image property', () => { diff --git a/src/targets/docker.ts b/src/targets/docker.ts index ebd53649..d8508b5a 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { TargetConfig } from '../schemas/project_config'; import { BaseArtifactProvider } from '../artifact_providers/base'; @@ -58,19 +58,19 @@ export function hasGcloudCredentials(): boolean { ]; for (const credPath of credPaths) { - if (credPath && fs.existsSync(credPath)) { + if (credPath && existsSync(credPath)) { return true; } } // Check default Application Default Credentials location - const defaultAdcPath = path.join( - os.homedir(), + const defaultAdcPath = join( + homedir(), '.config', 'gcloud', 'application_default_credentials.json' ); - if (fs.existsSync(defaultAdcPath)) { + if (existsSync(defaultAdcPath)) { return true; } @@ -348,12 +348,12 @@ export class DockerTarget extends BaseTarget { const registryHint = registry ? `DOCKER_${registryToEnvPrefix(registry)}_USERNAME/PASSWORD or ` : ''; - throw new ConfigurationError( - `Cannot perform Docker release: missing credentials. + throw new ConfigurationError( + `Cannot perform Docker release: missing credentials. Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variables.`.replace( - /^\s+/gm, - '' - ) + /^\s+/gm, + '' + ) ); } return undefined;