Release patches from dev and the next minor from next (#3316)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-10-19 18:07:12 +02:00
1 parent 462835fbab
commit 0521b2c41a
12 files changed
+167 -51

No files matched your search

+17 -41
View File
@@ -4,57 +4,35 @@
//
// 1. refreshes shared/data/sri.json for that version (`generate:sri --wait`),
// 2. commits it and pushes `dev`,
// 3. merges `dev` into `main` and pushes `main`.
// 3. merges `dev` into `main` and pushes `main`,
// 4. merges `dev` into `next`, when that branch exists, so the next minor carries the fix.
//
// The merge happens in a temporary worktree, so the current checkout (and a dev server running
// from it) never switches branches.
// The merges happen in a temporary worktree, so the current checkout (and a dev server running
// from it) never switches branches. A merge conflict stops the script before that push; the
// error says how to finish the merge by hand.
//
// Run: pnpm run release:main (does all of the above)
// pnpm run release:main --dry-run (refreshes and commits locally, pushes nothing)
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { commitSriHashes, coreVersion, fetchBranchIfExists, git, mergeAndPush, requireBranch, run } from './release-merge'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, '..')
const sriFile = 'shared/data/sri.json'
const script = 'release:main'
const dryRun = process.argv.includes('--dry-run')
const git = (args: string[], cwd = repoRoot): string => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
const run = (command: string, args: string[]): void => {
execFileSync(command, args, { cwd: repoRoot, stdio: 'inherit' })
}
const step = (message: string): void => {
console.log(`\nrelease:main: ${message}`)
console.log(`\n${script}: ${message}`)
}
const { version } = JSON.parse(readFileSync(join(repoRoot, 'core', 'package.json'), 'utf8')) as { version: string }
const main = (): void => {
if (git(['branch', '--show-current']) !== 'dev') {
throw new Error('release:main: run this on the `dev` branch')
}
if (git(['status', '--porcelain', '--untracked-files=no']) !== '') {
throw new Error('release:main: the working tree has uncommitted changes - commit or stash them first')
}
requireBranch(script, 'dev')
const version = coreVersion()
step('updating dev from origin')
git(['fetch', 'origin', 'dev', 'main'])
git(['merge', '--ff-only', 'origin/dev'])
step(`refreshing the SRI hashes for @tabler/core v${version}`)
run(join(repoRoot, 'node_modules', '.bin', 'tsx'), [join('.build', 'generate-sri.ts'), '--wait'])
if (git(['status', '--porcelain', '--', sriFile]) === '') {
console.log(`release:main: ${sriFile} is already up to date`)
} else {
git(['commit', '-m', `Update SRI hashes for v${version}`, '--', sriFile])
console.log(`release:main: committed ${sriFile}`)
}
commitSriHashes(script, version)
if (dryRun) {
step('--dry-run: stopping before any push')
@@ -65,15 +43,13 @@ const main = (): void => {
run('git', ['push', 'origin', 'dev'])
step('merging dev into main')
const worktree = mkdtempSync(join(tmpdir(), 'tabler-main-'))
mergeAndPush(script, 'dev', 'main', `Merge dev into main for v${version}`)
try {
git(['worktree', 'add', '--detach', worktree, 'origin/main'])
git(['merge', '--no-edit', '-m', `Merge dev into main for v${version}`, 'dev'], worktree)
execFileSync('git', ['push', 'origin', 'HEAD:main'], { cwd: worktree, stdio: 'inherit' })
} finally {
git(['worktree', 'remove', '--force', worktree])
rmSync(worktree, { recursive: true, force: true })
if (fetchBranchIfExists('next')) {
step('merging dev into next')
mergeAndPush(script, 'dev', 'next', `Merge dev into next after v${version}`)
} else {
step('no next branch - skipping the merge into next')
}
step(`done - docs.tabler.io deploys v${version} from main`)
+90
View File
@@ -0,0 +1,90 @@
// Steps of `release:main`: refresh the SRI hashes for the version in core/package.json, and
// merge one branch into another through a temporary worktree, so the current checkout (and a
// dev server running from it) never switches branches.
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export const repoRoot = join(__dirname, '..')
const sriFile = 'shared/data/sri.json'
export const git = (args: string[], cwd = repoRoot): string => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
export const run = (command: string, args: string[], cwd = repoRoot): void => {
execFileSync(command, args, { cwd, stdio: 'inherit' })
}
export const coreVersion = (): string => {
const { version } = JSON.parse(readFileSync(join(repoRoot, 'core', 'package.json'), 'utf8')) as { version: string }
return version
}
/** Fails unless the checkout is on `branch` and clean. */
export const requireBranch = (script: string, branch: string): string => {
const current = git(['branch', '--show-current'])
if (current !== branch) {
throw new Error(`${script}: run this on the \`${branch}\` branch`)
}
if (git(['status', '--porcelain', '--untracked-files=no']) !== '') {
throw new Error(`${script}: the working tree has uncommitted changes - commit or stash them first`)
}
return current
}
/** Whether `origin` has `branch`; fetches it when it does, so `origin/<branch>` is current. */
export const fetchBranchIfExists = (branch: string): boolean => {
if (git(['ls-remote', '--heads', 'origin', branch]) === '') return false
git(['fetch', 'origin', branch])
return true
}
/** Runs `generate:sri --wait` and commits shared/data/sri.json when it changed. */
export const commitSriHashes = (script: string, version: string): void => {
run(join(repoRoot, 'node_modules', '.bin', 'tsx'), [join('.build', 'generate-sri.ts'), '--wait'])
if (git(['status', '--porcelain', '--', sriFile]) === '') {
console.log(`${script}: ${sriFile} is already up to date`)
} else {
git(['commit', '-m', `Update SRI hashes for v${version}`, '--', sriFile])
console.log(`${script}: committed ${sriFile}`)
}
}
// Files two release lines always change on the same lines: the version fields and the top of
// the changelogs. On a conflict there the target branch is right (its version is newer, its
// changelog gets the entry from the GitHub release), so the merge keeps the target's side.
const versionFiles = /(^|\/)(package\.json|CHANGELOG\.md)$/
/**
* Merges the local branch `source` into `origin/<target>` in a temporary worktree and pushes
* the result to `target`. Conflicts in version files are resolved for the target; any other
* conflict aborts the merge and throws before anything is pushed.
*/
export const mergeAndPush = (script: string, source: string, target: string, message: string): void => {
const worktree = mkdtempSync(join(tmpdir(), `tabler-${target.replace(/\//g, '-')}-`))
try {
git(['worktree', 'add', '--detach', worktree, `origin/${target}`])
try {
git(['merge', '--no-edit', '-m', message, source], worktree)
} catch {
const conflicts = git(['diff', '--name-only', '--diff-filter=U'], worktree).split('\n').filter(Boolean)
const other = conflicts.filter((file) => !versionFiles.test(file))
if (other.length > 0) {
git(['merge', '--abort'], worktree)
throw new Error(`${script}: merging ${source} into ${target} has conflicts in ${other.join(', ')} - merge \`origin/${source}\` into \`${target}\` by hand, keep the version and the changelog of \`${target}\`, and push it`)
}
console.log(`${script}: keeping the ${target} side of ${conflicts.join(', ')}`)
git(['checkout', '--ours', '--', ...conflicts], worktree)
git(['add', '--', ...conflicts], worktree)
git(['commit', '--no-edit', '-m', message], worktree)
}
run('git', ['push', 'origin', `HEAD:${target}`], worktree)
} finally {
git(['worktree', 'remove', '--force', worktree])
rmSync(worktree, { recursive: true, force: true })
}
}
+5 -3
View File
@@ -12,6 +12,7 @@ on:
push:
branches:
- dev
- next
permissions:
contents: read
@@ -31,9 +32,10 @@ jobs:
run: pnpm exec turbo build
# Nothing the last release shipped may disappear in a minor release. On the
# versions pull request it runs with --strict, but workflows there wait for
# a manual approval, so the Release workflow runs --strict too.
# versions pull request (`changeset-release/dev` or `changeset-release/next`)
# it runs with --strict, but workflows there wait for a manual approval, so
# the Release workflow runs --strict too.
- name: Check backward compatibility
env:
STRICT: ${{ github.head_ref == 'changeset-release/dev' && '--strict' || '' }}
STRICT: ${{ startsWith(github.head_ref, 'changeset-release/') && '--strict' || '' }}
run: pnpm run check:compat $STRICT
+4 -1
View File
@@ -13,6 +13,7 @@ on:
branches:
- main
- dev
- next
permissions:
contents: read
@@ -35,4 +36,6 @@ jobs:
run: pnpm run bundlewatch
env:
BUNDLEWATCH_GITHUB_TOKEN: "${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}"
CI_BRANCH_BASE: dev
# Compare a pull request with its base branch (`dev` or `next`);
# a push records the branch itself as the new baseline.
CI_BRANCH_BASE: ${{ github.base_ref || github.ref_name }}
+1
View File
@@ -13,6 +13,7 @@ on:
branches:
- dev
- main
- next
schedule:
- cron: '0 3 * * 1'
+1
View File
@@ -12,6 +12,7 @@ on:
push:
branches:
- dev
- next
permissions:
contents: read
+1
View File
@@ -13,6 +13,7 @@ on:
branches:
- dev
- main
- next
permissions:
contents: read
+1
View File
@@ -12,6 +12,7 @@ on:
push:
branches:
- dev
- next
permissions:
contents: read
+22 -6
View File
@@ -1,9 +1,14 @@
name: Release
# Two lines can release (see CONTRIBUTING.md, "Releases"):
# dev the next patch of the current minor; `release:main` then merges it into `main`
# next the next minor, in changesets prerelease mode: publishes `x.y.0-next.N` under
# the npm tag `next`, without a GitHub release
on:
push:
branches:
- dev
- next
permissions:
contents: read
@@ -56,6 +61,9 @@ jobs:
# changeset-release/dev via a short-lived changesets-ghcommit-temp/* branch,
# and every one of those branches triggered a Vercel build.
push-with-git-cli: true
# A `next` prerelease is only an npm tag; the GitHub release comes with the
# final version from `dev`.
create-github-releases: ${{ github.ref_name != 'next' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -64,19 +72,26 @@ jobs:
# packages shows up several times. Replace that body with the same notes the
# GitHub release gets: the intro from `.github/release-notes/<version>.md`
# and the changes grouped as core / demo / docs, each listed once. The
# action rewrites the body on every push to dev, so this runs right after it.
# action rewrites the body on every push to the branch, so this runs right
# after it. The version branch is `changeset-release/<branch>`.
- name: Write the release notes into the version pull request
if: steps.changesets.outputs.pr-number != ''
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.changesets.outputs.pr-number }}
BRANCH: ${{ github.ref_name }}
run: |
git fetch --depth=1 origin changeset-release/dev
git checkout --force --detach origin/changeset-release/dev
git fetch --depth=1 origin "changeset-release/${BRANCH}"
git checkout --force --detach "origin/changeset-release/${BRANCH}"
version=$(node -p "require('./core/package.json').version")
if [ "${BRANCH}" = "next" ]; then
what="publishes \`@tabler/core@${version}\` to npm under the \`next\` tag"
else
what="publishes \`@tabler/core@${version}\` to npm and creates the GitHub release"
fi
{
echo "Merging this pull request publishes \`@tabler/core@${version}\` to npm and creates the GitHub release. It is regenerated on every push to \`dev\`."
echo "Merging this pull request ${what}. It is regenerated on every push to \`${BRANCH}\`."
echo
pnpm run --silent release-notes
} > "${RUNNER_TEMP}/pr-body.md"
@@ -84,8 +99,9 @@ jobs:
# `pnpm run build` above zipped preview/dist into packages-zip/; attach it to
# the GitHub release that changesets/action just created for @tabler/core.
# Not on `next`: a prerelease has no GitHub release.
- name: Attach the preview zip to the GitHub release
if: steps.changesets.outputs.published == 'true'
if: steps.changesets.outputs.published == 'true' && github.ref_name != 'next'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -97,7 +113,7 @@ jobs:
# the intro from `.github/release-notes/<version>.md` followed by the changes
# grouped per package.
- name: Write the release notes
if: steps.changesets.outputs.published == 'true'
if: steps.changesets.outputs.published == 'true' && github.ref_name != 'next'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+1
View File
@@ -13,6 +13,7 @@ on:
branches:
- dev
- main
- next
permissions:
contents: read
+1
View File
@@ -13,6 +13,7 @@ on:
branches:
- main
- dev
- next
permissions:
contents: read
+23
View File
@@ -78,6 +78,29 @@ Two of these help with refactors:
4. **Write the PR in English**: a concise title in present tense (max ~72 chars, no trailing period) and a short description that explains why the change is needed.
5. **Keep the scope small**: one logical change per pull request makes reviews faster and releases safer.
## Releases
Only three long-lived branches exist: `main`, `dev` and `next`. There are no `release/*` branches; a fix always ships from `dev` as the next patch of the current minor.
| Branch | Holds | Releases |
| ------ | ----- | -------- |
| `dev` | the current minor: fixes, docs, chores | the next patch (`1.7.1`, `1.7.2`, ...) as `latest` on npm |
| `next` | the next minor: new components, classes, pages | prereleases (`1.8.0-next.0`, `-next.1`, ...) under the npm tag `next`, no GitHub release |
| `main` | the last release | nothing; it builds docs.tabler.io |
Every push to `dev` or `next` updates that branch's `chore: update versions` pull request; merging it publishes to npm. Open a pull request against `dev` for a fix and against `next` for a feature. A fix on `next` that users of the current version need goes to `dev` instead, and reaches `next` with the next sync.
**After a patch release** from `dev`: `pnpm run release:main` (on `dev`) refreshes the SRI hashes, merges `dev` into `main` and merges `dev` into `next`. Run it after every release from `dev`, so `next` never drifts.
**Releasing the next minor** (`next` becomes `1.8.0`):
1. On `next`: `pnpm exec changeset pre exit`, commit `.changeset/pre.json` and push. The changesets consumed by the prereleases are still there; the final changelog lists all of them once.
2. Merge `next` into `dev` (a pull request, or a merge pushed to `dev`). The `chore: update versions` pull request on `dev` now shows `1.8.0`; the `.github/release-notes/1.8.0.md` intro goes in before merging it.
3. Merge the version pull request, wait for npm, run `pnpm run release:main`.
4. Start the next line. `dev` now contains `next`, so it fast-forwards: `git checkout next && git merge --ff-only dev && pnpm exec changeset pre enter next && git commit -am "Enter prerelease mode for 1.9" && git push`. The first time, create the branch with `git checkout -b next dev` instead.
Never commit to `main` directly. Changes reach it only through `release:main`.
## Code style
- Use Bootstrap 5 conventions and Tabler's CSS custom properties pattern: `--component-property`. Write custom properties without a prefix — the build adds the public `--tblr-` prefix. Names owned by a third-party library you are theming must be listed in `cssVarIgnore` in `.build/css-var-prefix.ts` so they stay untouched.