From 8fb002f7bc809dcf3a7e596efb89b60da3bdf5e0 Mon Sep 17 00:00:00 2001 From: Adrian M Date: Thu, 28 May 2026 13:52:15 -0600 Subject: [PATCH] feat(cli): auto-enrich release descriptions with CI metadata When running inside a supported CI provider (GitHub Actions, GitLab CI, CircleCI, Jenkins), release/promote/patch now annotate their description with a parseable metadata bracket: [ci=github sha=abc1234 branch=main pr=42 run=]. A user-provided --description is preserved and the bracket is appended after a blank line; an empty description is replaced by the bracket alone. Detection runs in priority order (github > gitlab > circleci > jenkins) off provider-specific env vars, not the universal CI=true, so behavior in act and other generic runners stays unchanged. Pass --no-ci-metadata to opt out. Useful for local emulators or teams that generate release notes from another source. --- __tests__/command-executor.test.ts | 130 ++++++++++++++- __tests__/command-parser.test.ts | 17 ++ __tests__/utils/ci-metadata.test.ts | 247 ++++++++++++++++++++++++++++ script/command-executor.ts | 10 ++ script/command-parser.ts | 11 ++ script/types/cli.ts | 1 + script/utils/ci-metadata.ts | 134 +++++++++++++++ 7 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 __tests__/utils/ci-metadata.test.ts create mode 100644 script/utils/ci-metadata.ts diff --git a/__tests__/command-executor.test.ts b/__tests__/command-executor.test.ts index 83a9c84..38dc79f 100644 --- a/__tests__/command-executor.test.ts +++ b/__tests__/command-executor.test.ts @@ -91,14 +91,43 @@ describe("command-executor", () => { let mkdirSyncSpy: jest.SpyInstance; let writeFileSyncSpy: jest.SpyInstance; let fetchSpy: jest.SpyInstance; - let savedCI: string | undefined; + + const CI_ENV_VARS = [ + "CI", + "GITHUB_ACTIONS", + "GITHUB_SHA", + "GITHUB_REF", + "GITHUB_REF_NAME", + "GITHUB_SERVER_URL", + "GITHUB_REPOSITORY", + "GITHUB_RUN_ID", + "GITLAB_CI", + "CI_COMMIT_SHA", + "CI_COMMIT_REF_NAME", + "CI_MERGE_REQUEST_IID", + "CI_JOB_URL", + "CIRCLECI", + "CIRCLE_SHA1", + "CIRCLE_BRANCH", + "CIRCLE_PR_NUMBER", + "CIRCLE_BUILD_URL", + "JENKINS_URL", + "GIT_COMMIT", + "GIT_BRANCH", + "CHANGE_ID", + "BUILD_URL", + ]; + let savedCiEnv: Record; beforeEach(() => { resetSdkMocks(); mockPromptGet.mockReset(); - savedCI = process.env.CI; - delete process.env.CI; + savedCiEnv = {}; + for (const key of CI_ENV_VARS) { + savedCiEnv[key] = process.env[key]; + delete process.env[key]; + } consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => undefined); consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); @@ -120,10 +149,12 @@ describe("command-executor", () => { jest.restoreAllMocks(); executor.sdk = null; - if (savedCI === undefined) { - delete process.env.CI; - } else { - process.env.CI = savedCI; + for (const key of CI_ENV_VARS) { + if (savedCiEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedCiEnv[key]; + } } }); @@ -1146,4 +1177,89 @@ describe("command-executor", () => { expect(mockPromptGet).not.toHaveBeenCalled(); }); }); + + describe("ci metadata enrichment", () => { + beforeEach(() => { + executor.sdk = mockSdkMethods; + mockSdkMethods.promote.mockResolvedValue(undefined); + mockSdkMethods.patchRelease.mockResolvedValue(undefined); + }); + + function setGithubEnv(): void { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234567"; + process.env.GITHUB_REF_NAME = "main"; + } + + it("appends CI metadata to a user-provided description on promote", async () => { + setGithubEnv(); + await executor.execute({ + type: cli.CommandType.promote, + appName: "MyApp", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: "Fix auth token timeout", + }); + + expect(mockSdkMethods.promote).toHaveBeenCalledTimes(1); + const packageInfo = mockSdkMethods.promote.mock.calls[0][3]; + expect(packageInfo.description).toBe("Fix auth token timeout\n\n[ci=github sha=abc1234 branch=main]"); + }); + + it("uses CI metadata as the description when none is provided", async () => { + setGithubEnv(); + await executor.execute({ + type: cli.CommandType.promote, + appName: "MyApp", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + }); + + const packageInfo = mockSdkMethods.promote.mock.calls[0][3]; + expect(packageInfo.description).toBe("[ci=github sha=abc1234 branch=main]"); + }); + + it("skips enrichment when ciMetadata is false", async () => { + setGithubEnv(); + await executor.execute({ + type: cli.CommandType.promote, + appName: "MyApp", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: "Fix auth token timeout", + ciMetadata: false, + }); + + const packageInfo = mockSdkMethods.promote.mock.calls[0][3]; + expect(packageInfo.description).toBe("Fix auth token timeout"); + }); + + it("leaves the description untouched when no CI provider is detected", async () => { + await executor.execute({ + type: cli.CommandType.promote, + appName: "MyApp", + sourceDeploymentName: "Staging", + destDeploymentName: "Production", + description: "Fix auth token timeout", + }); + + const packageInfo = mockSdkMethods.promote.mock.calls[0][3]; + expect(packageInfo.description).toBe("Fix auth token timeout"); + }); + + it("enriches a patch command as well", async () => { + setGithubEnv(); + await executor.execute({ + type: cli.CommandType.patch, + appName: "MyApp", + deploymentName: "Production", + label: "v3", + description: "Bumped rollout", + }); + + expect(mockSdkMethods.patchRelease).toHaveBeenCalledTimes(1); + const packageInfo = mockSdkMethods.patchRelease.mock.calls[0][3]; + expect(packageInfo.description).toBe("Bumped rollout\n\n[ci=github sha=abc1234 branch=main]"); + }); + }); }); diff --git a/__tests__/command-parser.test.ts b/__tests__/command-parser.test.ts index bf9148c..e301653 100644 --- a/__tests__/command-parser.test.ts +++ b/__tests__/command-parser.test.ts @@ -881,4 +881,21 @@ describe("command-parser", () => { expect(cmd.force).toBe(true); }); }); + + describe("ci-metadata flag", () => { + it("leaves ciMetadata undefined when the flag is absent", () => { + const cmd = parseArgs(["app", "ls"]); + expect(cmd.ciMetadata).toBeUndefined(); + }); + + it("sets ciMetadata true with --ci-metadata", () => { + const cmd = parseArgs(["app", "ls", "--ci-metadata"]); + expect(cmd.ciMetadata).toBe(true); + }); + + it("sets ciMetadata false with --no-ci-metadata", () => { + const cmd = parseArgs(["app", "ls", "--no-ci-metadata"]); + expect(cmd.ciMetadata).toBe(false); + }); + }); }); diff --git a/__tests__/utils/ci-metadata.test.ts b/__tests__/utils/ci-metadata.test.ts new file mode 100644 index 0000000..0a0f915 --- /dev/null +++ b/__tests__/utils/ci-metadata.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) Aether. All rights reserved. + +import { detectCiMetadata, formatCiMetadata, enrichDescriptionWithCiMetadata, CiMetadata } from "../../script/utils/ci-metadata"; +import * as cli from "../../script/types/cli"; + +const CI_ENV_VARS = [ + "CI", + "GITHUB_ACTIONS", + "GITHUB_SHA", + "GITHUB_REF", + "GITHUB_REF_NAME", + "GITHUB_SERVER_URL", + "GITHUB_REPOSITORY", + "GITHUB_RUN_ID", + "GITLAB_CI", + "CI_COMMIT_SHA", + "CI_COMMIT_REF_NAME", + "CI_MERGE_REQUEST_IID", + "CI_JOB_URL", + "CIRCLECI", + "CIRCLE_SHA1", + "CIRCLE_BRANCH", + "CIRCLE_PR_NUMBER", + "CIRCLE_BUILD_URL", + "JENKINS_URL", + "GIT_COMMIT", + "GIT_BRANCH", + "CHANGE_ID", + "BUILD_URL", +]; + +describe("ci-metadata", () => { + let savedCiEnv: Record; + + beforeEach(() => { + savedCiEnv = {}; + for (const key of CI_ENV_VARS) { + savedCiEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of CI_ENV_VARS) { + if (savedCiEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedCiEnv[key]; + } + } + }); + + describe("detectCiMetadata", () => { + it("returns null when no provider is detected", () => { + expect(detectCiMetadata()).toBeNull(); + }); + + it("ignores a bare CI=true with no provider env vars", () => { + process.env.CI = "true"; + expect(detectCiMetadata()).toBeNull(); + }); + + it("detects GitHub Actions with full metadata", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234567890def"; + process.env.GITHUB_REF_NAME = "main"; + process.env.GITHUB_REF = "refs/pull/42/merge"; + process.env.GITHUB_SERVER_URL = "https://github.com"; + process.env.GITHUB_REPOSITORY = "Monoradioactivo/aether-cli"; + process.env.GITHUB_RUN_ID = "9999"; + + expect(detectCiMetadata()).toEqual({ + provider: "github", + sha: "abc1234", + branch: "main", + pr: "42", + run: "https://github.com/Monoradioactivo/aether-cli/actions/runs/9999", + }); + }); + + it("returns no PR for a GitHub Actions push event", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234567890"; + process.env.GITHUB_REF = "refs/heads/main"; + process.env.GITHUB_REF_NAME = "main"; + + const meta = detectCiMetadata(); + expect(meta?.provider).toBe("github"); + expect(meta?.pr).toBeUndefined(); + }); + + it("returns no run URL when GitHub run inputs are partial", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234"; + process.env.GITHUB_REPOSITORY = "owner/repo"; + + const meta = detectCiMetadata(); + expect(meta?.run).toBeUndefined(); + }); + + it("detects GitLab CI with merge request metadata", () => { + process.env.GITLAB_CI = "true"; + process.env.CI_COMMIT_SHA = "fedcba9876543210"; + process.env.CI_COMMIT_REF_NAME = "feature/x"; + process.env.CI_MERGE_REQUEST_IID = "7"; + process.env.CI_JOB_URL = "https://gitlab.com/group/proj/-/jobs/123"; + + expect(detectCiMetadata()).toEqual({ + provider: "gitlab", + sha: "fedcba9", + branch: "feature/x", + pr: "7", + run: "https://gitlab.com/group/proj/-/jobs/123", + }); + }); + + it("detects CircleCI", () => { + process.env.CIRCLECI = "true"; + process.env.CIRCLE_SHA1 = "1234567abcdef"; + process.env.CIRCLE_BRANCH = "develop"; + process.env.CIRCLE_PR_NUMBER = "12"; + process.env.CIRCLE_BUILD_URL = "https://circleci.com/gh/x/y/123"; + + expect(detectCiMetadata()).toEqual({ + provider: "circleci", + sha: "1234567", + branch: "develop", + pr: "12", + run: "https://circleci.com/gh/x/y/123", + }); + }); + + it("detects Jenkins via JENKINS_URL presence", () => { + process.env.JENKINS_URL = "https://jenkins.internal/"; + process.env.GIT_COMMIT = "abcdef1234567890"; + process.env.GIT_BRANCH = "origin/main"; + process.env.CHANGE_ID = "55"; + process.env.BUILD_URL = "https://jenkins.internal/job/aether/42/"; + + expect(detectCiMetadata()).toEqual({ + provider: "jenkins", + sha: "abcdef1", + branch: "origin/main", + pr: "55", + run: "https://jenkins.internal/job/aether/42/", + }); + }); + + it("prefers GitHub over GitLab when both signals are set", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "ghsha12"; + process.env.GITLAB_CI = "true"; + process.env.CI_COMMIT_SHA = "glsha34"; + + expect(detectCiMetadata()?.provider).toBe("github"); + }); + + it("returns sparse metadata when only the trigger var is set", () => { + process.env.GITHUB_ACTIONS = "true"; + + expect(detectCiMetadata()).toEqual({ + provider: "github", + sha: undefined, + branch: undefined, + pr: undefined, + run: undefined, + }); + }); + }); + + describe("formatCiMetadata", () => { + it("formats the full set with provider first", () => { + const meta: CiMetadata = { + provider: "github", + sha: "abc1234", + branch: "main", + pr: "42", + run: "https://example.com/run/1", + }; + expect(formatCiMetadata(meta)).toBe("[ci=github sha=abc1234 branch=main pr=42 run=https://example.com/run/1]"); + }); + + it("omits fields that are undefined", () => { + const meta: CiMetadata = { + provider: "circleci", + sha: "1234567", + }; + expect(formatCiMetadata(meta)).toBe("[ci=circleci sha=1234567]"); + }); + + it("emits only the provider when no other fields are present", () => { + expect(formatCiMetadata({ provider: "jenkins" })).toBe("[ci=jenkins]"); + }); + }); + + describe("enrichDescriptionWithCiMetadata", () => { + function makeCmd(overrides: Partial<{ description: string; ciMetadata: boolean }> = {}): cli.ICommand & { description?: string } { + return { + type: cli.CommandType.release, + ...overrides, + } as any; + } + + it("does nothing when no CI provider is detected", () => { + const cmd = makeCmd({ description: "Fix bug" }); + enrichDescriptionWithCiMetadata(cmd); + expect(cmd.description).toBe("Fix bug"); + }); + + it("appends metadata with a blank line when description has content", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234"; + process.env.GITHUB_REF_NAME = "main"; + + const cmd = makeCmd({ description: "Fix bug" }); + enrichDescriptionWithCiMetadata(cmd); + expect(cmd.description).toBe("Fix bug\n\n[ci=github sha=abc1234 branch=main]"); + }); + + it("uses metadata alone when description is undefined", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234"; + + const cmd = makeCmd(); + enrichDescriptionWithCiMetadata(cmd); + expect(cmd.description).toBe("[ci=github sha=abc1234]"); + }); + + it("treats a whitespace-only description as empty", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234"; + + const cmd = makeCmd({ description: " \n " }); + enrichDescriptionWithCiMetadata(cmd); + expect(cmd.description).toBe("[ci=github sha=abc1234]"); + }); + + it("opts out when ciMetadata is false, leaving description untouched", () => { + process.env.GITHUB_ACTIONS = "true"; + process.env.GITHUB_SHA = "abc1234"; + + const cmd = makeCmd({ description: "Fix bug", ciMetadata: false }); + enrichDescriptionWithCiMetadata(cmd); + expect(cmd.description).toBe("Fix bug"); + }); + }); +}); diff --git a/script/command-executor.ts b/script/command-executor.ts index 1a0a0ef..e23f1ce 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -39,6 +39,7 @@ import { } from "../script/types"; import { getAndroidHermesEnabled, getiOSHermesEnabled, runHermesEmitBinaryCommand, isValidVersion } from "./react-native-utils"; import { fileDoesNotExistOrIsDirectory, isBinaryOrZip, fileExists } from "./utils/file-utils"; +import { enrichDescriptionWithCiMetadata } from "./utils/ci-metadata"; const configFilePath: string = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".aether", "config.json"); const DEFAULT_AETHER_SERVER_URL = "https://api-staging.aetherpush.com"; @@ -535,6 +536,15 @@ export function execute(command: cli.ICommand) { connectionInfo = deserializeConnectionInfo(); command.nonInteractive = resolveNonInteractive(command); + if ( + command.type === cli.CommandType.release || + command.type === cli.CommandType.releaseReact || + command.type === cli.CommandType.promote || + command.type === cli.CommandType.patch + ) { + enrichDescriptionWithCiMetadata(command); + } + return Promise.resolve().then(() => { switch (command.type) { // Must not be logged in diff --git a/script/command-parser.ts b/script/command-parser.ts index b37a84d..7a4346d 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -235,6 +235,12 @@ function addCommonConfiguration(yargs: yargs.Argv): void { description: "Skip confirmation prompts. Required to run destructive commands in non-interactive mode.", type: "boolean", }) + .option("ci-metadata", { + demand: false, + description: + "Auto-enrich release/promote/patch descriptions with CI metadata when running in a supported CI provider. Pass --no-ci-metadata to opt out.", + type: "boolean", + }) .fail((msg: string) => { parseFailed = true; showHelp(); @@ -1470,6 +1476,11 @@ export function createCommand(): cli.ICommand { if (isDefined(forceOption)) { cmd.force = forceOption; } + + const ciMetadataOption: boolean = argv["ci-metadata"] as any; + if (isDefined(ciMetadataOption)) { + cmd.ciMetadata = ciMetadataOption; + } } parseFailed = wasHelpShown || cmd === undefined; diff --git a/script/types/cli.ts b/script/types/cli.ts index 7d0b7da..a2ca823 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -45,6 +45,7 @@ export interface ICommand { type: CommandType; nonInteractive?: boolean; force?: boolean; + ciMetadata?: boolean; } export interface IAccessKeyAddCommand extends ICommand { diff --git a/script/utils/ci-metadata.ts b/script/utils/ci-metadata.ts new file mode 100644 index 0000000..7865449 --- /dev/null +++ b/script/utils/ci-metadata.ts @@ -0,0 +1,134 @@ +// Copyright (c) Aether. All rights reserved. + +import * as cli from "../types/cli"; + +export type CiProvider = "github" | "gitlab" | "circleci" | "jenkins"; + +export interface CiMetadata { + provider: CiProvider; + sha?: string; + branch?: string; + pr?: string; + run?: string; +} + +const SHA_SHORT_LENGTH = 7; + +function shortSha(sha: string | undefined): string | undefined { + if (!sha) { + return undefined; + } + return sha.length > SHA_SHORT_LENGTH ? sha.substring(0, SHA_SHORT_LENGTH) : sha; +} + +function parseGithubPrNumber(ref: string | undefined): string | undefined { + if (!ref) { + return undefined; + } + const match = ref.match(/^refs\/pull\/(\d+)\//); + return match ? match[1] : undefined; +} + +function detectGithub(env: NodeJS.ProcessEnv): CiMetadata | null { + if (env.GITHUB_ACTIONS !== "true") { + return null; + } + + let run: string | undefined; + if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID) { + run = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`; + } + + return { + provider: "github", + sha: shortSha(env.GITHUB_SHA), + branch: env.GITHUB_REF_NAME || undefined, + pr: parseGithubPrNumber(env.GITHUB_REF), + run, + }; +} + +function detectGitlab(env: NodeJS.ProcessEnv): CiMetadata | null { + if (env.GITLAB_CI !== "true") { + return null; + } + + return { + provider: "gitlab", + sha: shortSha(env.CI_COMMIT_SHA), + branch: env.CI_COMMIT_REF_NAME || undefined, + pr: env.CI_MERGE_REQUEST_IID || undefined, + run: env.CI_JOB_URL || undefined, + }; +} + +function detectCircleci(env: NodeJS.ProcessEnv): CiMetadata | null { + if (env.CIRCLECI !== "true") { + return null; + } + + return { + provider: "circleci", + sha: shortSha(env.CIRCLE_SHA1), + branch: env.CIRCLE_BRANCH || undefined, + pr: env.CIRCLE_PR_NUMBER || undefined, + run: env.CIRCLE_BUILD_URL || undefined, + }; +} + +function detectJenkins(env: NodeJS.ProcessEnv): CiMetadata | null { + if (!env.JENKINS_URL) { + return null; + } + + return { + provider: "jenkins", + sha: shortSha(env.GIT_COMMIT), + branch: env.GIT_BRANCH || undefined, + pr: env.CHANGE_ID || undefined, + run: env.BUILD_URL || undefined, + }; +} + +export function detectCiMetadata(): CiMetadata | null { + const env = process.env; + return detectGithub(env) || detectGitlab(env) || detectCircleci(env) || detectJenkins(env); +} + +export function formatCiMetadata(meta: CiMetadata): string { + const parts: string[] = [`ci=${meta.provider}`]; + if (meta.sha) { + parts.push(`sha=${meta.sha}`); + } + if (meta.branch) { + parts.push(`branch=${meta.branch}`); + } + if (meta.pr) { + parts.push(`pr=${meta.pr}`); + } + if (meta.run) { + parts.push(`run=${meta.run}`); + } + return `[${parts.join(" ")}]`; +} + +export function enrichDescriptionWithCiMetadata(command: cli.ICommand): void { + if (command.ciMetadata === false) { + return; + } + + const meta = detectCiMetadata(); + if (!meta) { + return; + } + + const formatted = formatCiMetadata(meta); + const target = command as cli.ICommand & { description?: string }; + const existing = target.description ? target.description.trim() : ""; + + if (existing) { + target.description = `${existing}\n\n${formatted}`; + } else { + target.description = formatted; + } +}