Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve apps release #22127

Merged
merged 3 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions .github/scripts/release-apps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
const path = require('path');
const fs = require('fs/promises');
const exec = require('util').promisify(require('child_process').exec);
const readline = require('readline/promises');

const semver = require('semver');

// Maps a package name to the config key in defaults.json
const CONFIG_KEYS = {
'@tryghost/portal': 'portal'
};

const CURRENT_DIR = process.cwd();

const packageJsonPath = path.join(CURRENT_DIR, 'package.json');
const packageJson = require(packageJsonPath);

const APP_NAME = packageJson.name;
const APP_VERSION = packageJson.version;

async function safeExec(command) {
try {
return await exec(command);
} catch (err) {
return {
stdout: err.stdout,
stderr: err.stderr
};
}
}

async function ensureEnabledApp() {
const ENABLED_APPS = Object.keys(CONFIG_KEYS);
if (!ENABLED_APPS.includes(APP_NAME)) {
console.error(`${APP_NAME} is not enabled, please modify ${__filename}`);
process.exit(1);
}
}

async function ensureNotOnMain() {
const currentGitBranch = await safeExec(`git branch --show-current`);
if (currentGitBranch.stderr) {
console.error(`There was an error checking the current git branch`)
console.error(`${currentGitBranch.stderr}`);
process.exit(1);
}

if (currentGitBranch.stdout.trim() === 'main') {
console.error(`The release can not be done on the "main" branch`)
process.exit(1);
}
}

async function ensureCleanGit() {
const localGitChanges = await safeExec(`git status --porcelain`);
if (localGitChanges.stderr) {
console.error(`There was an error checking the local git status`)
console.error(`${localGitChanges.stderr}`);
process.exit(1);
}

if (localGitChanges.stdout) {
console.error(`You have local git changes - are you sure you're ready to release?`)
console.error(`${localGitChanges.stdout}`);
process.exit(1);
}
}

async function getNewVersion() {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const bumpTypeInput = await rl.question('Is this a patch, minor or major (patch)? ');
rl.close();
const bumpType = bumpTypeInput.trim().toLowerCase() || 'patch';
if (!['patch', 'minor', 'major'].includes(bumpType)) {
console.error(`Unknown bump type ${bumpTypeInput} - expected one of "patch", "minor, "major"`)
process.exit(1);
}
return semver.inc(APP_VERSION, bumpType);
}

async function updateConfig(newVersion) {
const defaultConfigPath = path.resolve(__dirname, '../../ghost/core/core/shared/config/defaults.json');
const defaultConfig = require(defaultConfigPath);

allouis marked this conversation as resolved.
Show resolved Hide resolved
const configKey = CONFIG_KEYS[APP_NAME];

defaultConfig[configKey].version = `${semver.major(newVersion)}.${semver.minor(newVersion)}`;

await fs.writeFile(defaultConfigPath, JSON.stringify(defaultConfig, null, 4) + '\n');
}

async function updatePackageJson(newVersion) {
const newPackageJson = Object.assign({}, packageJson, {
version: newVersion
});

await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2) + '\n');
}

async function getChangelog(newVersion) {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const i18nChangesInput = await rl.question('Does this release contain i18n updates (Y/n)? ');
rl.close();

const i18nChanges = i18nChangesInput.trim().toLowerCase() !== 'n';

let changelogItems = [];

if (i18nChanges) {
changelogItems.push('Updated i18n translations');
}

const lastFiftyCommits = await safeExec(`git log -n 50 --oneline .`);

if (lastFiftyCommits.stderr) {
console.error(`There was an error getting the last 50 commits`);
process.exit(1);
}

const lastFiftyCommitsList = lastFiftyCommits.stdout.split('\n');
const releaseRegex = new RegExp(`Released ${APP_NAME} v${APP_VERSION}`);
const indexOfLastRelease = lastFiftyCommitsList.findIndex((commitLine) => {
const commitMessage = commitLine.slice(11); // Take the hash off the front
return releaseRegex.test(commitMessage);
});

if (indexOfLastRelease === -1) {
console.warn(`Could not find commit for previous release.`);
} else {
const lastReleaseCommit = lastFiftyCommitsList[indexOfLastRelease];
const lastReleaseCommitHash = lastReleaseCommit.slice(0, 10);

const commitsSinceLastRelease = await safeExec(`git log ${lastReleaseCommitHash}..HEAD --pretty=format:"%h%n%B__SPLIT__"`);
if (commitsSinceLastRelease.stderr) {
console.error(`There was an error getting commits since the last release`);
process.exit(1);
}
const commitsSinceLastReleaseList = commitsSinceLastRelease.stdout.split('__SPLIT__');

const commitsSinceLastReleaseWhichMentionLinear = commitsSinceLastReleaseList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost');
});

const commitChangelogItems = commitsSinceLastReleaseWhichMentionLinear.map((commitBlock) => {
const [hash] = commitBlock.split('\n');
return `https://github.com/TryGhost/Ghost/commit/${hash}`;
});
changelogItems.push(...commitChangelogItems);
}

const changelogList = changelogItems.map(item => ` - ${item}`).join('\n');
return `Changelog for v${APP_VERSION} -> ${newVersion}: \n${changelogList}`;
}

