diff --git a/app/client/src/git/ce/constants/messages.tsx b/app/client/src/git/ce/constants/messages.tsx
new file mode 100644
index 000000000000..0978898423ff
--- /dev/null
+++ b/app/client/src/git/ce/constants/messages.tsx
@@ -0,0 +1,18 @@
+export const OPS_MODAL = {
+ TAB_RELEASE: "RELEASE",
+};
+
+export const TAB_RELEASE = {
+ TITLE: "Release version",
+ RELEASE_BTN: "Release",
+};
+
+export const RELEASE_VERSION_RADIO_GROUP = {
+ TITLE: "Version",
+ LAST_RELEASED: "Last released",
+};
+
+export const RELEASE_NOTES_INPUT = {
+ TITLE: "Release notes",
+ PLACEHOLDER: "Your release notes here",
+};
diff --git a/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.test.tsx b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.test.tsx
new file mode 100644
index 000000000000..f5cfcf517c65
--- /dev/null
+++ b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.test.tsx
@@ -0,0 +1,94 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import LatestCommitInfoView from "./LatestCommitInfoView";
+import "@testing-library/jest-dom";
+
+describe("LatestCommitInfoView", () => {
+ it("renders correctly with all props", () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText("Initial commit")).toBeInTheDocument();
+ expect(getByText("John Doe committed 2025-03-01")).toBeInTheDocument();
+ expect(getByText("abc123")).toBeInTheDocument();
+ });
+
+ it("renders correctly with null authorName", () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText("Initial commit")).toBeInTheDocument();
+ expect(getByText("- committed 2025-03-01")).toBeInTheDocument();
+ expect(getByText("abc123")).toBeInTheDocument();
+ });
+
+ it("renders correctly with null committedAt", () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText("Initial commit")).toBeInTheDocument();
+ expect(getByText("John Doe committed -")).toBeInTheDocument();
+ expect(getByText("abc123")).toBeInTheDocument();
+ });
+
+ it("renders correctly with null hash", () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText("Initial commit")).toBeInTheDocument();
+ expect(getByText("John Doe committed 2025-03-01")).toBeInTheDocument();
+ expect(getByText("-")).toBeInTheDocument();
+ });
+
+ it("renders correctly with null message", () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText("John Doe committed 2025-03-01")).toBeInTheDocument();
+ expect(getByText("abc123")).toBeInTheDocument();
+ });
+
+ it("renders correctly with all null props", () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText("- committed -")).toBeInTheDocument();
+ expect(getByText("-")).toBeInTheDocument();
+ });
+});
diff --git a/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.tsx b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.tsx
new file mode 100644
index 000000000000..fc6fb1ce671f
--- /dev/null
+++ b/app/client/src/git/components/LatestCommitInfo/LatestCommitInfoView.tsx
@@ -0,0 +1,41 @@
+import { Flex, Icon, Text } from "@appsmith/ads";
+import React from "react";
+import styled from "styled-components";
+
+const Container = styled(Flex)`
+ border-radius: 4px;
+ background-color: var(--ads-v2-color-gray-0);
+`;
+
+interface LatestCommitInfoViewProps {
+ authorName: string | null;
+ committedAt: string | null;
+ hash: string | null;
+ message: string | null;
+}
+
+function LatestCommitInfoView({
+ authorName = null,
+ committedAt = null,
+ hash = null,
+ message = null,
+}: LatestCommitInfoViewProps) {
+ return (
+
+
+ {message}
+
+ {authorName ?? "-"} committed {committedAt ?? "-"}
+
+
+
+
+
+ {hash ?? "-"}
+
+
+
+ );
+}
+
+export default LatestCommitInfoView;
diff --git a/app/client/src/git/components/LatestCommitInfo/index.tsx b/app/client/src/git/components/LatestCommitInfo/index.tsx
new file mode 100644
index 000000000000..ce04d0695e36
--- /dev/null
+++ b/app/client/src/git/components/LatestCommitInfo/index.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+import LatestCommitInfoView from "./LatestCommitInfoView";
+import useLatestCommit from "git/hooks/useLatestCommit";
+
+function LatestCommitInfo() {
+ const { latestCommit } = useLatestCommit();
+
+ return (
+
+ );
+}
+
+export default LatestCommitInfo;
diff --git a/app/client/src/git/components/OpsModal/TabRelease.tsx b/app/client/src/git/components/OpsModal/TabRelease.tsx
new file mode 100644
index 000000000000..b1ce504ac26e
--- /dev/null
+++ b/app/client/src/git/components/OpsModal/TabRelease.tsx
@@ -0,0 +1,60 @@
+import { Button, ModalBody, ModalFooter, Text } from "@appsmith/ads";
+import LatestCommitInfo from "git/components/LatestCommitInfo";
+import ReleaseNotesInput from "git/components/ReleaseNotesInput";
+import ReleaseVersionRadioGroup from "git/components/ReleaseVersionRadioGroup";
+import { TAB_RELEASE } from "git/ee/constants/messages";
+import React, { useCallback, useState } from "react";
+import styled from "styled-components";
+
+const Container = styled.div`
+ min-height: 360px;
+ overflow: unset;
+ padding-bottom: 4px;
+`;
+
+const TabTitle = styled(Text)`
+ margin-bottom: 12px;
+ color: var(--ads-v2-color-fg-emphasis);
+`;
+
+const StyledModalFooter = styled(ModalFooter)`
+ min-height: 52px;
+`;
+
+function TabRelease() {
+ const [releaseVersion, setReleaseVersion] = useState(null);
+ const [releaseNotes, setReleaseNotes] = useState(null);
+
+ const isReleaseDisabled = !releaseVersion || !releaseNotes;
+
+ const handleClickOnRelease = useCallback(() => {}, []);
+
+ return (
+ <>
+
+
+
+ {TAB_RELEASE.TITLE}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default TabRelease;
diff --git a/app/client/src/git/components/ReleaseNotesInput/index.tsx b/app/client/src/git/components/ReleaseNotesInput/index.tsx
new file mode 100644
index 000000000000..8b12fc931f63
--- /dev/null
+++ b/app/client/src/git/components/ReleaseNotesInput/index.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { Flex, Input } from "@appsmith/ads";
+import { RELEASE_NOTES_INPUT } from "git/ee/constants/messages";
+import { noop } from "lodash";
+
+interface ReleaseNotesInputProps {
+ onTextChange: (text: string | null) => void;
+ text: string | null;
+}
+
+function ReleaseNotesInput({
+ onTextChange = noop,
+ text = null,
+}: ReleaseNotesInputProps) {
+ return (
+
+
+
+ );
+}
+
+export default ReleaseNotesInput;
diff --git a/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.test.tsx b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.test.tsx
new file mode 100644
index 000000000000..4aaa89cb9343
--- /dev/null
+++ b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.test.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { render, fireEvent } from "@testing-library/react";
+import ReleaseVersionRadioGroupView from "./ReleaseVersionRadioGroupView";
+import "@testing-library/jest-dom";
+
+describe("ReleaseVersionRadioGroupView", () => {
+ const mockOnVersionChange = jest.fn();
+
+ const renderComponent = (props = {}) => {
+ return render(
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ mockOnVersionChange.mockClear();
+ });
+
+ it("should render correctly with initial props", () => {
+ const { getByRole, getByTestId } = renderComponent();
+
+ expect(getByTestId("t--git-release-version-title").textContent).toBe(
+ "Version",
+ );
+ expect(getByTestId("t--git-release-next-version").textContent).toBe(
+ "1.0.1",
+ );
+ expect(getByTestId("t--git-release-released-at").textContent).toBe(
+ "Last released: 1.0.0 (2023-01-01)",
+ );
+ expect(getByRole("radio", { name: /patch/i })).toBeChecked();
+ });
+
+ it("should change version when a different radio button is selected", () => {
+ const { getByRole, getByTestId } = renderComponent();
+
+ fireEvent.click(getByRole("radio", { name: /minor/i }));
+ expect(getByTestId("t--git-release-next-version").textContent).toBe(
+ "1.1.0",
+ );
+ fireEvent.click(getByRole("radio", { name: /major/i }));
+ expect(getByTestId("t--git-release-next-version").textContent).toBe(
+ "2.0.0",
+ );
+ });
+
+ it("should call onVersionChange with the correct version", () => {
+ const { getByRole } = renderComponent();
+
+ expect(mockOnVersionChange).toHaveBeenCalledWith("1.0.1"); // initial call with patch version
+ fireEvent.click(getByRole("radio", { name: /minor/i }));
+ expect(mockOnVersionChange).toHaveBeenCalledWith("1.1.0");
+ fireEvent.click(getByRole("radio", { name: /major/i }));
+ expect(mockOnVersionChange).toHaveBeenCalledWith("2.0.0");
+ });
+
+ it("should handle null values for currentVersion and releasedAt", () => {
+ const { getByTestId } = renderComponent({
+ currentVersion: null,
+ releasedAt: null,
+ });
+
+ expect(getByTestId("t--git-release-released-at").textContent).toBe(
+ "Last released: - (-)",
+ );
+ });
+});
diff --git a/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.tsx b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.tsx
new file mode 100644
index 000000000000..3f05c811e19d
--- /dev/null
+++ b/app/client/src/git/components/ReleaseVersionRadioGroup/ReleaseVersionRadioGroupView.tsx
@@ -0,0 +1,73 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { Flex, Radio, RadioGroup, Tag, Text } from "@appsmith/ads";
+import { RELEASE_VERSION_RADIO_GROUP } from "git/ee/constants/messages";
+import { inc } from "semver";
+import noop from "lodash/noop";
+
+type ReleaseType = "major" | "minor" | "patch" | null;
+
+interface ReleaseVersionRadioGroupViewProps {
+ currentVersion: string | null;
+ onVersionChange: (value: string | null) => void;
+ releasedAt: string | null;
+}
+
+function ReleaseVersionRadioGroupView({
+ currentVersion = null,
+ onVersionChange = noop,
+ releasedAt = null,
+}: ReleaseVersionRadioGroupViewProps) {
+ const [releaseType, setReleaseType] = useState("patch");
+
+ const nextVersion = useMemo(() => {
+ if (!currentVersion || !releaseType) return null;
+
+ return inc(currentVersion, releaseType);
+ }, [currentVersion, releaseType]);
+
+ useEffect(
+ function releaseVersionChangeEffect() {
+ onVersionChange(nextVersion);
+ },
+ [nextVersion, onVersionChange],
+ );
+
+ const handleRadioChange = useCallback((value: string) => {
+ setReleaseType(value as ReleaseType);
+ }, []);
+
+ return (
+
+
+ {RELEASE_VERSION_RADIO_GROUP.TITLE}
+
+
+
+
+ {nextVersion ?? "-"}
+
+
+
+ Major
+ Minor
+ Patch
+
+
+
+ {RELEASE_VERSION_RADIO_GROUP.LAST_RELEASED}: {currentVersion ?? "-"} (
+ {releasedAt ?? "-"})
+
+
+ );
+}
+
+export default ReleaseVersionRadioGroupView;
diff --git a/app/client/src/git/components/ReleaseVersionRadioGroup/index.tsx b/app/client/src/git/components/ReleaseVersionRadioGroup/index.tsx
new file mode 100644
index 000000000000..a117dea1dc34
--- /dev/null
+++ b/app/client/src/git/components/ReleaseVersionRadioGroup/index.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+import ReleaseVersionRadioGroupView from "./ReleaseVersionRadioGroupView";
+import noop from "lodash/noop";
+import useLatestCommit from "git/hooks/useLatestCommit";
+
+interface ReleaseVersionRadioGroupProps {
+ onVersionChange: (version: string | null) => void;
+}
+
+function ReleaseVersionRadioGroup({
+ onVersionChange = noop,
+}: ReleaseVersionRadioGroupProps) {
+ const { latestCommit } = useLatestCommit();
+
+ return (
+
+ );
+}
+
+export default ReleaseVersionRadioGroup;
diff --git a/app/client/src/git/constants/enums.ts b/app/client/src/git/constants/enums.ts
index af2fb31513a6..b979b66645ed 100644
--- a/app/client/src/git/constants/enums.ts
+++ b/app/client/src/git/constants/enums.ts
@@ -7,6 +7,7 @@ export enum GitArtifactType {
export enum GitOpsTab {
Deploy = "Deploy",
Merge = "Merge",
+ Release = "Release",
}
export enum GitSettingsTab {
diff --git a/app/client/src/git/ee/constants/messages.tsx b/app/client/src/git/ee/constants/messages.tsx
new file mode 100644
index 000000000000..ca3e37386344
--- /dev/null
+++ b/app/client/src/git/ee/constants/messages.tsx
@@ -0,0 +1 @@
+export * from "../../ce/constants/messages";
diff --git a/app/client/src/git/hooks/useLatestCommit.ts b/app/client/src/git/hooks/useLatestCommit.ts
new file mode 100644
index 000000000000..2d96ece707de
--- /dev/null
+++ b/app/client/src/git/hooks/useLatestCommit.ts
@@ -0,0 +1,29 @@
+import { useGitContext } from "git/components/GitContextProvider";
+import useArtifactSelector from "./useArtifactSelector";
+import { selectLatestCommitState } from "git/store/selectors/gitArtifactSelectors";
+import { useDispatch } from "react-redux";
+import { gitArtifactActions } from "git/store/gitArtifactSlice";
+import { useCallback } from "react";
+
+export default function useLatestCommit() {
+ const dispatch = useDispatch();
+ const { artifact, artifactDef } = useGitContext();
+ const artifactId = artifact?.id;
+
+ const latestCommitState = useArtifactSelector(selectLatestCommitState);
+
+ const fetchLatestCommit = useCallback(() => {
+ if (artifactDef && artifactId) {
+ dispatch(
+ gitArtifactActions.fetchLatestCommitInit({ artifactDef, artifactId }),
+ );
+ }
+ }, [artifactDef, artifactId, dispatch]);
+
+ return {
+ latestCommit: latestCommitState?.value ?? null,
+ isLatestCommitLoading: latestCommitState?.loading ?? false,
+ latestCommitError: latestCommitState?.error ?? null,
+ fetchLatestCommit,
+ };
+}
diff --git a/app/client/src/git/requests/fetchLatestCommitRequest.ts b/app/client/src/git/requests/fetchLatestCommitRequest.ts
new file mode 100644
index 000000000000..e1c2fb150650
--- /dev/null
+++ b/app/client/src/git/requests/fetchLatestCommitRequest.ts
@@ -0,0 +1,15 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import type { AxiosPromise } from "axios";
+import type { GitArtifactType } from "git/constants/enums";
+import type { FetchLatestCommitResponse } from "./fetchLatestCommitRequest.types";
+import Api from "api/Api";
+import { GIT_BASE_URL } from "./constants";
+
+export default async function fetchLatestCommitRequest(
+ artifactType: GitArtifactType,
+ branchedArtifactId: string,
+): AxiosPromise {
+ return Api.get(
+ `${GIT_BASE_URL}/${artifactType}/${branchedArtifactId}/commit/latest`,
+ );
+}
diff --git a/app/client/src/git/requests/fetchLatestCommitRequest.types.ts b/app/client/src/git/requests/fetchLatestCommitRequest.types.ts
new file mode 100644
index 000000000000..3a21cc31edcc
--- /dev/null
+++ b/app/client/src/git/requests/fetchLatestCommitRequest.types.ts
@@ -0,0 +1,13 @@
+import type { ApiResponse } from "api/types";
+
+export interface FetchLatestCommitResponseData {
+ authorName: string;
+ committedAt: string;
+ hash: string;
+ message: string;
+ releaseTagName: string;
+ releasedAt: string;
+}
+
+export type FetchLatestCommitResponse =
+ ApiResponse;
diff --git a/app/client/src/git/store/actions/fetchLatestCommitActions.ts b/app/client/src/git/store/actions/fetchLatestCommitActions.ts
new file mode 100644
index 000000000000..dfb842d657de
--- /dev/null
+++ b/app/client/src/git/store/actions/fetchLatestCommitActions.ts
@@ -0,0 +1,38 @@
+import type {
+ GitArtifactErrorPayloadAction,
+ GitAsyncSuccessPayload,
+} from "../types";
+import { createArtifactAction } from "../helpers/createArtifactAction";
+import type { FetchLatestCommitResponseData } from "git/requests/fetchLatestCommitRequest.types";
+
+export interface FetchLatestCommitInitPayload {
+ artifactId: string;
+}
+
+export const fetchLatestCommitInitAction =
+ createArtifactAction((state) => {
+ state.apiResponses.latestCommit.loading = true;
+ state.apiResponses.latestCommit.error = null;
+
+ return state;
+ });
+
+export const fetchLatestCommitSuccessAction = createArtifactAction<
+ GitAsyncSuccessPayload
+>((state, action) => {
+ state.apiResponses.latestCommit.loading = false;
+ state.apiResponses.latestCommit.value = action.payload.responseData;
+
+ return state;
+});
+
+export const fetchLatestCommitErrorAction = createArtifactAction(
+ (state, action: GitArtifactErrorPayloadAction) => {
+ const { error } = action.payload;
+
+ state.apiResponses.latestCommit.loading = false;
+ state.apiResponses.latestCommit.error = error;
+
+ return state;
+ },
+);
diff --git a/app/client/src/git/store/gitArtifactSlice.ts b/app/client/src/git/store/gitArtifactSlice.ts
index 25e295366c05..f5bdcac8801f 100644
--- a/app/client/src/git/store/gitArtifactSlice.ts
+++ b/app/client/src/git/store/gitArtifactSlice.ts
@@ -141,6 +141,11 @@ import {
resetCurrentBranchAction,
updateCurrentBranchAction,
} from "./actions/currentBranchActions";
+import {
+ fetchLatestCommitErrorAction,
+ fetchLatestCommitInitAction,
+ fetchLatestCommitSuccessAction,
+} from "./actions/fetchLatestCommitActions";
const initialState: GitArtifactRootReduxState = {};
@@ -205,6 +210,9 @@ export const gitArtifactSlice = createSlice({
pullError: pullErrorAction,
toggleOpsModal: toggleOpsModalAction,
toggleConflictErrorModal: toggleConflictErrorModalAction,
+ fetchLatestCommitInit: fetchLatestCommitInitAction,
+ fetchLatestCommitSuccess: fetchLatestCommitSuccessAction,
+ fetchLatestCommitError: fetchLatestCommitErrorAction,
// branches
fetchBranchesInit: fetchBranchesInitAction,
diff --git a/app/client/src/git/store/helpers/initialState.ts b/app/client/src/git/store/helpers/initialState.ts
index 0528d5945cc6..1781a28342f7 100644
--- a/app/client/src/git/store/helpers/initialState.ts
+++ b/app/client/src/git/store/helpers/initialState.ts
@@ -52,6 +52,11 @@ const gitArtifactInitialAPIResponses: GitArtifactAPIResponsesReduxState = {
loading: false,
error: null,
},
+ latestCommit: {
+ value: null,
+ loading: false,
+ error: null,
+ },
pull: {
loading: false,
error: null,
diff --git a/app/client/src/git/store/selectors/gitArtifactSelectors.ts b/app/client/src/git/store/selectors/gitArtifactSelectors.ts
index eb8f7aab0c49..6df75a63b210 100644
--- a/app/client/src/git/store/selectors/gitArtifactSelectors.ts
+++ b/app/client/src/git/store/selectors/gitArtifactSelectors.ts
@@ -88,6 +88,11 @@ export const selectCommitState = (
artifactDef: GitArtifactDef,
) => selectGitArtifact(state, artifactDef)?.apiResponses?.commit;
+export const selectLatestCommitState = (
+ state: GitRootState,
+ artifactDef: GitArtifactDef,
+) => selectGitArtifact(state, artifactDef)?.apiResponses?.latestCommit;
+
export const selectDiscardState = (
state: GitRootState,
artifactDef: GitArtifactDef,
diff --git a/app/client/src/git/store/types.ts b/app/client/src/git/store/types.ts
index e6d1231679fd..e62b7ec47990 100644
--- a/app/client/src/git/store/types.ts
+++ b/app/client/src/git/store/types.ts
@@ -19,6 +19,7 @@ import type {
import type { FetchGlobalSSHKeyResponseData } from "git/requests/fetchGlobalSSHKeyRequest.types";
import type { FetchRefsResponseData } from "git/requests/fetchRefsRequest.types";
import type { GitArtifactDef } from "git/types";
+import type { FetchLatestCommitResponseData } from "git/requests/fetchLatestCommitRequest.types";
export interface GitApiError extends ApiResponseError {
errorType?: string;
@@ -41,6 +42,7 @@ export interface GitArtifactAPIResponsesReduxState
connect: GitAsyncStateWithoutValue;
status: GitAsyncState;
commit: GitAsyncStateWithoutValue;
+ latestCommit: GitAsyncState;
pull: GitAsyncStateWithoutValue;
discard: GitAsyncStateWithoutValue;
mergeStatus: GitAsyncState;