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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/actions/docker-auth-setup/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: docker-auth-setup
description: >
Isolate a job-scoped Docker config and optionally authenticate to Docker Hub.
Credential predicates and secret mapping stay in the calling workflow YAML.

runs:
using: composite
steps:
- name: Authenticate to Docker Hub
shell: bash
run: bash "${{ github.action_path }}/setup.sh"
38 changes: 38 additions & 0 deletions .github/actions/docker-auth-setup/setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

set -euo pipefail

docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")"
chmod 700 "${docker_config}"
export DOCKER_CONFIG="${docker_config}"
printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}"

if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then
echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls."
exit 0
fi
if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then
echo "::error::Docker Hub credentials are required for trusted runs."
exit 1
fi

auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"
: > "${auth_marker}"
chmod 600 "${auth_marker}"
login_succeeded=0
for attempt in 1 2 3; do
if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then
login_succeeded=1
break
fi
if [[ "${attempt}" -lt 3 ]]; then
echo "::warning::Docker Hub login attempt ${attempt} failed; retrying."
sleep 5
fi
done
if [[ "${login_succeeded}" -ne 1 ]]; then
echo "::error::Docker Hub login failed after 3 attempts."
exit 1
fi
36 changes: 1 addition & 35 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,45 +209,11 @@ jobs:
# explicit because strict YAML decoders reject 100 or more aliases here.
- &dockerhub-auth
name: Authenticate to Docker Hub
uses: NVIDIA/NemoClaw/.github/actions/docker-auth-setup@66cf71b3af6c32ab972a1ce0e505499da0b495f2
env:
DOCKERHUB_AUTH_REQUIRED: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '1' || '0' }}
DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_USERNAME || '' }}
DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_TOKEN || '' }}
shell: bash
run: |
set -euo pipefail
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")"
chmod 700 "${docker_config}"
export DOCKER_CONFIG="${docker_config}"
printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}"

if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then
echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls."
exit 0
fi
if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then
echo "::error::Docker Hub credentials are required for trusted E2E runs."
exit 1
fi

auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"
: > "${auth_marker}"
chmod 600 "${auth_marker}"
login_succeeded=0
for attempt in 1 2 3; do
if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then
login_succeeded=1
break
fi
if [[ "${attempt}" -lt 3 ]]; then
echo "::warning::Docker Hub login attempt ${attempt} failed; retrying."
sleep 5
fi
done
if [[ "${login_succeeded}" -ne 1 ]]; then
echo "::error::Docker Hub login failed after 3 attempts."
exit 1
fi

- name: Configure live E2E trace directory
env:
Expand Down
36 changes: 1 addition & 35 deletions .github/workflows/sandbox-images-and-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,45 +42,11 @@ jobs:
# receive Docker Hub credentials (this workflow has no schedule trigger).
- &dockerhub-auth
name: Authenticate to Docker Hub
uses: NVIDIA/NemoClaw/.github/actions/docker-auth-setup@66cf71b3af6c32ab972a1ce0e505499da0b495f2
env:
DOCKERHUB_AUTH_REQUIRED: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '1' || '0' }}
DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_USERNAME || '' }}
DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_TOKEN || '' }}
shell: bash
run: |
set -euo pipefail
docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")"
chmod 700 "${docker_config}"
export DOCKER_CONFIG="${docker_config}"
printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}"

if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then
echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls."
exit 0
fi
if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then
echo "::error::Docker Hub credentials are required for trusted image builds."
exit 1
fi

auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"
: > "${auth_marker}"
chmod 600 "${auth_marker}"
login_succeeded=0
for attempt in 1 2 3; do
if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then
login_succeeded=1
break
fi
if [[ "${attempt}" -lt 3 ]]; then
echo "::warning::Docker Hub login attempt ${attempt} failed; retrying."
sleep 5
fi
done
if [[ "${login_succeeded}" -ne 1 ]]; then
echo "::error::Docker Hub login failed after 3 attempts."
exit 1
fi

- name: Resolve sandbox base image
uses: ./.github/actions/resolve-sandbox-base-image
Expand Down
60 changes: 29 additions & 31 deletions test/e2e/support/dockerhub-auth-workflow-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { fileURLToPath } from "node:url";

import { describe, expect, it } from "vitest";
import YAML from "yaml";

import {
CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION,
DOCKER_AUTH_SETUP_ACTION,
validateDockerAuthSetupAction,
} from "../../../tools/e2e/docker-auth-setup-workflow-boundary.mts";
import { validateE2eWorkflowBoundary } from "../../../tools/e2e/workflow-boundary.mts";
import { readWorkflow } from "../../helpers/e2e-workflow-contract";

Expand All @@ -19,6 +23,13 @@ const CLEANUP_STEP_NAME = "Clean up Docker auth";
const CLEANUP_HELPER_RUN = "bash .github/scripts/docker-auth-cleanup.sh";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const CLEANUP_HELPER_PATH = path.join(REPO_ROOT, ".github", "scripts", "docker-auth-cleanup.sh");
const SETUP_HELPER_PATH = path.join(
REPO_ROOT,
".github",
"actions",
"docker-auth-setup",
"setup.sh",
);