async function main() {
await ensureEnabledApp();
await ensureNotOnMain();
await ensureCleanGit();

console.log(`Running release for ${APP_NAME}`);
console.log(`Current version is ${APP_VERSION}`);

const newVersion = await getNewVersion();

console.log(`Bumping to version ${newVersion}`);

const changelog = await getChangelog(newVersion);

await updatePackageJson(newVersion);
await exec(`git add package.json`);

await updateConfig(newVersion);
await exec(`git add ../../ghost/core/core/shared/config/defaults.json`);

await exec(`git commit -m 'Released ${APP_NAME} v${newVersion}\n\n${changelog}'`);

console.log(`Release commit created - please double check it and use "git commit --amend" to make any changes before opening a PR to merge into main`)
}

main();
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1071,3 +1071,70 @@ jobs:
with:
url: |
https://cdn.jsdelivr.net/ghost/admin-x-activitypub@0/dist/admin-x-activitypub.js

publish_portal:
needs: [
job_setup,
job_lint,
job_unit-tests
]
name: Publish @tryghost/portal
runs-on: ubuntu-latest
if: always() && needs.job_setup.result == 'success' && needs.job_lint.result == 'success' && needs.job_unit-tests.result == 'success' && needs.job_setup.outputs.is_main == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

- name: Restore caches
uses: ./.github/actions/restore-cache
env:
DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }}

- name: Check if version changed
id: version_check
working-directory: apps/portal
run: |
CURRENT_VERSION=$(cat package.json | jq -r .version)
PUBLISHED_VERSION=$(npm show @tryghost/portal version || echo "0.0.0")
echo "Current version: $CURRENT_VERSION"
echo "Published version: $PUBLISHED_VERSION"

CURRENT_MINOR=$(cat package.json | jq -r .version | awk -F. '{print $1"."$2}')
echo "current_minor=$CURRENT_MINOR" >> $GITHUB_ENV

if [ "$CURRENT_VERSION" = "$PUBLISHED_VERSION" ]; then
echo "Version is unchanged."
echo "version_changed=false" >> $GITHUB_ENV
else
echo "Version has changed."
echo "version_changed=true" >> $GITHUB_ENV
fi

- name: Build the package
if: env.version_changed == 'true'
run: yarn run nx build @tryghost/portal

- name: Configure .npmrc
if: env.version_changed == 'true'
run: |
echo "@tryghost:registry=https://registry.npmjs.org/" >> ~/.npmrc
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc

- name: Publish to npm
if: env.version_changed == 'true'
working-directory: apps/portal
allouis marked this conversation as resolved.
Show resolved Hide resolved
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

- name: Purge jsdelivr cache
if: env.version_changed == 'true'
uses: gacts/purge-jsdelivr-cache@v1
with:
url: |
https://cdn.jsdelivr.net/ghost/portal@~{{ env.current_minor }}/umd/portal.min.js
9 changes: 6 additions & 3 deletions apps/portal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,18 @@ In order to have Ghost's e2e tests run against the new code on CI or to test the
### Patch release

1. Run `yarn ship` and select a patch version when prompted
2. (Optional) Clear JsDelivr cache to get the new version out instantly ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/portal.min.js` and
`https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/main.css` in your browser, where `PORTAL_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L185))
2. Merge the release commit to `main`

### Minor / major release

1. Run `yarn ship` and select a minor or major version when prompted
2. Update the Portal version in `ghost/core/core/shared/config/defaults.json` to the new minor or major version ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
2. Merge the release commit to `main`
3. Wait until a new version of Ghost is released

### JsDelivr cache
If the CI doesn't clear JsDelivr cache to get the new version out instantly, you may want to do it yourself manually ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/portal.min.js` and
`https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/main.css` in your browser, where `PORTAL_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L185))

# Copyright & License

Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE).
3 changes: 1 addition & 2 deletions apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
"test:unit": "yarn test:ci",
"lint": "eslint src --ext .js --cache",
"preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain .); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; else echo \"Uncommitted changes found.\" && exit 1; fi",
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
"ship": "node ../../.github/scripts/release-apps.js",
"prepublishOnly": "yarn build"
},
"eslintConfig": {
Expand Down
50 changes: 35 additions & 15 deletions ghost/core/core/shared/config/defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,15 @@
"blocked_email_domains": []
},
"caching": {
"301": {
"maxAge": 31536000
},
"frontend": {
"maxAge": 0
},
"publicAssets": {
"maxAge": 31536000
},
"301": {
"maxAge": 31536000
},
"customRedirects": {
"maxAge": 31536000
},
Expand Down Expand Up @@ -233,18 +233,38 @@
"gravatar": {
"url": "https://www.gravatar.com/avatar/{hash}?s={size}&r={rating}&d={_default}"
},
"milestones":
{
"arr": [
{
"currency": "usd",
"values": [0, 100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
}
],
"members": [0, 100, 1000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000],
"minDaysSinceImported": 7,
"minDaysSinceLastEmail": 14
},
"milestones": {
"arr": [
{
"currency": "usd",
"values": [
0,
100,
1000,
10000,
50000,
100000,
250000,
500000,
1000000
]
}
],
"members": [
0,
100,
1000,
10000,
25000,
50000,
100000,
250000,
500000,
1000000
],
"minDaysSinceImported": 7,
"minDaysSinceLastEmail": 14
},
"bulkEmail": {
"batchSize": 1000,
"captureLinkClickBadMemberUuid": false
Expand Down
Loading