Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,14 @@ jobs:
DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }}

- name: Build server and admin assets
run: yarn build:production
run: |
PKG_VERSION=$(node -p "require('./ghost/core/package.json').version")
SHORT_SHA="${GITHUB_SHA:0:7}"
if [ "${{ github.ref_type }}" != "tag" ]; then
export GHOST_BUILD_VERSION="${PKG_VERSION}+${SHORT_SHA}"
echo "GHOST_BUILD_VERSION=${GHOST_BUILD_VERSION}" >> $GITHUB_ENV
fi
yarn build:production

- name: Pack standalone distribution
run: yarn workspace ghost pack:standalone
Expand Down Expand Up @@ -1032,7 +1039,9 @@ jobs:
context: /tmp/ghost-production
file: Dockerfile.production
target: core
build-args: NODE_VERSION=${{ env.NODE_VERSION }}
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
GHOST_BUILD_VERSION=${{ env.GHOST_BUILD_VERSION }}
push: ${{ steps.strategy.outputs.should-push }}
load: ${{ steps.strategy.outputs.should-push == 'false' }}
tags: ${{ steps.meta-core.outputs.tags }}
Expand All @@ -1046,7 +1055,9 @@ jobs:
context: /tmp/ghost-production
file: Dockerfile.production
target: full
build-args: NODE_VERSION=${{ env.NODE_VERSION }}
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
GHOST_BUILD_VERSION=${{ env.GHOST_BUILD_VERSION }}
push: ${{ steps.strategy.outputs.should-push }}
load: true
tags: ${{ steps.meta-full.outputs.tags }}
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile.production
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ ARG NODE_VERSION=22.18.0
# ---- Core: server + production deps ----
FROM node:$NODE_VERSION-bookworm-slim AS core

ARG GHOST_BUILD_VERSION=""
ENV NODE_ENV=production
ENV GHOST_BUILD_VERSION=${GHOST_BUILD_VERSION}