type WorkflowStep = Record<string, unknown> & {
env?: Record<string, unknown>;
Expand Down Expand Up @@ -146,7 +157,7 @@ describe("shared Docker Hub authentication workflow boundary", () => {
);
});

it("rejects trust, isolation, retry, password, and cleanup mapping drift", () => {
it("rejects trust, isolation, pin, and cleanup mapping drift", () => {
const errors = validateMutation((workflow) => {
const auth = namedStep(workflow.jobs.live, AUTH_STEP_NAME);
const cleanup = namedStep(workflow.jobs.live, CLEANUP_STEP_NAME);
Expand All @@ -158,20 +169,9 @@ describe("shared Docker Hub authentication workflow boundary", () => {
...auth!.env,
DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}",
};
auth!.run = String(auth!.run)
.replace(
"${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX",
"${GITHUB_WORKSPACE}/docker-config",
)
.replace("for attempt in 1 2 3; do", "for attempt in 1 2; do")
.replace(
'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"',
'auth_marker="${GITHUB_WORKSPACE}/login-attempted"',
)
.replace(': > "${auth_marker}"', 'touch "${auth_marker}"')
.replace('chmod 600 "${auth_marker}"', 'chmod 644 "${auth_marker}"')
.replace("--password-stdin", '--password "${DOCKERHUB_TOKEN}"')
.replaceAll("exit 1", "exit 0");
auth!.uses = CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION;
auth!.run = "echo inline login";
auth!.shell = "bash";

cleanup!.if = "success()";
cleanup!.run = `${String(cleanup!.run)} || true`;
Expand All @@ -189,17 +189,10 @@ describe("shared Docker Hub authentication workflow boundary", () => {
expect.arrayContaining([
"canonical Docker Hub auth step must always run so untrusted refs receive an isolated empty Docker config",
"canonical Docker Hub auth must gate DOCKERHUB_USERNAME on the trusted repository, main ref, and scheduled/manual events",
'canonical Docker Hub auth run script must include mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"',
"canonical Docker Hub auth directory must not use the checkout workspace",
"canonical Docker Hub auth run script must include for attempt in 1 2 3; do",
'canonical Docker Hub auth run script must include auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"',
'canonical Docker Hub auth run script must include : > "${auth_marker}"',
'canonical Docker Hub auth run script must include chmod 600 "${auth_marker}"',
"canonical Docker Hub auth must create and protect its login-attempt marker after trusted credential validation and before login",
"canonical Docker Hub auth run script must include --password-stdin",
"canonical Docker Hub auth must pass the token only through --password-stdin",
"canonical Docker Hub auth must fail when trusted credentials are missing",
"canonical Docker Hub auth must fail after exhausting login retries",
"canonical Docker Hub auth must not load docker-auth-setup from the target checkout",
"canonical Docker Hub auth must use the reviewed immutable docker-auth-setup action",
"canonical Docker Hub auth step must not embed an inline login script; use the pinned docker-auth-setup action",
"canonical Docker Hub auth step must contain exactly name, uses, and env",
"live Docker Hub cleanup step must contain exactly name, if, shell, and run",
"live Docker Hub cleanup step must always run",
`live Docker Hub cleanup step must run only ${CLEANUP_HELPER_RUN}`,
Expand Down Expand Up @@ -245,9 +238,14 @@ describe("shared Docker Hub authentication workflow boundary", () => {
);
});

it("keeps the pinned docker-auth-setup action and setup script under provenance review", () => {
expect(validateDockerAuthSetupAction()).toEqual([]);
expect(namedStep(loadWorkflow().jobs.live, AUTH_STEP_NAME)?.uses).toBe(
DOCKER_AUTH_SETUP_ACTION,
);
});

it("executes the shared auth script with isolated config and bounded fail-closed retries", () => {
const workflow = loadWorkflow();
const authScript = String(namedStep(workflow.jobs.live, AUTH_STEP_NAME)?.run ?? "");
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-auth-script-"));
const fakeBin = path.join(directory, "bin");
const runnerTemp = path.join(directory, "runner-temp");
Expand Down Expand Up @@ -284,7 +282,7 @@ fi
fs.rmSync(callsPath, { force: true });
fs.rmSync(tokensPath, { force: true });
fs.rmSync(githubEnv, { force: true });
return spawnSync("bash", ["-c", authScript], {
return spawnSync("bash", [SETUP_HELPER_PATH], {
encoding: "utf8",
env: {
...process.env,
Expand Down Expand Up @@ -349,7 +347,7 @@ fi
expect(missing.status).toBe(1);
expect(fs.existsSync(callsPath)).toBe(false);
expect(`${missing.stdout}${missing.stderr}`).toContain(
"Docker Hub credentials are required for trusted E2E runs",
"Docker Hub credentials are required for trusted runs",
);
} finally {
fs.rmSync(directory, { force: true, recursive: true });
Expand Down
6 changes: 4 additions & 2 deletions test/e2e/support/hermes-secret-boundary-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,17 @@ describe("Hermes image workflow secret boundary", () => {
...auth!.env,
DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}",
};
auth!.run = auth!.run?.replaceAll("exit 1", "exit 0");
auth!.run = "echo inline login";
auth!.shell = "bash";
mainWorkflow.jobs["sandbox-images-and-e2e"].secrets = {
inherit: true,
};

expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual(
expect.arrayContaining([
"sandbox image Docker Hub credentials must be gated to trusted main push/manual runs",
"sandbox image Docker Hub auth must fail closed on missing credentials and retries",
"sandbox image Docker Hub auth step must expose only name, uses, and env",
"sandbox image Docker Hub auth must not embed an inline login script; use the pinned docker-auth-setup action",
"main sandbox image caller must map only the optional Docker Hub secrets explicitly",
]),
);
Expand Down
7 changes: 7 additions & 0 deletions test/helpers/vitest-watch-triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [
pattern: /(?:^|\/)test\/e2e\/docs\/parity-inventory\.generated\.json$/,
testsToRun: runTests("test/e2e/support/e2e-migration-policy.test.ts"),
},
{
pattern: /(?:^|\/)\.github\/actions\/docker-auth-setup\/(?:action\.yaml|setup\.sh)$/,
testsToRun: runTests(
"test/e2e/support/dockerhub-auth-workflow-boundary.test.ts",
"test/e2e/support/sandbox-images-workflow-boundary.test.ts",
),
},
{
pattern: /(?:^|\/)\.github\/workflows\/e2e\.yaml$/,
testsToRun: runTests(...E2E_WORKFLOW_CONTRACTS),
Expand Down
2 changes: 2 additions & 0 deletions test/pr-risk-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ describe("deterministic PR risk plan", () => {
".github/workflows/pr.yaml",
".github/actions/prepare-e2e/action.yaml",
".github/actions/upload-e2e-artifacts/action.yaml",
".github/actions/docker-auth-setup/action.yaml",
".github/actions/docker-auth-setup/setup.sh",
"package-lock.json",
"package.json",
"vitest.config.ts",
Expand Down
10 changes: 10 additions & 0 deletions test/vitest-watch-triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const OPAQUE_INPUTS = [
".github/workflows/pr-e2e-gate.yaml",
".github/workflows/platform-vitest-main.yaml",
"ci/platform-vitest-macos-requirements.lock",
".github/actions/docker-auth-setup/action.yaml",
".github/actions/docker-auth-setup/setup.sh",
] as const;

function triggeredBy(relativePath: string): string[] {
Expand Down Expand Up @@ -112,6 +114,14 @@ describe("Vitest opaque-input watch triggers", () => {
expect(triggeredBy("ci/platform-vitest-macos-requirements.lock")).toEqual([
"test/platform-vitest-main-workflow.test.ts",
]);
expect(triggeredBy(".github/actions/docker-auth-setup/action.yaml")).toEqual([
"test/e2e/support/dockerhub-auth-workflow-boundary.test.ts",
"test/e2e/support/sandbox-images-workflow-boundary.test.ts",
]);
expect(triggeredBy(".github/actions/docker-auth-setup/setup.sh")).toEqual([
"test/e2e/support/dockerhub-auth-workflow-boundary.test.ts",
"test/e2e/support/sandbox-images-workflow-boundary.test.ts",
]);
});

it("returns only concrete test files that exist (#6692)", () => {
Expand Down
2 changes: 1 addition & 1 deletion tools/advisors/e2e-recommendations.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const CLOUD_ONBOARD_E2E_PATTERNS: readonly RegExp[] = [
/^scripts\/scorecard\/analyze-trace-timing\.ts$/,
/^ci\/onboard-performance-budget\.json$/,
/^scripts\/e2e\/sanitize-trace-timing\.py$/,
/^\.github\/actions\/(?:prepare-e2e|upload-e2e-artifacts)\//,
/^\.github\/actions\/(?:prepare-e2e|upload-e2e-artifacts|docker-auth-setup)\//,
/^\.github\/workflows\/e2e\.yaml$/,
/^test\/e2e\/live\/cloud-onboard\.test\.ts$/,
];
Expand Down
4 changes: 3 additions & 1 deletion tools/advisors/risk-plan.mts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export const RISK_RULES: readonly RiskRule[] = [
file === "ci/platform-matrix.json" ||
file === ".github/workflows/e2e.yaml" ||
file.startsWith(".github/actions/prepare-e2e/") ||
file.startsWith(".github/actions/docker-auth-setup/") ||
file === "src/lib/trace.ts" ||
file === "scripts/scorecard/analyze-trace-timing.mts" ||
file === "scripts/e2e/sanitize-trace-timing.py" ||
Expand Down Expand Up @@ -276,7 +277,8 @@ export const RISK_RULES: readonly RiskRule[] = [
file.startsWith("tools/e2e/") ||
file.startsWith("test/e2e/") ||
file.startsWith(".github/actions/prepare-e2e/") ||
file.startsWith(".github/actions/upload-e2e-artifacts/"),
file.startsWith(".github/actions/upload-e2e-artifacts/") ||
file.startsWith(".github/actions/docker-auth-setup/"),
},
{
id: "sandbox-boundary",
Expand Down
Loading
Loading