Skip to content

Commit 46e314a

Browse files
committed
Moved release automation from Ghost-Release into Ghost monorepo
ref https://linear.app/ghost/issue/BER-3313/ Consolidates the Ghost-Release private repo into the Ghost monorepo. The release orchestration (version bumping, theme updates, tag creation) becomes scripts/release.js, triggered by a workflow_dispatch + scheduled workflow in release.yml. Tag-triggered npm publishing via OIDC and GitHub Release creation are absorbed into ci.yml, eliminating the redundant full rebuild that Ghost-Release performed. The npm package published to self-hosters is now the same artifact that CI tested.
1 parent 5a4041c commit 46e314a

11 files changed

Lines changed: 916 additions & 160 deletions

File tree

.github/workflows/ci.yml

Lines changed: 170 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ on:
99
- main
1010
- 'v[0-9]+.*' # Matches any release branch, e.g. v6.0.3, v12.1.0
1111
- '[0-9]+.x' # Matches any major version branch, e.g. 5.x, 23.x
12+
tags:
13+
- 'v[0-9]*' # Version tags trigger release publishing (npm, GitHub Release)
1214

1315
env:
1416
FORCE_COLOR: 1
@@ -39,6 +41,7 @@ jobs:
3941
timeout-minutes: 15
4042
env:
4143
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
44+
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/v') }}
4245
IS_DEVELOPMENT: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/5.x' || github.ref == 'refs/heads/6.x' }}
4346
IS_SIX: ${{ github.ref == 'refs/heads/6.x' }}
4447
IS_SIX_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref == '6.x' }}
@@ -212,6 +215,7 @@ jobs:
212215
changed_new_package: ${{ steps.added.outputs.new-package }}
213216
base_commit: ${{ env.BASE_COMMIT }}
214217
is_main: ${{ env.IS_MAIN }}
218+
is_tag: ${{ env.IS_TAG }}
215219
is_development: ${{ env.IS_DEVELOPMENT }}
216220
is_six: ${{ env.IS_SIX }}
217221
is_six_pr: ${{ env.IS_SIX_PR }}
@@ -245,7 +249,7 @@ jobs:
245249
job_lint:
246250
runs-on: ubuntu-latest
247251
needs: [job_setup]
248-
if: needs.job_setup.outputs.changed_any_code == 'true'
252+
if: needs.job_setup.outputs.changed_any_code == 'true' && needs.job_setup.outputs.is_tag != 'true'
249253
name: Lint
250254
steps:
251255
- uses: actions/checkout@v6
@@ -281,11 +285,12 @@ jobs:
281285
needs: [job_setup]
282286
name: i18n
283287
if: |
284-
needs.job_setup.outputs.changed_comments_ui == 'true'
288+
needs.job_setup.outputs.is_tag != 'true'
289+
&& (needs.job_setup.outputs.changed_comments_ui == 'true'
285290
|| needs.job_setup.outputs.changed_signup_form == 'true'
286291
|| needs.job_setup.outputs.changed_sodo_search == 'true'
287292
|| needs.job_setup.outputs.changed_portal == 'true'
288-
|| needs.job_setup.outputs.changed_core == 'true'
293+
|| needs.job_setup.outputs.changed_core == 'true')
289294
steps:
290295
- uses: actions/checkout@v6
291296
- uses: actions/setup-node@v4
@@ -303,7 +308,7 @@ jobs:
303308
job_admin-tests:
304309
runs-on: ubuntu-latest
305310
needs: [job_setup]
306-
if: needs.job_setup.outputs.changed_admin == 'true'
311+
if: needs.job_setup.outputs.changed_admin == 'true' && needs.job_setup.outputs.is_tag != 'true'
307312
name: Admin tests - Chrome
308313
env:
309314
MOZ_HEADLESS: 1
@@ -344,7 +349,7 @@ jobs:
344349
job_unit-tests:
345350
runs-on: ubuntu-latest
346351
needs: [job_setup]
347-
if: needs.job_setup.outputs.changed_any_code == 'true'
352+
if: needs.job_setup.outputs.changed_any_code == 'true' && needs.job_setup.outputs.is_tag != 'true'
348353
strategy:
349354
matrix:
350355
node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }}
@@ -404,7 +409,7 @@ jobs:
404409
job_acceptance-tests:
405410
runs-on: ubuntu-latest
406411
needs: [job_setup]
407-
if: needs.job_setup.outputs.changed_core == 'true'
412+
if: needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_tag != 'true'
408413
services:
409414
mysql:
410415
image: ${{ matrix.env.DB == 'mysql8' && 'mysql:8.0' || '' }}
@@ -492,7 +497,7 @@ jobs:
492497
job_legacy-tests:
493498
runs-on: ubuntu-latest
494499
needs: [job_setup]
495-
if: needs.job_setup.outputs.changed_core == 'true'
500+
if: needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_tag != 'true'
496501
services:
497502
mysql:
498503
image: ${{ matrix.env.DB == 'mysql8' && 'mysql:8.0' || '' }}
@@ -565,7 +570,7 @@ jobs:
565570
job_admin_x_settings:
566571
runs-on: ubuntu-latest
567572
needs: [job_setup]
568-
if: needs.job_setup.outputs.changed_admin_x_settings == 'true'
573+
if: needs.job_setup.outputs.changed_admin_x_settings == 'true' && needs.job_setup.outputs.is_tag != 'true'
569574
name: Admin-X Settings tests
570575
env:
571576
CI: true
@@ -605,7 +610,7 @@ jobs:
605610
job_activitypub:
606611
runs-on: ubuntu-latest
607612
needs: [job_setup]
608-
if: needs.job_setup.outputs.changed_activitypub == 'true'
613+
if: needs.job_setup.outputs.changed_activitypub == 'true' && needs.job_setup.outputs.is_tag != 'true'
609614
name: ActivityPub tests
610615
env:
611616
CI: true
@@ -645,7 +650,7 @@ jobs:
645650
job_comments_ui:
646651
runs-on: ubuntu-latest
647652
needs: [job_setup]
648-
if: needs.job_setup.outputs.changed_comments_ui == 'true'
653+
if: needs.job_setup.outputs.changed_comments_ui == 'true' && needs.job_setup.outputs.is_tag != 'true'
649654
name: Comments-UI tests
650655
env:
651656
CI: true
@@ -685,7 +690,7 @@ jobs:
685690
job_signup_form:
686691
runs-on: ubuntu-latest
687692
needs: [job_setup]
688-
if: needs.job_setup.outputs.changed_signup_form == 'true'
693+
if: needs.job_setup.outputs.changed_signup_form == 'true' && needs.job_setup.outputs.is_tag != 'true'
689694
name: Signup-form tests
690695
env:
691696
CI: true
@@ -726,7 +731,7 @@ jobs:
726731
name: Tinybird Tests
727732
runs-on: ubuntu-latest
728733
needs: [job_setup]
729-
if: needs.job_setup.outputs.changed_tinybird == 'true'
734+
if: needs.job_setup.outputs.changed_tinybird == 'true' && needs.job_setup.outputs.is_tag != 'true'
730735
defaults:
731736
run:
732737
working-directory: ghost/core/core/server/data/tinybird
@@ -756,7 +761,7 @@ jobs:
756761
job_ghost-cli:
757762
name: Ghost-CLI tests
758763
needs: [job_setup]
759-
if: needs.job_setup.outputs.changed_core == 'true'
764+
if: needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_tag != 'true'
760765
runs-on: ubuntu-latest
761766
steps:
762767
- uses: actions/checkout@v6
@@ -814,8 +819,8 @@ jobs:
814819
env:
815820
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
816821

