PR merged to main
-> Quality Checks workflow runs (lint, analyze, test)
-> Release workflow triggers (after Quality Checks passes)
-> semantic-release analyzes commits since last v* tag
-> If feat:/fix:/refactor: found:
-> Calculate next version (custom rollover logic)
-> Generate release notes from conventional commits
-> Create + push git tag (v1.x.y) at the current main HEAD
-> Publish a GitHub Release on that tag with the generated notes
-> Release workflow POSTs to Packagist's update-package API
-> Packagist re-fetches the repo and publishes the new tag
The pipeline is tag-only — it does not push any commits to main. The release tag is created at the HEAD of main produced by the triggering PR merge, which is also the tree Packagist clones when it publishes the version. Release notes live on the GitHub Release for that tag (no CHANGELOG.md is committed to the repo).
This project uses a digit-capped semver scheme. Each version position is capped at 9 and rolls over to the next position:
| Scenario | Current | Bump Type | Next |
|---|---|---|---|
| Normal patch | 1.0.3 | patch | 1.0.4 |
| Patch at cap | 1.0.9 | patch | 1.1.0 |
| Normal minor | 1.2.5 | minor | 1.3.0 |
| Minor at cap | 1.9.3 | minor | 2.0.0 |
| Full cap | 1.9.9 | patch | 2.0.0 |
| Breaking change | 1.2.5 | major | 2.0.0 |
Major bumps happen either directly via BREAKING CHANGE commits (standard semver) or via rollover when a digit exceeds 9.
Only conventional commits trigger releases:
| Commit Type | Release Type | In Release Notes |
|---|---|---|
fix: |
patch | Yes (Bug Fixes) |
feat: |
patch | Yes (Features) |
refactor: |
minor | Yes (Refactoring) |
BREAKING CHANGE (footer) |
major | Yes |
chore:, docs:, ci:, test:, style:, perf: |
no release | No |
When a PR merges to main:
- Quality Checks workflow runs lint, static analysis, monorepo validation, and tests (6-job matrix).
- Release workflow triggers after Quality Checks passes (via
workflow_run). - semantic-release analyzes commits since the last
v*tag. - If releasable commits exist, it:
- Calculates the next version using the rollover plugin.
- Generates release notes from the conventional-commit history.
- Creates and pushes the git tag (e.g.
v1.1.0) at main's current HEAD. - Publishes a GitHub Release on the new tag with the generated notes.
- Release workflow notifies Packagist via its
/api/update-packageendpoint after the tag push; Packagist re-fetches the repo and publishes the new version ofconvertcom/php-sdk.
The pipeline never pushes commits to main — only tags and a GitHub Release object. This means it works with branch-protection rulesets on main (PR-required, code-scanning, etc.) without needing a GitHub App or PAT bypass: tag refs (refs/tags/*) and the Releases REST API are not gated by branch rules.
The repo ships a .yarnrc.yml with:
nodeLinker: node-modulesThis is load-bearing for the release pipeline. Do not remove it, and do not switch to yarn's PnP linker.
Why: @semantic-release/release-notes-generator loads the conventionalcommits preset via import-from-esm, which performs string-based dynamic imports by walking node_modules/. Yarn's default PnP linker does not produce a node_modules/ tree and enforces strict dependency boundaries, so the dynamic import fails with Cannot find module 'conventional-changelog-conventionalcommits' — breaking every yarn release run. The same failure mode would surface for several other semantic-release plugins that use dynamic preset loading.
The classic node-modules linker eliminates this class of problem without changing what yarn installs or locks — only where the files live on disk. This repo uses yarn solely to run semantic-release, so PnP's strict-dependency benefits are not in use; the node-modules linker is the correct choice.
If you see Cannot find module '<some preset>' errors from semantic-release plugins in CI or locally, check .yarnrc.yml is present and contains nodeLinker: node-modules, then re-run yarn install.
One-time setup for the monorepo:
- Go to packagist.org > Submit > enter the monorepo GitHub URL.
- Generate a Packagist API token from your Packagist profile (Profile > Show API Token).
- In the monorepo's GitHub Settings > Secrets and variables > Actions, add:
PACKAGIST_USERNAME— the Packagist account that owns the packagePACKAGIST_API_TOKEN— the API token from step 2
The release workflow calls Packagist's /api/update-package endpoint after each tag push, so no GitHub OAuth grant ("connect your user account") and no repo-level webhook is required. This keeps Packagist sync tied to a shared, company-owned Packagist account rather than any individual contributor's GitHub identity.
To verify what semantic-release would do without actually releasing:
yarn release --dry-runThis analyzes commits and prints the calculated version without creating tags or commits. To run the full pipeline (including generateNotes) on a feature branch for pre-merge verification, push the branch to origin first, then:
yarn release --dry-run --branches $(git rev-parse --abbrev-ref HEAD)The --branches override lets semantic-release treat the current branch as a release branch for the dry-run only; no tags or commits are created.
One-time setup items required before the automated pipeline works:
- Monorepo registered on Packagist pointing at the monorepo GitHub URL
-
PACKAGIST_USERNAMEandPACKAGIST_API_TOKENsecrets configured on the monorepo (see Packagist Setup above) -
yarn installrun once to generateyarn.lock(committed to repo)
The first release is produced automatically by the pipeline -- no manual tagging is required. On the first merge to main after the release workflow is configured, semantic-release observes that no prior v* tag exists, so it:
- Treats every releasable commit in history (all
fix:/feat:/refactor:since project inception) as part of the first release. - Emits
v1.0.0as the version (semantic-release's fixed first-release default, regardless of the rollover logic's bump type). - Generates a correspondingly long release-notes block covering the full history, grouped by commit type.
- Creates and pushes the tag
v1.0.0at main's current HEAD and publishes a GitHub Release on that tag. - Packagist (once registered) picks up
v1.0.0on the tag push and publishes.
Do not create a v1.0.0 tag manually before or after the first merge -- the pipeline owns this, and a pre-existing tag will either be raced or block the automated tag push.
- Check that commits use conventional format (
feat:,fix:,refactor:) chore:,docs:,ci:,test:commits do NOT trigger releases- Run
yarn release --dry-runlocally to debug
- Review the rollover truth table in the versioning scheme section
- Check
scripts/rollover-version-plugin.mjsfor the translation logic - The plugin logs its analysis:
logical=<type>, lastVersion=<ver>, effective=<type>
- Confirm
.yarnrc.ymlcontainsnodeLinker: node-modules(see Environment Requirements) - Run
yarn installto regeneratenode_modules/ - Do not commit
.pnp.*files or switch the linker to PnP
If you need to publish individual packages (convertcom/php-sdk-api, convertcom/php-sdk-bucketing, etc.) to their own Packagist entries, follow the Reactivating 12-Package Split Publishing section below.
This section describes how to switch from the current single-package publishing model (convertcom/php-sdk published from the monorepo root) back to 12 individually published packages, each in its own read-only split repository on GitHub.
The split publishing strategy is appropriate when:
- Consumers need to install individual sub-packages independently (e.g.,
convertcom/php-sdk-bucketingwithout the full SDK) - Package-level versioning diverges (one package gets a breaking change while others stay stable)
- Downstream CI pipelines depend on per-package Packagist webhooks for fine-grained dependency tracking
Until one of these scenarios materializes, the single-package model is simpler to maintain and has no consumer-facing downsides.
- GitHub org admin access to
convertcom(to create repos and manage secrets) - Packagist account with publish rights on
convertcom/*packages - The monorepo checked out locally with push access to
main
Create empty repositories (no README, no license, no initial commit) under the convertcom GitHub organization:
for repo in php-sdk-api php-sdk-bucketing php-sdk-data php-sdk-enums \
php-sdk-event php-sdk-experience php-sdk-logger php-sdk \
php-sdk-rules php-sdk-segments php-sdk-types php-sdk-utils; do
gh repo create "convertcom/$repo" --public \
--description "Convert PHP SDK - ${repo#php-sdk-}" --confirm
doneThe split action pushes the first commit to each repo. Do not initialize them with any content.
- Create a fine-grained Personal Access Token (Settings > Developer settings > Fine-grained tokens) with:
- Repository access: select all 12 split repos created above
- Permissions: Contents (Read and write)
- Add the token as a repository secret named
SPLIT_TOKENin the monorepo's Settings > Secrets and variables > Actions.
The split workflow and release workflow both need this token. GITHUB_TOKEN cannot trigger other workflows (GitHub limitation), so a PAT is required when the release tag push must trigger the split workflow.
Register each of the 12 packages on Packagist:
- Go to packagist.org > Submit > enter the split repo URL (e.g.,
https://github.com/convertcom/php-sdk-api) - Enable the GitHub Service Hook on each split repo, or manually configure a webhook:
- URL:
https://packagist.org/api/github?username=PACKAGIST_USERNAME - Add the Packagist API token as a webhook secret
- URL:
- Alternative: use Packagist's auto-update feature (polls GitHub periodically)
Also update the monorepo's Packagist entry to point back to the convertcom/php-sdk split repo (instead of the monorepo), or deregister the monorepo from Packagist entirely.
Edit .github/workflows/split.yml and change the on: block from:
on:
workflow_dispatch:back to:
on:
push:
branches: [main]
tags: ['v*']This re-enables automatic split propagation on every push to main and on every version tag.
The root composer.json needs to be reverted from "published library" mode back to "monorepo aggregator" mode. Reference commit 556084c for the exact pre-change state.
Changes required:
- Rename
"name"from"convertcom/php-sdk"to"convertcom/php-sdk-monorepo" - Change
"type"from"library"to"project" - Add
"private": true - Restore the 12 path
"repositories"entries:"repositories": [ { "type": "path", "url": "packages/Api" }, { "type": "path", "url": "packages/Bucketing" }, { "type": "path", "url": "packages/Data" }, { "type": "path", "url": "packages/Enums" }, { "type": "path", "url": "packages/Event" }, { "type": "path", "url": "packages/Experience" }, { "type": "path", "url": "packages/Logger" }, { "type": "path", "url": "packages/Rules" }, { "type": "path", "url": "packages/Segments" }, { "type": "path", "url": "packages/Types" }, { "type": "path", "url": "packages/Utils" }, { "type": "path", "url": "packages/Php-sdk" } ]
- Replace the aggregated
"autoload"and external"require"with internal package requires:"require": { "php": "^8.2", "convertcom/php-sdk-api": ">=1.0.0", "convertcom/php-sdk-data": ">=1.0.0", "convertcom/php-sdk-enums": ">=1.0.0", "convertcom/php-sdk-event": ">=1.0.0", "convertcom/php-sdk-logger": ">=1.0.0", "convertcom/php-sdk-utils": ">=1.0.0", "convertcom/php-sdk": ">=1.0.0" }
- Remove the root-level
"autoload"block (PSR-4 autoloading is handled by each package's owncomposer.jsonvia path repositories)
Run rm -rf vendor composer.lock && composer install && composer test to verify everything resolves correctly.
Edit .github/workflows/release.yml:
- In the checkout step, change
token: ${{ secrets.GITHUB_TOKEN }}totoken: ${{ secrets.SPLIT_TOKEN }} - In the semantic-release env, change
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}toGITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
This ensures the tag push from semantic-release triggers the split workflow (PAT-based pushes trigger other workflows; GITHUB_TOKEN pushes do not).
-
Dry-run release:
yarn release --dry-run
Confirm semantic-release calculates the next version without errors.
-
Manual-dispatch split: Go to Actions > Split Monorepo > Run workflow (on
main). All 12 matrix jobs should succeed now that the split repos exist. -
End-to-end test: Push a
feat:commit tomain. Verify:- CI passes
- Release workflow creates a tag
- Split workflow triggers on the tag and propagates to all 12 repos
- Packagist shows the new version for each package