diff --git a/packages/cli/src/commands/upload.ts b/packages/cli/src/commands/upload.ts index d925dcc6..9d697e2f 100644 --- a/packages/cli/src/commands/upload.ts +++ b/packages/cli/src/commands/upload.ts @@ -5,14 +5,17 @@ import ora from "ora"; import { buildNameOption, parallelNonceOption, + projectOption, tokenOption, type BuildNameOption, type ParallelNonceOption, + type ProjectOption, type TokenOption, } from "../options"; type UploadOptions = BuildNameOption & ParallelNonceOption & + ProjectOption & TokenOption & { files?: string[] | undefined; ignore?: string[] | undefined; @@ -41,6 +44,7 @@ export function uploadCommand(program: Command) { 'One or more globs matching image file paths to ignore (ex: "**/*.png **/diff.jpg")', ) .addOption(tokenOption) + .addOption(projectOption) .addOption(buildNameOption) .addOption( new Option( @@ -117,6 +121,7 @@ export function uploadCommand(program: Command) { })(); const result = await upload({ token: options.token, + project: options.project, root: directory, buildName: options.buildName, files: options.files, diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 658e9f52..5bf8ac9c 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -12,6 +12,12 @@ export const tokenOption = new Option( "Repository token", ).env("ARGOS_TOKEN"); +export type ProjectOption = { project?: string | undefined }; +export const projectOption = new Option( + "--project ", + "Argos project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository", +).env("ARGOS_PROJECT"); + export type BuildNameOption = { buildName?: string | undefined }; export const buildNameOption = new Option( "--build-name ", diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 22481d09..15ceb73b 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -17,6 +17,7 @@ const base64Decode = (str: string): unknown => const baseConfig: Config = { apiBaseUrl: "https://api.argos-ci.com/v2/", token: null, + project: null, commit: "abc123def456abc123def456abc123def456abc1", branch: "main", buildName: null, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5481417a..476e435b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -126,6 +126,12 @@ const schema = { default: null, format: mustBeArgosToken, }, + project: { + env: "ARGOS_PROJECT", + default: null, + format: String, + nullable: true, + }, buildName: { env: "ARGOS_BUILD_NAME", default: null, @@ -279,6 +285,14 @@ export interface Config { */ token: string | null; + /** + * Argos project slug used for tokenless authentication. + * Useful to disambiguate when multiple Argos projects are linked + * to the same repository. + * @example "my-org/my-project" + */ + project: string | null; + /** * Custom build name. * Useful for multi-build setups on the same commit. @@ -433,6 +447,7 @@ export async function readConfig(options: Partial = {}) { commit: options.commit || defaultConfig.commit || ciEnv?.commit || null, branch: options.branch || defaultConfig.branch || ciEnv?.branch || null, token: options.token || defaultConfig.token || null, + project: options.project || defaultConfig.project || null, buildName: options.buildName || defaultConfig.buildName || null, prNumber: options.prNumber || defaultConfig.prNumber || ciEnv?.prNumber || null, diff --git a/packages/core/src/github-actions-tokenless.test.ts b/packages/core/src/github-actions-tokenless.test.ts index a65254e8..766a2a25 100644 --- a/packages/core/src/github-actions-tokenless.test.ts +++ b/packages/core/src/github-actions-tokenless.test.ts @@ -21,6 +21,7 @@ describe("exchangeGitHubActionsTokenlessToken", () => { prHeadCommit: null, commit: "abc123def456abc123def456abc123def456abc1", branch: "main", + project: null, }; it("builds the tokenless bearer token and exchanges it for an Argos token", async () => { @@ -108,6 +109,60 @@ describe("exchangeGitHubActionsTokenlessToken", () => { }); }); + it("includes the project slug in the bearer token when set", async () => { + let capturedBody: Record = {}; + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + token: MOCK_TOKENLESS_ARGOS_TOKEN, + expiresAt: MOCK_EXPIRES_AT, + }); + }, + ), + ); + + await exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: { ...baseConfig, project: "acme/web-app" }, + }); + + const bearer = capturedBody.tokenlessToken as string; + const payload = base64Decode(bearer.replace("tokenless-github-", "")) as { + project?: string; + }; + expect(payload.project).toBe("acme/web-app"); + }); + + it("omits the project slug from the bearer token when null", async () => { + let capturedBody: Record = {}; + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + token: MOCK_TOKENLESS_ARGOS_TOKEN, + expiresAt: MOCK_EXPIRES_AT, + }); + }, + ), + ); + + await exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: baseConfig, + }); + + const bearer = capturedBody.tokenlessToken as string; + const payload = base64Decode(bearer.replace("tokenless-github-", "")) as { + project?: string; + }; + expect(payload.project).toBeUndefined(); + }); + it("omits prNumber from the bearer token when null", async () => { let capturedBody: Record = {}; server.use( diff --git a/packages/core/src/github-actions-tokenless.ts b/packages/core/src/github-actions-tokenless.ts index 6294c621..712e5b7f 100644 --- a/packages/core/src/github-actions-tokenless.ts +++ b/packages/core/src/github-actions-tokenless.ts @@ -17,9 +17,18 @@ export function isGitHubActionsTokenlessAvailable( * Build a tokenless GitHub Actions bearer token from the CI environment. */ function getTokenlessBearerToken( - config: Pick, + config: Pick< + Config, + "originalRepository" | "jobId" | "runId" | "prNumber" | "project" + >, ): string { - const { originalRepository: repository, jobId, runId, prNumber } = config; + const { + originalRepository: repository, + jobId, + runId, + prNumber, + project, + } = config; if (!repository || !jobId || !runId) { throw new Error( @@ -35,6 +44,7 @@ function getTokenlessBearerToken( jobId, runId, prNumber: prNumber ?? undefined, + project: project ?? undefined, })}`; } @@ -52,6 +62,7 @@ export async function exchangeGitHubActionsTokenlessToken(args: { | "branch" | "prHeadCommit" | "commit" + | "project" >; }): Promise { const { apiBaseUrl, config } = args; diff --git a/packages/core/src/upload.ts b/packages/core/src/upload.ts index e5cc4625..1f71f40a 100644 --- a/packages/core/src/upload.ts +++ b/packages/core/src/upload.ts @@ -71,6 +71,13 @@ export interface UploadParameters { */ token?: string; + /** + * Argos project slug (`account/project`). + * Used to disambiguate tokenless authentication when multiple + * Argos projects are linked to the same repository. + */ + project?: string; + /** * Pull request number associated with the build. */