817-
job_docker_build_production:
818-
name: Build & Push Production Docker Images
822+
job_build_artifacts:
823+
name: Build & Publish Artifacts
819824
needs: [job_setup]
820825
runs-on: ubuntu-latest
821826
permissions:
@@ -848,9 +853,33 @@ jobs:
848853
fi
849854
yarn build:production
850855
856+
- name: Verify tag matches package.json
857+
if: startsWith(github.ref, 'refs/tags/v')
858+
working-directory: ghost/core
859+
run: |
860+
PKG_VERSION=$(node -p "require('./package.json').version")
861+
TAG_VERSION="${GITHUB_REF_NAME#v}"
862+
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
863+
echo "::error::Tag ${GITHUB_REF_NAME} doesn't match package.json version ${PKG_VERSION}"
864+
exit 1
865+
fi
866+
851867
- name: Pack standalone distribution
852868
run: yarn workspace ghost pack:standalone
853869

870+
- name: Create npm tarball
871+
if: startsWith(github.ref, 'refs/tags/v')
872+
run: yarn workspace ghost pack:tarball
873+
874+
- name: Upload npm tarball
875+
if: startsWith(github.ref, 'refs/tags/v')
876+
uses: actions/upload-artifact@v4
877+
with:
878+
name: ghost-npm-tarball
879+
path: ghost/core/ghost-*.tgz
880+
retention-days: 7
881+
if-no-files-found: error
882+
854883
- name: Prepare Docker build context
855884
run: mv ghost/core/package/ /tmp/ghost-production/
856885

