diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8350591..c3ddad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,7 @@ jobs: with: registry: public.ecr.aws - github-container: + ghcr: runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -356,3 +356,125 @@ jobs: echo "::error::Should have failed" exit 1 fi + + scope-dockerhub: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - + name: Checkout + uses: actions/checkout@v6 + - + name: Login to Docker Hub + uses: ./ + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + scope: '@push' + - + name: Print config.json files + shell: bash + run: | + shopt -s globstar nullglob + for file in ~/.docker/**/config.json; do + echo "## ${file}" + jq '(.auths[]?.auth) |= "REDACTED"' "$file" + echo "" + done + + scope-dockerhub-repo: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - + name: Checkout + uses: actions/checkout@v6 + - + name: Login to Docker Hub + uses: ./ + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + scope: 'docker/buildx-bin@push' + - + name: Print config.json files + shell: bash + run: | + shopt -s globstar nullglob + for file in ~/.docker/**/config.json; do + echo "## ${file}" + jq '(.auths[]?.auth) |= "REDACTED"' "$file" + echo "" + done + + scope-ghcr: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - + name: Checkout + uses: actions/checkout@v6 + - + name: Login to GitHub Container Registry + uses: ./ + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + scope: '@push' + - + name: Print config.json files + shell: bash + run: | + shopt -s globstar nullglob + for file in ~/.docker/**/config.json; do + echo "## ${file}" + jq '(.auths[]?.auth) |= "REDACTED"' "$file" + echo "" + done + + scope-ghcr-repo: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - + name: Checkout + uses: actions/checkout@v6 + - + name: Login to GitHub Container Registry + uses: ./ + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + scope: 'docker/login-action@push' + - + name: Print config.json files + shell: bash + run: | + shopt -s globstar nullglob + for file in ~/.docker/**/config.json; do + echo "## ${file}" + jq '(.auths[]?.auth) |= "REDACTED"' "$file" + echo "" + done diff --git a/README.md b/README.md index b57be30..cf22ef4 100644 --- a/README.md +++ b/README.md @@ -527,8 +527,8 @@ jobs: ``` You can also use the `registry-auth` input for raw authentication to -registries, defined as YAML objects. Each object can contain `registry`, -`username`, `password` and `ecr` keys similar to current inputs: +registries, defined as YAML objects. Each object have the same attributes as +current inputs (except `logout`): > [!WARNING] > We don't recommend using this method, it's better to use the action multiple @@ -568,13 +568,13 @@ The following inputs can be used as `step.with` keys: | `registry` | String | `docker.io` | Server address of Docker registry. If not set then will default to Docker Hub | | `username` | String | | Username for authenticating to the Docker registry | | `password` | String | | Password or personal access token for authenticating the Docker registry | +| `scope` | String | | Scope for the authentication token | | `ecr` | String | `auto` | Specifies whether the given registry is ECR (`auto`, `true` or `false`) | | `logout` | Bool | `true` | Log out from the Docker registry at the end of a job | | `registry-auth` | YAML | | Raw authentication to registries, defined as YAML objects | > [!NOTE] -> The `registry-auth` input is mutually exclusive with `registry`, `username`, -> `password` and `ecr` inputs. +> The `registry-auth` input cannot be used with other inputs except `logout`. ## Contributing diff --git a/__tests__/docker.test.ts b/__tests__/docker.test.ts index e1213b0..99cd6ef 100644 --- a/__tests__/docker.test.ts +++ b/__tests__/docker.test.ts @@ -50,7 +50,7 @@ test('logout calls exec', async () => { const registry = 'https://ghcr.io'; - await logout(registry); + await logout(registry, ''); expect(execSpy).toHaveBeenCalledTimes(1); const callfunc = execSpy.mock.calls[0]; diff --git a/action.yml b/action.yml index ebd3e4b..44c1adc 100644 --- a/action.yml +++ b/action.yml @@ -19,6 +19,9 @@ inputs: ecr: description: 'Specifies whether the given registry is ECR (auto, true or false)' required: false + scope: + description: 'Scope for the authentication token' + required: false logout: description: 'Log out from the Docker registry at the end of a job' default: 'true' diff --git a/src/context.ts b/src/context.ts index c030622..96a61b1 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,21 +1,90 @@ +import path from 'path'; import * as core from '@actions/core'; +import * as yaml from 'js-yaml'; + +import {Buildx} from '@docker/actions-toolkit/lib/buildx/buildx'; +import {Util} from '@docker/actions-toolkit/lib/util'; export interface Inputs { registry: string; username: string; password: string; + scope: string; ecr: string; logout: boolean; registryAuth: string; } +export interface Auth { + registry: string; + username: string; + password: string; + scope: string; + ecr: string; + configDir: string; +} + export function getInputs(): Inputs { return { registry: core.getInput('registry'), username: core.getInput('username'), password: core.getInput('password'), + scope: core.getInput('scope'), ecr: core.getInput('ecr'), logout: core.getBooleanInput('logout'), registryAuth: core.getInput('registry-auth') }; } + +export function getAuthList(inputs: Inputs): Array { + if (inputs.registryAuth && (inputs.registry || inputs.username || inputs.password || inputs.scope || inputs.ecr)) { + throw new Error('Cannot use registry-auth with other inputs'); + } + let auths: Array = []; + if (!inputs.registryAuth) { + auths.push({ + registry: inputs.registry || 'docker.io', + username: inputs.username, + password: inputs.password, + scope: inputs.scope, + ecr: inputs.ecr || 'auto', + configDir: scopeToConfigDir(inputs.registry, inputs.scope) + }); + } else { + auths = (yaml.load(inputs.registryAuth) as Array).map(auth => { + core.setSecret(auth.password); // redacted in workflow logs + return { + registry: auth.registry || 'docker.io', + username: auth.username, + password: auth.password, + scope: auth.scope, + ecr: auth.ecr || 'auto', + configDir: scopeToConfigDir(auth.registry || 'docker.io', auth.scope) + }; + }); + } + if (auths.length == 0) { + throw new Error('No registry to login'); + } + return auths; +} + +export function scopeToConfigDir(registry: string, scope?: string): string { + if (scopeDisabled() || !scope || scope === '') { + return ''; + } + let configDir = path.join(Buildx.configDir, 'config', registry === 'docker.io' ? 'registry-1.docker.io' : registry); + if (scope.startsWith('@')) { + configDir += scope; + } else { + configDir = path.join(configDir, scope); + } + return configDir; +} + +function scopeDisabled(): boolean { + if (process.env.DOCKER_LOGIN_SCOPE_DISABLED) { + return Util.parseBool(process.env.DOCKER_LOGIN_SCOPE_DISABLED); + } + return false; +} diff --git a/src/docker.ts b/src/docker.ts index 681b2b5..fcb057b 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -1,19 +1,31 @@ -import * as aws from './aws'; import * as core from '@actions/core'; +import * as aws from './aws'; +import * as context from './context'; + import {Docker} from '@docker/actions-toolkit/lib/docker/docker'; -export async function login(registry: string, username: string, password: string, ecr: string): Promise { - if (/true/i.test(ecr) || (ecr == 'auto' && aws.isECR(registry))) { - await loginECR(registry, username, password); +export async function login(auth: context.Auth): Promise { + if (/true/i.test(auth.ecr) || (auth.ecr == 'auto' && aws.isECR(auth.registry))) { + await loginECR(auth.registry, auth.username, auth.password, auth.scope); } else { - await loginStandard(registry, username, password); + await loginStandard(auth.registry, auth.username, auth.password, auth.scope); } } -export async function logout(registry: string): Promise { +export async function logout(registry: string, configDir: string): Promise { + let envs: {[key: string]: string} | undefined; + if (configDir !== '') { + envs = Object.assign({}, process.env, { + DOCKER_CONFIG: configDir + }) as { + [key: string]: string; + }; + core.info(`Alternative config dir: ${configDir}`); + } await Docker.getExecOutput(['logout', registry], { - ignoreReturnCode: true + ignoreReturnCode: true, + env: envs }).then(res => { if (res.stderr.length > 0 && res.exitCode != 0) { core.warning(res.stderr.trim()); @@ -21,7 +33,7 @@ export async function logout(registry: string): Promise { }); } -export async function loginStandard(registry: string, username: string, password: string): Promise { +export async function loginStandard(registry: string, username: string, password: string, scope?: string): Promise { if (!username && !password) { throw new Error('Username and password required'); } @@ -31,38 +43,39 @@ export async function loginStandard(registry: string, username: string, password if (!password) { throw new Error('Password required'); } + await loginExec(registry, username, password, scope); +} - const loginArgs: Array = ['login', '--password-stdin']; - loginArgs.push('--username', username); - loginArgs.push(registry); +export async function loginECR(registry: string, username: string, password: string, scope?: string): Promise { + core.info(`Retrieving registries data through AWS SDK...`); + const regDatas = await aws.getRegistriesData(registry, username, password); + for (const regData of regDatas) { + await loginExec(regData.registry, regData.username, regData.password, scope); + } +} - core.info(`Logging into ${registry}...`); - await Docker.getExecOutput(loginArgs, { +async function loginExec(registry: string, username: string, password: string, scope?: string): Promise { + let envs: {[key: string]: string} | undefined; + const configDir = context.scopeToConfigDir(registry, scope); + if (configDir !== '') { + envs = Object.assign({}, process.env, { + DOCKER_CONFIG: configDir + }) as { + [key: string]: string; + }; + core.info(`Logging into ${registry} (scope ${scope})...`); + } else { + core.info(`Logging into ${registry}...`); + } + await Docker.getExecOutput(['login', '--password-stdin', '--username', username, registry], { ignoreReturnCode: true, silent: true, - input: Buffer.from(password) + input: Buffer.from(password), + env: envs }).then(res => { if (res.stderr.length > 0 && res.exitCode != 0) { throw new Error(res.stderr.trim()); } - core.info(`Login Succeeded!`); + core.info('Login Succeeded!'); }); } - -export async function loginECR(registry: string, username: string, password: string): Promise { - core.info(`Retrieving registries data through AWS SDK...`); - const regDatas = await aws.getRegistriesData(registry, username, password); - for (const regData of regDatas) { - core.info(`Logging into ${regData.registry}...`); - await Docker.getExecOutput(['login', '--password-stdin', '--username', regData.username, regData.registry], { - ignoreReturnCode: true, - silent: true, - input: Buffer.from(regData.password) - }).then(res => { - if (res.stderr.length > 0 && res.exitCode != 0) { - throw new Error(res.stderr.trim()); - } - core.info('Login Succeeded!'); - }); - } -} diff --git a/src/main.ts b/src/main.ts index cc0e49f..2fcbd8c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,3 @@ -import * as yaml from 'js-yaml'; import * as core from '@actions/core'; import * as actionsToolkit from '@docker/actions-toolkit'; @@ -6,48 +5,21 @@ import * as context from './context'; import * as docker from './docker'; import * as stateHelper from './state-helper'; -interface Auth { - registry: string; - username: string; - password: string; - ecr: string; -} - export async function main(): Promise { const inputs: context.Inputs = context.getInputs(); stateHelper.setLogout(inputs.logout); - if (inputs.registryAuth && (inputs.registry || inputs.username || inputs.password || inputs.ecr)) { - throw new Error('Cannot use registry-auth with other inputs'); - } + const auths = context.getAuthList(inputs); + stateHelper.setRegistries(Array.from(new Map(auths.map(auth => [`${auth.registry}|${auth.configDir}`, {registry: auth.registry, configDir: auth.configDir} as stateHelper.RegistryState])).values())); - if (!inputs.registryAuth) { - stateHelper.setRegistries([inputs.registry || 'docker.io']); - await docker.login(inputs.registry || 'docker.io', inputs.username, inputs.password, inputs.ecr || 'auto'); + if (auths.length === 1) { + await docker.login(auths[0]); return; } - const auths = yaml.load(inputs.registryAuth) as Auth[]; - if (auths.length == 0) { - throw new Error('No registry to login'); - } - - const registries: string[] = []; for (const auth of auths) { - if (!auth.registry) { - registries.push('docker.io'); - } else { - registries.push(auth.registry); - } - if (auth.password) { - core.setSecret(auth.password); - } - } - stateHelper.setRegistries(registries.filter((value, index, self) => self.indexOf(value) === index)); - - for (const auth of auths) { - await core.group(`Login to ${auth.registry || 'docker.io'}`, async () => { - await docker.login(auth.registry || 'docker.io', auth.username, auth.password, auth.ecr || 'auto'); + await core.group(`Login to ${auth.registry}`, async () => { + await docker.login(auth); }); } } @@ -56,8 +28,10 @@ async function post(): Promise { if (!stateHelper.logout) { return; } - for (const registry of stateHelper.registries.split(',')) { - await docker.logout(registry); + for (const registryState of stateHelper.registries) { + await core.group(`Logout from ${registryState.registry}`, async () => { + await docker.logout(registryState.registry, registryState.configDir); + }); } } diff --git a/src/state-helper.ts b/src/state-helper.ts index 2120881..a76e0f1 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -1,10 +1,15 @@ import * as core from '@actions/core'; -export const registries = process.env['STATE_registries'] || ''; +export const registries = process.env['STATE_registries'] ? (JSON.parse(process.env['STATE_registries']) as Array) : []; export const logout = /true/i.test(process.env['STATE_logout'] || ''); -export function setRegistries(registries: string[]) { - core.saveState('registries', registries.join(',')); +export interface RegistryState { + registry: string; + configDir: string; +} + +export function setRegistries(registries: Array) { + core.saveState('registries', JSON.stringify(registries)); } export function setLogout(logout: boolean) {