diff --git a/.build/release-main.ts b/.build/release-main.ts index 05e2173d9..df282c151 100644 --- a/.build/release-main.ts +++ b/.build/release-main.ts @@ -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`) diff --git a/.build/release-merge.ts b/.build/release-merge.ts new file mode 100644 index 000000000..96fb38439 --- /dev/null +++ b/.build/release-merge.ts @@ -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/` 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/` 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 }) + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 425e6682b..fdafdc007 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/bundlewatch.yml b/.github/workflows/bundlewatch.yml index 0114db091..28b9802bb 100644 --- a/.github/workflows/bundlewatch.yml +++ b/.github/workflows/bundlewatch.yml @@ -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 }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9908b0255..ff8d3f248 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,6 +13,7 @@ on: branches: - dev - main + - next schedule: - cron: '0 3 * * 1' diff --git a/.github/workflows/html-checks.yml b/.github/workflows/html-checks.yml index 3d4a8a43d..7fe50a24b 100644 --- a/.github/workflows/html-checks.yml +++ b/.github/workflows/html-checks.yml @@ -12,6 +12,7 @@ on: push: branches: - dev + - next permissions: contents: read diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 4acec73d7..25c36b482 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -13,6 +13,7 @@ on: branches: - dev - main + - next permissions: contents: read diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a5737c9e2..ff298e03c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,7 @@ on: push: branches: - dev + - next permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13d073d42..71a59e1ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/.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/`. - 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/.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: | diff --git a/.github/workflows/scss-tests.yml b/.github/workflows/scss-tests.yml index 21f65e5bb..025951655 100644 --- a/.github/workflows/scss-tests.yml +++ b/.github/workflows/scss-tests.yml @@ -13,6 +13,7 @@ on: branches: - dev - main + - next permissions: contents: read diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml index 57b1ecb87..b5ff6b870 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/type-check.yml @@ -13,6 +13,7 @@ on: branches: - main - dev + - next permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5463dee0c..f8721f9e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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.