@@ -882,6 +911,12 @@ jobs:
882911
IMAGE_FULL_NAME="ghcr.io/${OWNER}/${REPO_NAME}"
883912
fi
884913
914+
# Force push on tag pushes (release images must always be published)
915+
IS_TAG="${{ startsWith(github.ref, 'refs/tags/v') }}"
916+
if [ "$IS_TAG" = "true" ]; then
917+
USE_ARTIFACT="false"
918+
fi
919+
885920
echo "use-artifact=$USE_ARTIFACT" >> $GITHUB_OUTPUT
886921
echo "should-push=$( [ "$USE_ARTIFACT" = "false" ] && echo "true" || echo "false" )" >> $GITHUB_OUTPUT
887922
echo "owner=$OWNER" >> $GITHUB_OUTPUT
@@ -917,6 +952,9 @@ jobs:
917952
type=ref,event=branch
918953
type=ref,event=pr
919954
type=sha
955+
type=semver,pattern=v{{version}}
956+
type=semver,pattern={{version}}
957+
type=semver,pattern={{major}}.{{minor}}
920958
type=raw,value=latest,enable={{is_default_branch}}
921959
labels: |
922960
org.opencontainers.image.title=Ghost Core
@@ -932,6 +970,9 @@ jobs:
932970
type=ref,event=branch
933971
type=ref,event=pr
934972
type=sha
973+
type=semver,pattern=v{{version}}
974+
type=semver,pattern={{version}}
975+
type=semver,pattern={{major}}.{{minor}}
935976
type=raw,value=latest,enable={{is_default_branch}}
936977
labels: |
937978
org.opencontainers.image.title=Ghost
@@ -1041,7 +1082,8 @@ jobs:
10411082
job_e2e_tests:
10421083
name: E2E Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
10431084
runs-on: ubuntu-latest
1044-
needs: [job_docker_build_production, job_setup]
1085+
needs: [job_build_artifacts, job_setup]
1086+
if: needs.job_setup.outputs.is_tag != 'true'
10451087
strategy:
10461088
fail-fast: true
10471089
matrix:
@@ -1070,8 +1112,8 @@ jobs:
10701112
uses: ./.github/actions/load-docker-image
10711113
id: load
10721114
with:
1073-
use-artifact: ${{ needs.job_docker_build_production.outputs.use-artifact }}
1074-
image-tags: ${{ needs.job_docker_build_production.outputs.image-tags }}
1115+
use-artifact: ${{ needs.job_build_artifacts.outputs.use-artifact }}
1116+
image-tags: ${{ needs.job_build_artifacts.outputs.image-tags }}
10751117
artifact-name: docker-image-production
10761118

