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
1315env :
1416 FORCE_COLOR : 1
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)"
0 commit comments