RUN apt-get update && \
apt-get install -y --no-install-recommends libjemalloc2 && \
Expand Down
3 changes: 2 additions & 1 deletion apps/admin-x-framework/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export default function adminXViteConfig({packageName, entry, overrides}: {packa
],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VITEST_SEGFAULT_RETRY': 3
'process.env.VITEST_SEGFAULT_RETRY': 3,
'import.meta.env.GHOST_BUILD_VERSION': JSON.stringify(process.env.GHOST_BUILD_VERSION || '')
},
preview: {
port: 4174
Expand Down
28 changes: 21 additions & 7 deletions apps/admin-x-settings/src/components/settings/general/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import {showDatabaseWarning} from '../../../utils/show-database-warning';
import {useGlobalData} from '../../providers/global-data-provider';
import {useUpgradeStatus} from '../../providers/settings-app-provider';

const adminBuildVersion = import.meta.env.GHOST_BUILD_VERSION;

function VersionLink({label, version}: {label: string; version: string}) {
const link = linkToGitHubReleases(version);
return (
<div>
<strong>{label}:</strong> {link
? <a className='text-green' href={link} rel="noopener noreferrer" target="_blank">{version}</a>
: version}
</div>
);
}

const AboutModal = NiceModal.create<RoutingModalProps>(({}) => {
const {updateRoute} = useRouting();
const globalData = useGlobalData();
Expand Down Expand Up @@ -56,13 +69,14 @@ const AboutModal = NiceModal.create<RoutingModalProps>(({}) => {
</div>
)
}
{
linkToGitHubReleases(config.version) && (
<div><strong>Version:</strong> <a className='text-green' href={linkToGitHubReleases(config.version)} rel="noopener noreferrer" target="_blank">{config.version}</a></div>
) || (
<div><strong>Version:</strong> {config.version}</div>
)
}
{adminBuildVersion ? (
<>
<VersionLink label="Server" version={config.version} />
<VersionLink label="Admin" version={adminBuildVersion} />
</>
) : (
<VersionLink label="Version" version={config.version} />
)}
{
showSystemInfo() && (
<>
Expand Down
46 changes: 32 additions & 14 deletions apps/admin-x-settings/src/utils/link-to-github-releases.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,54 @@
// @ts-expect-error - semver subpath has no types
import semverParse from 'semver/functions/parse';

// This function needs to support:
// - 5.94.1+moya
// - 5.94.1-0-g1f3e72eac8+moya
// - 5.95.0-pre-g028c1a6+moya
// Supported version formats:
// - 6.21.2 → release tag
// - 6.21.2+1710072000.abc1234 → commit (server: semver+epoch.sha)
// - 6.21.2+abc1234 → commit (admin: semver+sha)
// - 5.94.1+moya → release tag (legacy)
// - 5.94.1-0-gabcdef+moya → commit (legacy git describe)
// - 5.95.0-pre-gabcdef+moya → commit (legacy canary)
export function linkToGitHubReleases(version: string): string {
if (!version) {
return '';
}

const cleanedVersion = version.replace('+moya', '');
// Extract build metadata (everything after +) before semver parsing strips it
const plusIndex = version.indexOf('+');
const buildMetadata = plusIndex !== -1 ? version.slice(plusIndex + 1) : '';
const versionWithoutBuild = plusIndex !== -1 ? version.slice(0, plusIndex) : version;

// Check build metadata for a commit SHA
if (buildMetadata && buildMetadata !== 'moya') {
// New format: "epoch.sha" or just "sha"
const parts = buildMetadata.split('.');
const sha = parts[parts.length - 1];

if (sha && /^[0-9a-f]{7,40}$/.test(sha)) {
return `https://github.com/TryGhost/Ghost/commit/${sha}`;
}
}

// Check pre-release segment for a commit SHA (legacy format: -0-gabcdef or -pre-gabcdef)
try {
const semverVersion = semverParse(cleanedVersion, {includePrerelease: true} as any);
const semverVersion = semverParse(versionWithoutBuild, {includePrerelease: true} as any);
const prerelease = semverVersion?.prerelease;

if (prerelease && prerelease?.length > 0) {
if (prerelease && prerelease.length > 0) {
const splitPrerelease = String(prerelease[0]).split('-');
const commitHash = splitPrerelease[1];

if (!commitHash || !commitHash.startsWith('g')) {
return '';
if (commitHash?.startsWith('g')) {
return `https://github.com/TryGhost/Ghost/commit/${commitHash.slice(1)}`;
}

const commitHashWithoutG = commitHash.slice(1);

return `https://github.com/TryGhost/Ghost/commit/${commitHashWithoutG}`;
// Has pre-release but no recognizable commit hash
return '';
}

return `https://github.com/TryGhost/Ghost/releases/tag/v${cleanedVersion}`;
} catch {
return '';
}

// Plain semver (with or without +moya) → release tag
return `https://github.com/TryGhost/Ghost/releases/tag/v${versionWithoutBuild}`;
Comment on lines 33 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard the release-tag fallback for unsupported version strings.

apps/admin-x-framework/src/vite.ts:47-51 passes GHOST_BUILD_VERSION through verbatim, and apps/admin-x-settings/src/components/settings/general/about.tsx:9-17 will render any non-empty URL this helper returns. With the current fallback, a raw SHA or other non-semver value still becomes releases/tag/v..., which gives the About dialog a broken link instead of plain text. Return '' when versionWithoutBuild is not a valid semver before constructing the tag URL.

🔧 Proposed fix
 try { const semverVersion = semverParse(versionWithoutBuild, {includePrerelease: true} as any); - const prerelease = semverVersion?.prerelease; + if (!semverVersion) { + return ''; + } + + const prerelease = semverVersion.prerelease; if (prerelease && prerelease.length > 0) { const splitPrerelease = String(prerelease[0]).split('-'); const commitHash = splitPrerelease[1];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const semverVersion = semverParse(cleanedVersion, {includePrerelease: true} as any);
const semverVersion = semverParse(versionWithoutBuild, {includePrerelease: true} as any);
const prerelease = semverVersion?.prerelease;
if (prerelease && prerelease?.length > 0) {
if (prerelease && prerelease.length > 0) {
const splitPrerelease = String(prerelease[0]).split('-');
const commitHash = splitPrerelease[1];
if (!commitHash || !commitHash.startsWith('g')) {
return '';
if (commitHash?.startsWith('g')) {
return `https://github.com/TryGhost/Ghost/commit/${commitHash.slice(1)}`;
}
const commitHashWithoutG = commitHash.slice(1);
return `https://github.com/TryGhost/Ghost/commit/${commitHashWithoutG}`;
// Has pre-release but no recognizable commit hash
return '';
}
return `https://github.com/TryGhost/Ghost/releases/tag/v${cleanedVersion}`;
} catch {
return '';
}
// Plain semver (with or without +moya) → release tag
return `https://github.com/TryGhost/Ghost/releases/tag/v${versionWithoutBuild}`;
try {
const semverVersion = semverParse(versionWithoutBuild, {includePrerelease: true} as any);
if (!semverVersion) {
return '';
}
const prerelease = semverVersion.prerelease;
if (prerelease && prerelease.length > 0) {
const splitPrerelease = String(prerelease[0]).split('-');
const commitHash = splitPrerelease[1];
if (commitHash?.startsWith('g')) {
return `https://github.com/TryGhost/Ghost/commit/${commitHash.slice(1)}`;
}
// Has pre-release but no recognizable commit hash
return '';
}
} catch {
return '';
}
// Plain semver (with or without +moya) → release tag
return `https://github.com/TryGhost/Ghost/releases/tag/v${versionWithoutBuild}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/admin-x-settings/src/utils/link-to-github-releases.ts` around lines 33 - 53, The fallback to a releases/tag URL must only be used for valid semver strings: in link-to-github-releases.ts, after computing versionWithoutBuild and before returning the tag URL, call semverParse(versionWithoutBuild, {includePrerelease: true}) (same helper used earlier) and if it returns null/undefined, return '' instead of constructing `https://github.com/TryGhost/Ghost/releases/tag/v${versionWithoutBuild}`; keep the existing prerelease/commit-hash logic (the current try block with semverParse and prerelease handling) but add this explicit semver validity guard so raw SHAs or non-semver values produce an empty string. 
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,20 @@ describe('linkToGithubRelease', function () {
const link = linkToGitHubReleases('5.70.0-pre-gabcdef+moya');
assert.equal(link, 'https://github.com/TryGhost/Ghost/commit/abcdef');
});

it('handles server build version with epoch.sha', function () {
const link = linkToGitHubReleases('6.21.2+1710072000.abc1234');
assert.equal(link, 'https://github.com/TryGhost/Ghost/commit/abc1234');
});

it('handles admin build version with sha only', function () {
const link = linkToGitHubReleases('6.21.2+abc1234');
assert.equal(link, 'https://github.com/TryGhost/Ghost/commit/abc1234');
});

it('handles full 40-char sha in build metadata', function () {
const sha = 'a'.repeat(40);
const link = linkToGitHubReleases(`6.21.2+1710072000.${sha}`);
assert.equal(link, `https://github.com/TryGhost/Ghost/commit/${sha}`);
});
});
2 changes: 1 addition & 1 deletion ghost/core/core/server/services/public-config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const ghostVersion = require('@tryghost/version');

module.exports = function getConfigProperties() {
const configProperties = {
version: ghostVersion.original,
version: process.env.GHOST_BUILD_VERSION || ghostVersion.original,
environment: config.get('env'),
database: databaseInfo.getEngine(),
mail: isPlainObject(config.get('mail')) ? config.get('mail').transport : '',
Expand Down
19 changes: 19 additions & 0 deletions ghost/core/test/unit/server/services/public-config/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ describe('Public-config Service', function () {
assert.deepEqual(Object.keys(configProperties), allowedKeys);
});

it('should return GHOST_BUILD_VERSION as version when set', function () {
process.env.GHOST_BUILD_VERSION = '6.21.2+abc1234';

const configProperties = getConfigProperties();

assert.equal(configProperties.version, '6.21.2+abc1234');

delete process.env.GHOST_BUILD_VERSION;
});

it('should return package version when GHOST_BUILD_VERSION is not set', function () {
delete process.env.GHOST_BUILD_VERSION;

const configProperties = getConfigProperties();

assert.match(configProperties.version, /^\d+\.\d+\.\d+/);
assert.notEqual(configProperties.version, '6.21.2+abc1234');
});
Comment on lines +48 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Improve test isolation by cleaning up env var in afterEach.

The current pattern of manually deleting process.env.GHOST_BUILD_VERSION at the end of the test is fragile. If the test fails before reaching the cleanup line, the env var remains set and could pollute subsequent tests.

Consider moving the cleanup to the existing afterEach hook to ensure proper isolation regardless of test outcome:

Proposed fix
 afterEach(async function () { await configUtils.restore(); sinon.restore(); + delete process.env.GHOST_BUILD_VERSION; });

Then simplify the tests:

 it('should return GHOST_BUILD_VERSION as version when set', function () { process.env.GHOST_BUILD_VERSION = '6.21.2+abc1234'; const configProperties = getConfigProperties(); assert.equal(configProperties.version, '6.21.2+abc1234'); - - delete process.env.GHOST_BUILD_VERSION; }); it('should return package version when GHOST_BUILD_VERSION is not set', function () { - delete process.env.GHOST_BUILD_VERSION; - const configProperties = getConfigProperties();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ghost/core/test/unit/server/services/public-config/config.test.js` around lines 48 - 65, The tests set process.env.GHOST_BUILD_VERSION inside the it blocks but manually delete it at the end of each test, which is fragile; add cleanup in the existing afterEach hook to delete process.env.GHOST_BUILD_VERSION so the environment is reset even when a test fails, then remove the manual delete lines from the two tests that call getConfigProperties() (the "should return GHOST_BUILD_VERSION as version when set" and "should return package version when GHOST_BUILD_VERSION is not set" tests) so they rely on the centralized afterEach cleanup. 

it('should return null for tenor apikey when unset', function () {
let configProperties = getConfigProperties();

Expand Down
Loading