diff --git a/action.yml b/action.yml index 64b6ad32..cbc31639 100644 --- a/action.yml +++ b/action.yml @@ -105,6 +105,12 @@ inputs: required: false default: '' description: '[Orchestrator] Github private token to pull from github' + gitAuthMode: + required: false + default: 'header' + description: + '[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token + never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).' githubOwner: required: false default: '' diff --git a/src/model/build-parameters.ts b/src/model/build-parameters.ts index a338433b..e67f215a 100644 --- a/src/model/build-parameters.ts +++ b/src/model/build-parameters.ts @@ -54,6 +54,7 @@ class BuildParameters { public sshAgent!: string; public sshPublicKeysDirectoryPath!: string; public providerStrategy!: string; + public gitAuthMode!: string; public gitPrivateToken!: string; public awsStackName!: string; public awsEndpoint?: string; @@ -194,6 +195,7 @@ class BuildParameters { containerRegistryRepository: Input.containerRegistryRepository, containerRegistryImageVersion: Input.containerRegistryImageVersion, providerStrategy: OrchestratorOptions.providerStrategy, + gitAuthMode: OrchestratorOptions.gitAuthMode, buildPlatform: OrchestratorOptions.buildPlatform, kubeConfig: OrchestratorOptions.kubeConfig, containerMemory: OrchestratorOptions.containerMemory, diff --git a/src/model/orchestrator/options/orchestrator-folders-auth.test.ts b/src/model/orchestrator/options/orchestrator-folders-auth.test.ts new file mode 100644 index 00000000..5845d4dd --- /dev/null +++ b/src/model/orchestrator/options/orchestrator-folders-auth.test.ts @@ -0,0 +1,144 @@ +import { OrchestratorFolders } from './orchestrator-folders'; + +jest.mock('../orchestrator', () => ({ + __esModule: true, + default: { + buildParameters: { + orchestratorRepoName: 'game-ci/unity-builder', + githubRepo: 'myorg/myrepo', + gitPrivateToken: 'ghp_test123', + gitAuthMode: 'header', + buildGuid: 'test-guid', + projectPath: '', + buildPath: 'Builds', + cacheKey: 'test-cache', + }, + lockedWorkspace: '', + }, +})); + +jest.mock('./orchestrator-options', () => ({ + __esModule: true, + default: { + useSharedBuilder: false, + }, +})); + +jest.mock('../services/core/orchestrator-system', () => ({ + OrchestratorSystem: { + Run: jest.fn().mockResolvedValue(''), + }, +})); + +const mockOrchestrator = require('../orchestrator').default; + +describe('OrchestratorFolders git auth', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useHeaderAuth', () => { + it('should return true when gitAuthMode is header', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'header'; + expect(OrchestratorFolders.useHeaderAuth).toBe(true); + }); + + it('should return true when gitAuthMode is undefined (default)', () => { + mockOrchestrator.buildParameters.gitAuthMode = undefined; + expect(OrchestratorFolders.useHeaderAuth).toBe(true); + }); + + it('should return false when gitAuthMode is url', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'url'; + expect(OrchestratorFolders.useHeaderAuth).toBe(false); + }); + }); + + describe('unityBuilderRepoUrl', () => { + it('should not include token in URL when using header auth', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'header'; + const url = OrchestratorFolders.unityBuilderRepoUrl; + expect(url).toBe('https://github.com/game-ci/unity-builder.git'); + expect(url).not.toContain('ghp_test123'); + }); + + it('should include token in URL when using url auth (legacy)', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'url'; + const url = OrchestratorFolders.unityBuilderRepoUrl; + expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git'); + }); + }); + + describe('targetBuildRepoUrl', () => { + it('should not include token in URL when using header auth', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'header'; + const url = OrchestratorFolders.targetBuildRepoUrl; + expect(url).toBe('https://github.com/myorg/myrepo.git'); + expect(url).not.toContain('ghp_test123'); + }); + + it('should include token in URL when using url auth (legacy)', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'url'; + const url = OrchestratorFolders.targetBuildRepoUrl; + expect(url).toBe('https://ghp_test123@github.com/myorg/myrepo.git'); + }); + }); + + describe('gitAuthConfigScript', () => { + it('should emit http.extraHeader commands in header mode', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'header'; + const script = OrchestratorFolders.gitAuthConfigScript; + expect(script).toContain('http.extraHeader'); + expect(script).toContain('GIT_PRIVATE_TOKEN'); + expect(script).toContain('Authorization: Basic'); + }); + + it('should emit no-op comment in url mode', () => { + mockOrchestrator.buildParameters.gitAuthMode = 'url'; + const script = OrchestratorFolders.gitAuthConfigScript; + expect(script).toContain('legacy'); + expect(script).not.toContain('http.extraHeader'); + }); + }); + + describe('configureGitAuth', () => { + it('should run git config with http.extraHeader in header mode', async () => { + mockOrchestrator.buildParameters.gitAuthMode = 'header'; + mockOrchestrator.buildParameters.gitPrivateToken = 'ghp_test123'; + const { OrchestratorSystem } = require('../services/core/orchestrator-system'); + + await OrchestratorFolders.configureGitAuth(); + + // Verify the base64 encoding and extraHeader config are correct + const expectedEncoded = Buffer.from('x-access-token:ghp_test123').toString('base64'); + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining(expectedEncoded), + ); + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('.extraHeader'), + ); + }); + + it('should not run git config in url mode', async () => { + mockOrchestrator.buildParameters.gitAuthMode = 'url'; + const { OrchestratorSystem } = require('../services/core/orchestrator-system'); + + await OrchestratorFolders.configureGitAuth(); + + expect(OrchestratorSystem.Run).not.toHaveBeenCalled(); + }); + + it('should not run git config when no token is available', async () => { + mockOrchestrator.buildParameters.gitAuthMode = 'header'; + mockOrchestrator.buildParameters.gitPrivateToken = ''; + const originalEnv = process.env.GIT_PRIVATE_TOKEN; + delete process.env.GIT_PRIVATE_TOKEN; + const { OrchestratorSystem } = require('../services/core/orchestrator-system'); + + await OrchestratorFolders.configureGitAuth(); + + expect(OrchestratorSystem.Run).not.toHaveBeenCalled(); + if (originalEnv !== undefined) process.env.GIT_PRIVATE_TOKEN = originalEnv; + }); + }); +}); diff --git a/src/model/orchestrator/options/orchestrator-folders.ts b/src/model/orchestrator/options/orchestrator-folders.ts index eebbbe2d..2dc6d9d7 100644 --- a/src/model/orchestrator/options/orchestrator-folders.ts +++ b/src/model/orchestrator/options/orchestrator-folders.ts @@ -72,14 +72,67 @@ export class OrchestratorFolders { return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`); } + /** + * Whether to use http.extraHeader for git authentication (secure, default) + * instead of embedding the token in clone URLs (legacy). + */ + public static get useHeaderAuth(): boolean { + return Orchestrator.buildParameters.gitAuthMode !== 'url'; + } + public static get unityBuilderRepoUrl(): string { + if (OrchestratorFolders.useHeaderAuth) { + return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`; + } + return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`; } public static get targetBuildRepoUrl(): string { + if (OrchestratorFolders.useHeaderAuth) { + return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`; + } + return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`; } + /** + * Shell commands to configure git authentication via http.extraHeader. + * Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output. + * This is the same mechanism used by actions/checkout. + * + * Only emits commands when gitAuthMode is 'header' (default). In 'url' mode, + * returns a no-op comment since the token is already in the URL. + */ + public static get gitAuthConfigScript(): string { + if (!OrchestratorFolders.useHeaderAuth) { + return `# git auth: using token-in-URL mode (legacy)`; + } + + return `# git auth: configuring http.extraHeader (secure mode) +if [ -n "$GIT_PRIVATE_TOKEN" ]; then + git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)" +fi`; + } + + /** + * Configure git authentication via http.extraHeader in the current Node process. + * For use in the remote-client where shell scripts aren't used. + * Only configures when gitAuthMode is 'header' (default). + */ + public static async configureGitAuth(): Promise { + if (!OrchestratorFolders.useHeaderAuth) return; + + const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || ''; + if (!token) return; + + const encoded = Buffer.from(`x-access-token:${token}`).toString('base64'); + const { OrchestratorSystem } = await import('../services/core/orchestrator-system'); + await OrchestratorSystem.Run( + `git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`, + ); + } + public static get buildVolumeFolder() { return 'data'; } diff --git a/src/model/orchestrator/options/orchestrator-options.ts b/src/model/orchestrator/options/orchestrator-options.ts index e68160cc..175d2957 100644 --- a/src/model/orchestrator/options/orchestrator-options.ts +++ b/src/model/orchestrator/options/orchestrator-options.ts @@ -138,6 +138,10 @@ class OrchestratorOptions { return provider || 'local'; } + static get gitAuthMode(): string { + return OrchestratorOptions.getInput('gitAuthMode') || 'header'; + } + static get containerCpu(): string { return OrchestratorOptions.getInput('containerCpu') || `1024`; } diff --git a/src/model/orchestrator/remote-client/index.ts b/src/model/orchestrator/remote-client/index.ts index 62d9124d..1d99809d 100644 --- a/src/model/orchestrator/remote-client/index.ts +++ b/src/model/orchestrator/remote-client/index.ts @@ -302,6 +302,7 @@ export class RemoteClient { RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`); await OrchestratorSystem.Run(`git config --global advice.detachedHead false`); + await OrchestratorFolders.configureGitAuth(); RemoteClientLogger.log(`Cloning the repository being built:`); await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`); await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`); @@ -411,12 +412,7 @@ export class RemoteClient { const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN; if (gitPrivateToken) { RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`); - await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`); - await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`); - await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`); - await OrchestratorSystem.Run( - `git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`, - ); + await RemoteClient.configureTokenAuth(gitPrivateToken); await OrchestratorSystem.Run(`git lfs pull`, true); await OrchestratorSystem.Run(`git lfs checkout || true`, true); RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`); @@ -432,12 +428,7 @@ export class RemoteClient { const githubToken = process.env.GITHUB_TOKEN; if (githubToken) { RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`); - await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`); - await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`); - await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`); - await OrchestratorSystem.Run( - `git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`, - ); + await RemoteClient.configureTokenAuth(githubToken); await OrchestratorSystem.Run(`git lfs pull`, true); await OrchestratorSystem.Run(`git lfs checkout || true`, true); RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`); @@ -501,4 +492,25 @@ export class RemoteClient { return false; } + + /** + * Configure git authentication for a token. In header mode (default), uses + * http.extraHeader so the token never appears in URLs or git config output. + * In url mode (legacy), uses url.insteadOf to embed the token in URLs. + */ + private static async configureTokenAuth(token: string): Promise { + if (OrchestratorFolders.useHeaderAuth) { + const encoded = Buffer.from(`x-access-token:${token}`).toString('base64'); + await OrchestratorSystem.Run( + `git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`, + ); + } else { + await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`); + await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`); + await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`); + await OrchestratorSystem.Run( + `git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`, + ); + } + } } diff --git a/src/model/orchestrator/workflows/async-workflow.ts b/src/model/orchestrator/workflows/async-workflow.ts index 0f9c4f87..02495b22 100644 --- a/src/model/orchestrator/workflows/async-workflow.ts +++ b/src/model/orchestrator/workflows/async-workflow.ts @@ -27,6 +27,7 @@ printenv git config --global advice.detachedHead false git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f" git config --global filter.lfs.process "git-lfs filter-process --skip" +${OrchestratorFolders.gitAuthConfigScript} BRANCH="${Orchestrator.buildParameters.orchestratorBranch}" REPO="${OrchestratorFolders.unityBuilderRepoUrl}" if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then diff --git a/src/model/orchestrator/workflows/build-automation-workflow.ts b/src/model/orchestrator/workflows/build-automation-workflow.ts index cbd642fb..3663940e 100644 --- a/src/model/orchestrator/workflows/build-automation-workflow.ts +++ b/src/model/orchestrator/workflows/build-automation-workflow.ts @@ -92,6 +92,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface { const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder( OrchestratorFolders.builderPathAbsolute, )} +${OrchestratorFolders.gitAuthConfigScript} BRANCH="${Orchestrator.buildParameters.orchestratorBranch}" REPO="${OrchestratorFolders.unityBuilderRepoUrl}" DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"