From 2b2c517c598b4425f71be46e66020b47828c0807 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 6 May 2026 18:29:25 +0700 Subject: [PATCH] feat(version): show unreleased commit label (#1126) --- .../version/__tests__/version.test.tsx | 61 +++++++++++++++ src/components/version/version.tsx | 22 ++++-- vite.config.js | 76 ++++++++++++++++++- 3 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 src/components/version/__tests__/version.test.tsx diff --git a/src/components/version/__tests__/version.test.tsx b/src/components/version/__tests__/version.test.tsx new file mode 100644 index 00000000..8a4600f5 --- /dev/null +++ b/src/components/version/__tests__/version.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +let root: Root; +let container: HTMLDivElement; + +const renderVersion = async ({ commitRef, version }: { commitRef: string; version: string }) => { + vi.resetModules(); + vi.stubEnv('VITE_APP_VERSION', version); + vi.stubEnv('VITE_COMMIT_REF', commitRef); + const { default: Version } = await import('../version'); + + await act(async () => { + root.render(createElement(Version)); + }); +}; + +describe('Version', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllEnvs(); + }); + + it('links only to the release when no unreleased commit is configured', async () => { + await renderVersion({ commitRef: '', version: '0.8.3' }); + + expect(container.textContent).toBe('v0.8.3'); + const links = container.querySelectorAll('a'); + expect(links).toHaveLength(1); + expect(links[0]?.href).toBe('https://github.com/bitsocialnet/5chan/releases/tag/v0.8.3'); + }); + + it('appends a linked short hash for unreleased commits', async () => { + await renderVersion({ commitRef: '2ebd9ecc30a58a96723f9a71f6ed4beeef1b847b', version: '0.8.3' }); + + expect(container.textContent).toBe('v0.8.3#2ebd9ec'); + const links = container.querySelectorAll('a'); + expect(links).toHaveLength(2); + expect(links[0]?.href).toBe('https://github.com/bitsocialnet/5chan/releases/tag/v0.8.3'); + expect(links[1]?.textContent).toBe('#2ebd9ec'); + expect(links[1]?.href).toBe('https://github.com/bitsocialnet/5chan/commit/2ebd9ecc30a58a96723f9a71f6ed4beeef1b847b'); + }); +}); diff --git a/src/components/version/version.tsx b/src/components/version/version.tsx index dc5151db..54b5fee2 100644 --- a/src/components/version/version.tsx +++ b/src/components/version/version.tsx @@ -1,20 +1,28 @@ import { useTranslation } from 'react-i18next'; import { currentAppVersion } from '../../lib/app-version'; -const commitRef = import.meta.env.VITE_COMMIT_REF; +const commitRef = `${import.meta.env.VITE_COMMIT_REF || ''}`.trim(); +const shortCommitRef = commitRef.slice(0, 7); const isElectron = window.electronApi?.isElectron === true; const Version = () => { const { t } = useTranslation(); return ( <> - - v{commitRef ? `${currentAppVersion}#${commitRef.slice(0, 7)}` : currentAppVersion} + + v{currentAppVersion} + {shortCommitRef ? ( + + #{shortCommitRef} + + ) : null} {isElectron && ( <> {' '} diff --git a/vite.config.js b/vite.config.js index ba72e8fc..76c59f35 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,11 +3,13 @@ import react from '@vitejs/plugin-react'; import { resolve } from 'path'; import { readFileSync } from 'fs'; import { createHash } from 'crypto'; +import { execFileSync } from 'child_process'; import { VitePWA } from 'vite-plugin-pwa'; const { version: packageVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); const appVersion = `${process.env.VITE_APP_VERSION || packageVersion}`.trim() || packageVersion; process.env.VITE_APP_VERSION = appVersion; +const releaseTag = `v${appVersion.replace(/^v/i, '').split('-')[0]}`; const publicBase = process.env.PUBLIC_URL || '/'; const buildOutDir = 'build'; const basePathPrefix = (() => { @@ -36,6 +38,72 @@ const baselineAppShellUrls = new Set([ 'manifest-icon-512x512.png', ]); +function readGitRef(args) { + try { + return execFileSync('git', args, { + cwd: new URL('.', import.meta.url), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }).trim(); + } catch { + return ''; + } +} + +function firstNonEmpty(...values) { + return values.map((value) => `${value || ''}`.trim()).find(Boolean) || ''; +} + +function normalizeCommitRef(ref) { + return `${ref || ''}`.trim().toLowerCase(); +} + +function isSameCommitRef(left, right) { + const normalizedLeft = normalizeCommitRef(left); + const normalizedRight = normalizeCommitRef(right); + + if (!normalizedLeft || !normalizedRight) { + return false; + } + + return normalizedLeft === normalizedRight || normalizedLeft.startsWith(normalizedRight) || normalizedRight.startsWith(normalizedLeft); +} + +function readRemoteTagCommitRef(tagName) { + const tagRef = `refs/tags/${tagName}`; + const output = readGitRef(['ls-remote', '--tags', 'origin', tagRef, `${tagRef}^{}`]); + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const releaseLine = lines.find((line) => line.endsWith(`${tagRef}^{}`)) || lines.find((line) => line.endsWith(tagRef)) || ''; + + return releaseLine.split(/\s+/)[0] || ''; +} + +function resolveBuildCommitRef() { + return firstNonEmpty(process.env.VITE_COMMIT_REF, process.env.VERCEL_GIT_COMMIT_SHA, process.env.GITHUB_SHA, process.env.COMMIT_REF, readGitRef(['rev-parse', 'HEAD'])); +} + +function resolveReleaseCommitRef() { + const configuredReleaseCommitRef = firstNonEmpty(process.env.VITE_LATEST_RELEASE_COMMIT_REF, process.env.LATEST_RELEASE_COMMIT_REF); + + if (configuredReleaseCommitRef) { + return configuredReleaseCommitRef; + } + + if (process.env.GITHUB_REF_NAME === releaseTag) { + return resolveBuildCommitRef(); + } + + return firstNonEmpty(readGitRef(['rev-list', '-n', '1', releaseTag]), readRemoteTagCommitRef(releaseTag)); +} + +const buildCommitRef = resolveBuildCommitRef(); +const releaseCommitRef = resolveReleaseCommitRef(); +const displayCommitRef = buildCommitRef && !isSameCommitRef(buildCommitRef, releaseCommitRef) ? buildCommitRef : ''; + function normalizePrecacheUrl(url) { const normalizedUrl = url.split('?')[0].replace(/^[./]+/, ''); @@ -78,7 +146,11 @@ function keepAppShellPrecacheOnly(manifestEntries) { } function appVersionMetadataPlugin() { - const payload = `${JSON.stringify({ version: appVersion })}\n`; + const payload = `${JSON.stringify({ + version: appVersion, + commitRef: buildCommitRef || undefined, + releaseCommitRef: releaseCommitRef || undefined, + })}\n`; return { name: 'fivechan-version-metadata', @@ -375,7 +447,7 @@ export default defineConfig({ }, define: { 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion), - 'process.env.VITE_COMMIT_REF': JSON.stringify(process.env.COMMIT_REF), + 'import.meta.env.VITE_COMMIT_REF': JSON.stringify(displayCommitRef), 'process.version': JSON.stringify(''), global: 'globalThis', __dirname: '""',