10771119
- name: Setup Docker Registry Mirrors
@@ -1286,8 +1328,12 @@ jobs:
12861328
- name: Output needs
12871329
run: echo "${{ toJson(needs) }}"
12881330

1331+
- name: Pass on tag pushes (tests already ran on branch)
1332+
if: needs.job_setup.outputs.is_tag == 'true'
1333+
run: echo "Tag push — tests skipped (already tested on branch commit)"
1334+
12891335
- name: Check if any required jobs failed or been cancelled
1290-
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
1336+
if: needs.job_setup.outputs.is_tag != 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
12911337
run: |
12921338
echo "One of the dependent jobs have failed or been cancelled. You may need to re-run it." && exit 1
12931339
@@ -1467,15 +1513,15 @@ jobs:
14671513
# Trigger Pro CD — dispatch to Ghost-Moya cd.yml (runs on main + PRs)
14681514
# --------------------------------------------------------------------------- #
14691515
trigger_cd:
1470-
needs: [job_setup, job_docker_build_production]
1516+
needs: [job_setup, job_build_artifacts]
14711517
name: Trigger Pro CD
14721518
runs-on: ubuntu-latest
14731519
if: |
14741520
always()
14751521
&& github.repository == 'TryGhost/Ghost'
14761522
&& needs.job_setup.result == 'success'
1477-
&& needs.job_docker_build_production.result == 'success'
1478-
&& needs.job_docker_build_production.outputs.use-artifact != 'true'
1523+
&& needs.job_build_artifacts.result == 'success'
1524+
&& needs.job_build_artifacts.outputs.use-artifact != 'true'
14791525
steps:
14801526
- name: Determine dispatch parameters
14811527
id: params
@@ -1506,10 +1552,110 @@ jobs:
15061552
event-type: ghost-artifacts-ready
15071553
client-payload: >-
15081554
{
1509-
"ref": "${{ github.sha }}",
1555+
"ref": "${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || github.sha }}",
15101556
"source_repo": "${{ github.repository }}",
15111557
"pr_number": "${{ steps.params.outputs.pr_number }}",
15121558
"deploy": "${{ steps.params.outputs.deploy }}",
1513-
"admin_artifact_id": "${{ needs.job_docker_build_production.outputs.admin-artifact-id }}",
1559+
"admin_artifact_id": "${{ needs.job_build_artifacts.outputs.admin-artifact-id }}",
15141560
"admin_artifact_run_id": "${{ github.run_id }}"
15151561
}
1562+
1563+
# --------------------------------------------------------------------------- #
1564+
# Publish Ghost npm package — runs on version tags only (OIDC, no stored token)
1565+
# --------------------------------------------------------------------------- #
1566+
publish_ghost:
1567+
needs: [job_setup, job_build_artifacts]
1568+
name: Publish Ghost to npm
1569+
runs-on: ubuntu-latest
1570+
if: |
1571+
startsWith(github.ref, 'refs/tags/v')
1572+
&& github.repository == 'TryGhost/Ghost'
1573+
&& needs.job_build_artifacts.result == 'success'
1574+
environment: npm-release
1575+
permissions:
1576+
id-token: write
1577+
steps:
1578+
- name: Download npm tarball
1579+
uses: actions/download-artifact@v4
1580+
with:
1581+
name: ghost-npm-tarball
1582+
1583+
- name: Set up Node.js
1584+
uses: actions/setup-node@v4
1585+
with:
1586+
node-version: ${{ env.NODE_VERSION }}
1587+
1588+
# TODO: Remove once Node v24 ships with npm >= 11
1589+
- name: Install npm v11 (required for OIDC publishing)
1590+
run: npm install -g npm@11
1591+
1592+
- name: Verify tarball contents
1593+
run: |
1594+
echo "Tarball contents:"
1595+
tar -tzf ghost-*.tgz | head -20
1596+
tar -tzf ghost-*.tgz | grep -q 'package/yarn.lock' || { echo "::error::yarn.lock not found in tarball"; exit 1; }
1597+
1598+
- name: Publish to npm
1599+
run: npm publish ghost-*.tgz --access public
1600+
1601+
- uses: tryghost/actions/actions/slack-build@main
1602+
if: failure()
1603+
with:
1604+
status: ${{ job.status }}
1605+
env:
1606+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
1607+
1608+
# --------------------------------------------------------------------------- #
1609+
# Create GitHub Release — runs after successful npm publish
1610+
# --------------------------------------------------------------------------- #
1611+
create_github_release:
1612+
needs: [publish_ghost]
1613+
name: Create GitHub Release
1614+
runs-on: ubuntu-latest
1615+
if: startsWith(github.ref, 'refs/tags/v')
1616+
permissions:
1617+
contents: write
1618+
env:
1619+
GH_TOKEN: ${{ secrets.CANARY_DOCKER_BUILD }}
1620+
steps:
1621+
- uses: actions/checkout@v6
1622+
with:
1623+
fetch-depth: 0
1624+
1625+
- name: Resolve previous tag
1626+
id: prev_tag
1627+
run: |
1628+
CURRENT_TAG="${GITHUB_REF_NAME}"
1629+
# Find the tag immediately before this one (excluding pre-releases)
1630+
PREV_TAG=$(git tag --list 'v[0-9]*' --sort=-version:refname | grep -v '-' | grep -v "^${CURRENT_TAG}$" | head -n 1)
1631+
echo "tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
1632+
echo "Previous tag: ${PREV_TAG}"
1633+
1634+
- name: Generate release notes
1635+
id: notes
1636+
run: |
1637+
node scripts/lib/release-notes.js "${{ steps.prev_tag.outputs.tag }}" "${GITHUB_REF_NAME}" > /tmp/release-notes.md
1638+
cat /tmp/release-notes.md
1639+
1640+
- name: Create GitHub Release
1641+
run: |
1642+
gh release create "${GITHUB_REF_NAME}" \
1643+
--title "${GITHUB_REF_NAME}" \
1644+
--notes-file /tmp/release-notes.md
1645+
1646+
- name: Notify Slack
1647+
if: always() && steps.notes.outcome == 'success'
1648+
run: |
1649+
VERSION="${GITHUB_REF_NAME}"
1650+
RELEASE_URL="https://github.com/TryGhost/Ghost/releases/tag/${VERSION}"
1651+
CHANGELOG=$(cat /tmp/release-notes.md | head -c 3000)
1652+
1653+
# Build Slack payload
1654+
PAYLOAD=$(jq -n \
1655+
--arg text "👻 *Ghost ${VERSION} is loose!* — <${RELEASE_URL}|Release Notes>\n\n${CHANGELOG}" \
1656+
'{text: $text}')
1657+
1658+
curl -sf -X POST \
1659+
-H 'Content-type: application/json' \
1660+
--data "${PAYLOAD}" \
1661+
"${{ secrets.RELEASE_NOTIFICATION_URL }}" || echo "Slack notification failed (non-fatal)"

.github/workflows/deploy-to-staging.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ jobs:
6464
if [ "$CONCLUSION" = "success" ] || [ "$CONCLUSION" = "failure" ]; then
6565
# Check if Docker build job specifically succeeded (paginate — CI has 30+ jobs)
6666
BUILD_JOB=$(gh api --paginate "repos/${{ github.repository }}/actions/runs/${RUN_ID}/jobs?per_page=100" \
67-
--jq '.jobs[] | select(.name == "Build & Push Production Docker Images") | .conclusion')
67+
--jq '.jobs[] | select(.name == "Build & Publish Artifacts") | .conclusion')
6868
if [ -z "$BUILD_JOB" ]; then
69-
echo "::error::Build & Push Production Docker Images job not found in CI run ${RUN_ID}"
69+
echo "::error::Build & Publish Artifacts job not found in CI run ${RUN_ID}"
7070
exit 1
7171
elif [ "$BUILD_JOB" = "success" ]; then
7272
echo "Docker build ready (CI run $RUN_ID)"

0 commit comments

Comments
 (0)