[READ-ONLY] Mirror of https://github.com/danielroe/uppt. A composite GitHub Action that turns conventional commits into a draft release PR, tags the PR on merge, and stages publishing to npm via OIDC
github-action npm npmjs oidc staged-publish trusted-publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(pr): respect `package.json` formatting

resolves https://github.com/danielroe/uppt/issues/32

+138 -14
+5 -1
scripts/_workspaces.ts
··· 19 19 version: string | null 20 20 /** Parsed contents of the workspace `package.json`. */ 21 21 pkg: Record<string, unknown> 22 + /** Raw on-disk contents of the workspace `package.json`, preserved so writes can match the original indentation and line endings. */ 23 + source: string 22 24 } 23 25 24 26 interface RawPackageJson { ··· 119 121 120 122 const workspaces: Workspace[] = [] 121 123 for (const dir of dirs) { 122 - const pkg = JSON.parse(readFileSync(resolve(dir, 'package.json'), 'utf8')) as RawPackageJson 124 + const source = readFileSync(resolve(dir, 'package.json'), 'utf8') 125 + const pkg = JSON.parse(source) as RawPackageJson 123 126 if (pkg.private === true) continue 124 127 if (!pkg.name) { 125 128 throw new Error(`Workspace at ${relative(rootDir, dir) || '.'} has no "name" field in package.json.`) ··· 130 133 name: pkg.name, 131 134 version: typeof pkg.version === 'string' ? pkg.version : null, 132 135 pkg, 136 + source, 133 137 }) 134 138 } 135 139 return workspaces
+11
scripts/pkg-format.ts
··· 1 + export function makePkgFormatter (source: string): (pkg: unknown) => string { 2 + const indentMatch = source.match(/\n([\t ]+)"/) 3 + const indent: string | number = indentMatch ? indentMatch[1]! : 2 4 + const usesCRLF = /\r\n/.test(source) 5 + const trailingNewline = source.endsWith('\r\n') ? '\r\n' : source.endsWith('\n') ? '\n' : '' 6 + return (pkg) => { 7 + let out = JSON.stringify(pkg, null, indent) 8 + if (usesCRLF) out = out.replace(/\n/g, '\r\n') 9 + return out + trailingNewline 10 + } 11 + }
+9 -4
scripts/update-changelog.ts
··· 22 22 import { Buffer } from 'node:buffer' 23 23 import { readFileSync } from 'node:fs' 24 24 import { resolve } from 'node:path' 25 + import { makePkgFormatter } from './pkg-format.ts' 25 26 26 27 import { lockstepVersionFromWorkspaces, resolveWorkspaces, type Workspace } from './_workspaces.ts' 27 28 ··· 456 457 monorepo: boolean 457 458 workspaces: Workspace[] 458 459 rootPkg: Record<string, unknown> 460 + rootPkgSource: string 459 461 currentVersion: string 460 462 newVersion: string 461 463 }): Array<{ path: string, content: string }> { 462 464 const files: Array<{ path: string, content: string }> = [] 465 + const formatRootPkg = makePkgFormatter(opts.rootPkgSource) 463 466 464 467 if (!opts.monorepo) { 465 468 const updated = { ...opts.rootPkg, version: opts.newVersion } 466 - files.push({ path: 'package.json', content: JSON.stringify(updated, null, 2) + '\n' }) 469 + files.push({ path: 'package.json', content: formatRootPkg(updated) }) 467 470 return files 468 471 } 469 472 ··· 472 475 const wsPkg = { ...ws.pkg, version: opts.newVersion } 473 476 const path = ws.relDir === '.' ? 'package.json' : `${ws.relDir}/package.json` 474 477 if (path === 'package.json') rootCoveredByWorkspaces = true 475 - files.push({ path, content: JSON.stringify(wsPkg, null, 2) + '\n' }) 478 + files.push({ path, content: makePkgFormatter(ws.source)(wsPkg) }) 476 479 } 477 480 478 481 if (!rootCoveredByWorkspaces && opts.rootPkg.version === opts.currentVersion) { 479 482 const updated = { ...opts.rootPkg, version: opts.newVersion } 480 - files.push({ path: 'package.json', content: JSON.stringify(updated, null, 2) + '\n' }) 483 + files.push({ path: 'package.json', content: formatRootPkg(updated) }) 481 484 } 482 485 483 486 return files ··· 512 515 : [] 513 516 514 517 const rootPkgPath = resolve(process.cwd(), 'package.json') 515 - const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')) 518 + const rootPkgSource = readFileSync(rootPkgPath, 'utf8') 519 + const rootPkg = JSON.parse(rootPkgSource) 516 520 517 521 const currentVersion = monorepo 518 522 ? lockstepVersionFromWorkspaces(workspaces) ··· 601 605 monorepo, 602 606 workspaces, 603 607 rootPkg, 608 + rootPkgSource, 604 609 currentVersion, 605 610 newVersion, 606 611 })
+2
test/_workspaces.test.ts
··· 113 113 name: 'a', 114 114 version: '1.0.0', 115 115 pkg: { name: 'a', version: '1.0.0' }, 116 + source: JSON.stringify({ name: 'a', version: '1.0.0' }, null, 2), 116 117 }, 117 118 { 118 119 dir: resolve(tmp, 'packages/b'), ··· 120 121 name: 'b', 121 122 version: '1.0.0', 122 123 pkg: { name: 'b', version: '1.0.0' }, 124 + source: JSON.stringify({ name: 'b', version: '1.0.0' }, null, 2), 123 125 }, 124 126 ]) 125 127 })
+48
test/pkg-format.test.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { makePkgFormatter } from '../scripts/pkg-format.ts' 3 + 4 + describe('makePkgFormatter', () => { 5 + it('preserves tab indentation and trailing newline', () => { 6 + const source = '{\n\t"name": "x",\n\t"version": "1.0.0"\n}\n' 7 + const format = makePkgFormatter(source) 8 + expect(format(JSON.parse(source))).toBe(source) 9 + }) 10 + 11 + it('preserves 2-space indentation and trailing newline', () => { 12 + const source = '{\n "name": "x",\n "version": "1.0.0"\n}\n' 13 + const format = makePkgFormatter(source) 14 + expect(format(JSON.parse(source))).toBe(source) 15 + }) 16 + 17 + it('preserves 4-space indentation', () => { 18 + const source = '{\n "name": "x",\n "version": "1.0.0"\n}' 19 + const format = makePkgFormatter(source) 20 + expect(format(JSON.parse(source))).toBe(source) 21 + }) 22 + 23 + it('preserves absence of trailing newline', () => { 24 + const source = '{\n "name": "x"\n}' 25 + const format = makePkgFormatter(source) 26 + expect(format(JSON.parse(source))).toBe(source) 27 + }) 28 + 29 + it('preserves CRLF line endings throughout', () => { 30 + const source = '{\r\n "name": "x",\r\n "version": "1.0.0"\r\n}\r\n' 31 + const format = makePkgFormatter(source) 32 + expect(format(JSON.parse(source))).toBe(source) 33 + }) 34 + 35 + it('falls back to 2-space indent for a minified single-line source', () => { 36 + const source = '{"name":"x","version":"1.0.0"}' 37 + const format = makePkgFormatter(source) 38 + expect(format(JSON.parse(source))).toBe('{\n "name": "x",\n "version": "1.0.0"\n}') 39 + }) 40 + 41 + it('applies the detected format when serialising a mutated value', () => { 42 + const source = '{\n\t"name": "x",\n\t"version": "1.0.0"\n}\n' 43 + const format = makePkgFormatter(source) 44 + const pkg = JSON.parse(source) 45 + pkg.version = '1.1.0' 46 + expect(format(pkg)).toBe('{\n\t"name": "x",\n\t"version": "1.1.0"\n}\n') 47 + }) 48 + })
+63 -9
test/update-changelog.test.ts
··· 16 16 rmSync(tmp, { recursive: true, force: true }) 17 17 }) 18 18 19 - function writePackage (relDir: string, contents: Record<string, unknown>) { 19 + function writePackage (relDir: string, contents: Record<string, unknown>, opts: { indent?: string | number, trailingNewline?: string } = {}) { 20 20 const dir = resolve(tmp, relDir) 21 21 mkdirSync(dir, { recursive: true }) 22 - writeFileSync(resolve(dir, 'package.json'), JSON.stringify(contents, null, 2)) 22 + const indent = opts.indent ?? 2 23 + const trailingNewline = opts.trailingNewline ?? '' 24 + writeFileSync(resolve(dir, 'package.json'), JSON.stringify(contents, null, indent) + trailingNewline) 25 + } 26 + 27 + function sourceOf (pkg: Record<string, unknown>): string { 28 + return JSON.stringify(pkg, null, 2) + '\n' 23 29 } 24 30 25 31 describe('incVersion', () => { ··· 66 72 describe('buildBumpFileSet', () => { 67 73 describe('single-package mode', () => { 68 74 it('rewrites the root package.json with the new version', () => { 75 + const rootPkg = { name: 'pkg', version: '1.2.3' } 69 76 const files = buildBumpFileSet({ 70 77 monorepo: false, 71 78 workspaces: [], 72 - rootPkg: { name: 'pkg', version: '1.2.3' }, 79 + rootPkg, 80 + rootPkgSource: sourceOf(rootPkg), 73 81 currentVersion: '1.2.3', 74 82 newVersion: '1.2.4', 75 83 }) ··· 79 87 }) 80 88 81 89 it('preserves other root fields', () => { 90 + const rootPkg = { name: 'pkg', version: '1.2.3', description: 'hello', private: false } 82 91 const files = buildBumpFileSet({ 83 92 monorepo: false, 84 93 workspaces: [], 85 - rootPkg: { name: 'pkg', version: '1.2.3', description: 'hello', private: false }, 94 + rootPkg, 95 + rootPkgSource: sourceOf(rootPkg), 86 96 currentVersion: '1.2.3', 87 97 newVersion: '1.2.4', 88 98 }) 89 99 const written = JSON.parse(files[0]!.content) 90 100 expect(written).toEqual({ name: 'pkg', version: '1.2.4', description: 'hello', private: false }) 91 101 }) 102 + 103 + it('preserves the existing indentation of the root package.json', () => { 104 + const rootPkg = { name: 'pkg', version: '1.2.3' } 105 + const tabSource = '{\n\t"name": "pkg",\n\t"version": "1.2.3"\n}\n' 106 + const files = buildBumpFileSet({ 107 + monorepo: false, 108 + workspaces: [], 109 + rootPkg, 110 + rootPkgSource: tabSource, 111 + currentVersion: '1.2.3', 112 + newVersion: '1.2.4', 113 + }) 114 + expect(files[0]!.content).toBe('{\n\t"name": "pkg",\n\t"version": "1.2.4"\n}\n') 115 + }) 92 116 }) 93 117 94 118 describe('monorepo mode', () => { ··· 96 120 writePackage('packages/a', { name: 'a', version: '1.2.3' }) 97 121 writePackage('packages/b', { name: 'b', version: '1.2.3' }) 98 122 const workspaces = resolveWorkspaces(tmp, 'packages/*') 123 + const rootPkg = { name: 'root', private: true } 99 124 100 125 const files = buildBumpFileSet({ 101 126 monorepo: true, 102 127 workspaces, 103 - rootPkg: { name: 'root', private: true }, 128 + rootPkg, 129 + rootPkgSource: sourceOf(rootPkg), 104 130 currentVersion: '1.2.3', 105 131 newVersion: '1.2.4', 106 132 }) ··· 113 139 it('leaves the root alone when it has no version', () => { 114 140 writePackage('packages/a', { name: 'a', version: '1.2.3' }) 115 141 const workspaces = resolveWorkspaces(tmp, 'packages/a') 142 + const rootPkg = { name: 'root', private: true } 116 143 117 144 const files = buildBumpFileSet({ 118 145 monorepo: true, 119 146 workspaces, 120 - rootPkg: { name: 'root', private: true }, 147 + rootPkg, 148 + rootPkgSource: sourceOf(rootPkg), 121 149 currentVersion: '1.2.3', 122 150 newVersion: '1.2.4', 123 151 }) ··· 128 156 it('bumps the root when its version equals the current lockstep', () => { 129 157 writePackage('packages/a', { name: 'a', version: '1.2.3' }) 130 158 const workspaces = resolveWorkspaces(tmp, 'packages/a') 159 + const rootPkg = { name: 'root', version: '1.2.3', private: true } 131 160 132 161 const files = buildBumpFileSet({ 133 162 monorepo: true, 134 163 workspaces, 135 - rootPkg: { name: 'root', version: '1.2.3', private: true }, 164 + rootPkg, 165 + rootPkgSource: sourceOf(rootPkg), 136 166 currentVersion: '1.2.3', 137 167 newVersion: '1.2.4', 138 168 }) ··· 144 174 it('leaves the root alone when its version differs from the lockstep', () => { 145 175 writePackage('packages/a', { name: 'a', version: '1.2.3' }) 146 176 const workspaces = resolveWorkspaces(tmp, 'packages/a') 177 + const rootPkg = { name: 'root', version: '0.0.0', private: true } 147 178 148 179 const files = buildBumpFileSet({ 149 180 monorepo: true, 150 181 workspaces, 151 - rootPkg: { name: 'root', version: '0.0.0', private: true }, 182 + rootPkg, 183 + rootPkgSource: sourceOf(rootPkg), 152 184 currentVersion: '1.2.3', 153 185 newVersion: '1.2.4', 154 186 }) ··· 159 191 it('preserves unrelated workspace fields', () => { 160 192 writePackage('packages/a', { name: 'a', version: '1.2.3', dependencies: { foo: 'workspace:^' } }) 161 193 const workspaces = resolveWorkspaces(tmp, 'packages/a') 194 + const rootPkg = { name: 'root', private: true } 162 195 163 196 const files = buildBumpFileSet({ 164 197 monorepo: true, 165 198 workspaces, 166 - rootPkg: { name: 'root', private: true }, 199 + rootPkg, 200 + rootPkgSource: sourceOf(rootPkg), 167 201 currentVersion: '1.2.3', 168 202 newVersion: '1.2.4', 169 203 }) ··· 173 207 version: '1.2.4', 174 208 dependencies: { foo: 'workspace:^' }, 175 209 }) 210 + }) 211 + 212 + it('preserves each workspace package.json indentation independently', () => { 213 + writePackage('packages/a', { name: 'a', version: '1.2.3' }, { indent: '\t', trailingNewline: '\n' }) 214 + writePackage('packages/b', { name: 'b', version: '1.2.3' }, { indent: 4, trailingNewline: '\n' }) 215 + const workspaces = resolveWorkspaces(tmp, 'packages/*') 216 + const rootPkg = { name: 'root', private: true } 217 + 218 + const files = buildBumpFileSet({ 219 + monorepo: true, 220 + workspaces, 221 + rootPkg, 222 + rootPkgSource: sourceOf(rootPkg), 223 + currentVersion: '1.2.3', 224 + newVersion: '1.2.4', 225 + }) 226 + 227 + const byPath = Object.fromEntries(files.map(f => [f.path, f.content])) 228 + expect(byPath['packages/a/package.json']).toBe('{\n\t"name": "a",\n\t"version": "1.2.4"\n}\n') 229 + expect(byPath['packages/b/package.json']).toBe('{\n "name": "b",\n "version": "1.2.4"\n}\n') 176 230 }) 177 231 }) 178 232 })