mirror of
https://github.com/tabler/tabler.git
synced 2026-10-21 07:14:30 +04:00
Release patches from dev and the next minor from next (#3316)
This commit is contained in:
1 parent
462835fbab
commit
0521b2c41a
12 files changed
+167
-51
No files matched your search
+17
-41
@@ -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`)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
- next
|
||||
schedule:
|
||||
- cron: '0 3 * * 1'
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user