├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── 10-feature.md │ ├── 20-bug.md │ ├── 30-docs.md │ ├── 99-something-else.md │ └── config.yml ├── renovate.json ├── semantic.yml └── workflows │ └── main.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── plugins │ └── @yarnpkg │ ├── plugin-interactive-tools.cjs │ ├── plugin-outdated.cjs │ └── plugin-typescript.cjs ├── .yarnrc.yml ├── README.md ├── action.yml ├── jest.config.ts ├── jest.config.unit.ts ├── package.json ├── src ├── cli │ └── main.ts ├── lib │ ├── __snapshots__ │ │ └── semver.spec.ts.snap │ ├── changelog │ │ ├── __snapshots__ │ │ │ └── index.spec.ts.snap │ │ ├── data.ts │ │ ├── helpers.ts │ │ ├── index.spec.ts │ │ ├── index.ts │ │ └── renderers │ │ │ ├── index.ts │ │ │ ├── markdown.ts │ │ │ └── terminal.ts │ ├── conventional-commit.spec.ts │ ├── conventional-commit.ts │ ├── debug.ts │ ├── git.ts │ ├── git2.ts │ ├── github-ci-environment.ts │ ├── npm-auth.ts │ ├── package-json.spec.ts │ ├── package-json.ts │ ├── pacman.ts │ ├── proc.ts │ ├── publish-changelog.ts │ ├── publish-package.ts │ ├── semver.spec.ts │ ├── semver.ts │ └── utils.ts ├── sdk │ ├── pr.spec.ts │ ├── pr.ts │ ├── preview.spec.ts │ ├── preview.ts │ ├── stable.spec.ts │ └── stable.ts └── utils │ ├── __snapshots__ │ └── release.spec.ts.snap │ ├── context-checkers.ts │ ├── context-guard.ts │ ├── context.ts │ ├── octokit.ts │ ├── output.ts │ ├── pr-release.ts │ ├── release.spec.ts │ └── release.ts ├── tests ├── __fixtures │ ├── git-init │ │ ├── HEAD │ │ ├── config │ │ ├── description │ │ ├── hooks │ │ │ ├── applypatch-msg.sample │ │ │ ├── commit-msg.sample │ │ │ ├── fsmonitor-watchman.sample │ │ │ ├── post-update.sample │ │ │ ├── pre-applypatch.sample │ │ │ ├── pre-commit.sample │ │ │ ├── pre-push.sample │ │ │ ├── pre-rebase.sample │ │ │ ├── pre-receive.sample │ │ │ ├── prepare-commit-msg.sample │ │ │ └── update.sample │ │ └── info │ │ │ └── exclude │ └── git-repo-dripip-system-tests │ │ ├── HEAD │ │ ├── config │ │ ├── description │ │ ├── hooks │ │ ├── applypatch-msg.sample │ │ ├── commit-msg.sample │ │ ├── fsmonitor-watchman.sample │ │ ├── post-update.sample │ │ ├── pre-applypatch.sample │ │ ├── pre-commit.sample │ │ ├── pre-push.sample │ │ ├── pre-rebase.sample │ │ ├── pre-receive.sample │ │ ├── prepare-commit-msg.sample │ │ └── update.sample │ │ ├── index │ │ ├── info │ │ └── exclude │ │ ├── objects │ │ └── pack │ │ │ ├── pack-d07ed83ba7cb14007fba9bbba58713e09c775de1.idx │ │ │ └── pack-d07ed83ba7cb14007fba9bbba58713e09c775de1.pack │ │ ├── packed-refs │ │ └── refs │ │ ├── heads │ │ └── main │ │ └── remotes │ │ └── origin │ │ └── HEAD ├── __lib │ ├── helpers.ts │ └── workspace.ts ├── __providers__ │ ├── fixture.ts │ └── git.ts ├── _setup.ts ├── e2e │ └── preview.spec.todo.ts └── git.spec.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .yalc 2 | .vscode 3 | .github 4 | node_modules 5 | dist-cjs 6 | dist-esm 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": ["tsconfig.json"] 6 | }, 7 | "plugins": [ 8 | // This enables using new lint rules powered by the TypeScript compiler API. 9 | // https://github.com/typescript-eslint/typescript-eslint 10 | "@typescript-eslint", 11 | // This makes it so the IDE reports lint rejections as warnings only. This is 12 | // better than errors because most lint rejections are not runtime errors. This 13 | // allows IDE errors to be exclusive for e.g. static type errors which often are 14 | // reflective of real runtime errors. 15 | // https://github.com/bfanger/eslint-plugin-only-warn 16 | "only-warn", 17 | // This enables the use of a lint rule to reject function declarations. This is 18 | // preferable as a way to encourage higher order function usage. For example it is not 19 | // possible to wrap a function declaration with Open Telemetry instrumentation but it is 20 | // possible to wrap an arrow function since its an expression. 21 | // https://github.com/TristonJ/eslint-plugin-prefer-arrow 22 | "prefer-arrow", 23 | // This enables the use of a lint rule to reject use of @deprecated functions. 24 | // https://github.com/gund/eslint-plugin-deprecation 25 | "deprecation", 26 | // Import sorting integrated into ESLint. 27 | // https://github.com/lydell/eslint-plugin-simple-import-sort 28 | "simple-import-sort", 29 | // https://github.com/microsoft/tsdoc/tree/master/eslint-plugin 30 | "tsdoc" 31 | ], 32 | "extends": [ 33 | "eslint:recommended", 34 | "plugin:@typescript-eslint/eslint-recommended", 35 | "plugin:@typescript-eslint/recommended", 36 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 37 | "prettier" 38 | ], 39 | "overrides": [], 40 | "rules": { 41 | "quotes": ["warn", "backtick"], 42 | "tsdoc/syntax": "warn", 43 | "simple-import-sort/imports": [ 44 | "warn", 45 | { 46 | "groups": [] 47 | } 48 | ], 49 | "simple-import-sort/exports": "warn", 50 | "deprecation/deprecation": "warn", 51 | "prefer-arrow/prefer-arrow-functions": "warn", 52 | // TypeScript makes these safe & effective 53 | "no-case-declarations": "off", 54 | // Same approach used by TypeScript noUnusedLocals 55 | "@typescript-eslint/no-unused-vars": ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/10-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature 3 | about: You have an idea for a new capability or a refinement to an existing one 4 | title: '' 5 | labels: type/feat 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | #### Perceived Problem 15 | 16 | #### Ideas / Proposed Solution(s) 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/20-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug 3 | about: You encountered something that is not working the way it should 4 | title: '' 5 | labels: type/bug 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | #### Screenshot 15 | 16 | #### Description 17 | 18 | #### Repro Steps/Link 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/30-docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📗 Docs 3 | about: You have feedback or ideas about the documentation 4 | title: '' 5 | labels: type/docs 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | #### Perceived Problem 15 | 16 | #### Ideas / Proposed Solution(s) 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/99-something-else.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤷‍♂️ Something Else 3 | about: You have something to say that doesn't obviously fit another category here 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | #### What 15 | 16 | #### Why 17 | 18 | #### How 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ✋ Question 4 | about: You have a question about something you're not sure about 5 | url: https://github.com/prisma-labs/dripip/discussions/new 6 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>prisma-labs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | types: 2 | - deps 3 | # defaults 4 | - feat 5 | - fix 6 | - improvement 7 | - docs 8 | - style 9 | - refactor 10 | - perf 11 | - test 12 | - build 13 | - ci 14 | - chore 15 | - revert 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci-cd 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | push: 7 | branches: [master] 8 | 9 | jobs: 10 | type-check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 16.x 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 16.x 18 | cache: yarn 19 | - run: yarn install --immutable 20 | - run: yarn type-check 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | node-version: [14.x, 16.x] 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | - run: yarn --immutable 34 | - run: yarn test:unit # todo system tests on ci 35 | 36 | publish: 37 | needs: test 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v2 41 | with: 42 | fetch-depth: 0 43 | - uses: actions/setup-node@v2 44 | with: 45 | node-version: 16.x 46 | - run: yarn --immutable 47 | - run: yarn dripip preview-or-pr 48 | env: 49 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 50 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # app 64 | dist-* 65 | .swc 66 | 67 | # Yarn 2 68 | .pnp.* 69 | .yarn/* 70 | !.yarn/patches 71 | !.yarn/plugins 72 | !.yarn/sdks 73 | !.yarn/versions 74 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/dist-cjs": true, 4 | "**/dist-esm": true, 5 | "**/__snapshots__": true, 6 | "**/.yarn": true, 7 | "yarn.lock": true 8 | }, 9 | "typescript.tsdk": "node_modules/typescript/lib", 10 | "typescript.enablePromptUseWorkspaceTsdk": true, 11 | "editor.codeActionsOnSave": { 12 | "source.addMissingImports": "explicit", 13 | "source.fixAll.eslint": "explicit", 14 | "source.organizeImports": "never" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: '@yarnpkg/plugin-interactive-tools' 6 | - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs 7 | spec: '@yarnpkg/plugin-typescript' 8 | - path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs 9 | spec: 'https://mskelton.dev/yarn-outdated/v2' 10 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: dripip 2 | description: Use Dripip to automate package releases in your CI. 3 | inputs: 4 | isStable: 5 | required: false 6 | description: Is this a stable release? Values can be 'true' or 'false'. By default is 'false'. 7 | extraFlags: 8 | required: false 9 | description: Extra CLI flags to append to the executed CLI command. 10 | npmToken: 11 | required: true 12 | description: The NPM token that will be used to publish the package to the npm registry. 13 | githubToken: 14 | required: true 15 | description: The GitHub token that will be used to create a GitHub release on the repository. 16 | 17 | runs: 18 | using: composite 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 18.x 26 | - name: Install Dependencies 27 | shell: bash 28 | run: | 29 | corepack enable 30 | 31 | if [ -f "pnpm-lock.yaml" ]; then 32 | pnpm install 33 | elif [ -f "package-lock.json" ]; then 34 | npm install 35 | elif [ -f "yarn.lock" ]; then 36 | yarn_version=$(yarn --version) 37 | yarn_version_major=${yarn_version:0:1} 38 | 39 | if [ "${yarn_version_major}" = '1' ]; then 40 | yarn install --immutable 41 | else 42 | yarn install --frozen-lockfile 43 | fi 44 | else 45 | echo "Could not detect which package manager is being used." 46 | exit 1 47 | fi 48 | 49 | # TODO Do not assume that dripip is locally installed. 50 | - name: Publish Release 51 | shell: bash 52 | env: 53 | NPM_TOKEN: ${{inputs.npmToken}} 54 | GITHUB_TOKEN: ${{inputs.githubToken}} 55 | run: | 56 | if [ '${{inputs.isStable}}' = 'true' ]; then 57 | sub_command='stable' 58 | else 59 | sub_command='preview-or-pr' 60 | fi 61 | 62 | if [ -f "pnpm-lock.yaml" ]; then 63 | pnpm dripip ${sub_command} --json ${{inputs.extraFlags}} 64 | elif [ -f "package-lock.json" ]; then 65 | npx dripip ${sub_command} --json ${{inputs.extraFlags}} 66 | elif [ -f "yarn.lock" ]; then 67 | yarn_version=$(yarn --version) 68 | yarn_version_major=${yarn_version:0:1} 69 | 70 | if [ "${yarn_version_major}" = '1' ]; then 71 | silent_flag='--silent' 72 | else 73 | silent_flag='' 74 | fi 75 | 76 | yarn ${silent_flag} dripip ${sub_command} --json ${{inputs.extraFlags}} 77 | else 78 | echo "Could not detect which package manager is being used." 79 | exit 1 80 | fi 81 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import * as Fs from 'fs' 2 | import { pathsToModuleNameMapper } from 'ts-jest' 3 | import * as TypeScript from 'typescript' 4 | 5 | const tsconfig: { 6 | config?: { compilerOptions?: { paths?: Record } } 7 | error?: TypeScript.Diagnostic 8 | } = TypeScript.readConfigFile(`tsconfig.json`, (path) => Fs.readFileSync(path, { encoding: `utf-8` })) 9 | 10 | const config = { 11 | transform: { 12 | '^.+\\.ts$': `@swc/jest`, 13 | }, 14 | moduleNameMapper: pathsToModuleNameMapper(tsconfig.config?.compilerOptions?.paths ?? {}, { 15 | prefix: ``, 16 | }), 17 | watchPlugins: [ 18 | `jest-watch-typeahead/filename`, 19 | `jest-watch-typeahead/testname`, 20 | `jest-watch-select-projects`, 21 | `jest-watch-suspend`, 22 | ], 23 | setupFiles: [`./tests/_setup.ts`], 24 | } 25 | 26 | export default config 27 | -------------------------------------------------------------------------------- /jest.config.unit.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from './jest.config' 2 | 3 | const config = { 4 | ...baseConfig, 5 | testRegex: `src/.*\\.spec\\.ts$`, 6 | displayName: { 7 | name: `Unit`, 8 | color: `blue`, 9 | }, 10 | } 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dripip", 3 | "version": "0.0.0-dripip", 4 | "description": "Opinionated CLI for continuous delivery of npm packages", 5 | "main": "dist-cjs/cli/main", 6 | "bin": "dist-cjs/cli/main.js", 7 | "repository": "git@github.com:prisma-labs/dripip.git", 8 | "packageManager": "yarn@3.2.0", 9 | "author": "Jason Kuhrt ", 10 | "license": "MIT", 11 | "files": [ 12 | "src", 13 | "dist-cjs", 14 | "dist-esm" 15 | ], 16 | "scripts": { 17 | "release:stable": "yarn dripip stable", 18 | "release:preview": "yarn dripip preview", 19 | "release:pr": "yarn dripip pr", 20 | "format": "prettier --write .", 21 | "dripip": "ts-node src/cli/main", 22 | "build": "yarn clean && yarn build:cjs && yarn build:esm", 23 | "build:cjs": "tsc --project tsconfig.cjs.json", 24 | "build:esm": "tsc --project tsconfig.esm.json", 25 | "lint": "eslint . --ext .ts,.tsx --fix", 26 | "lint:check": "eslint . --ext .ts,.tsx --max-warnings 0", 27 | "clean": "rm -rf dist-cjs dist-esm node_modules/.cache", 28 | "dev": "tsc --watch", 29 | "type-check": "yarn tsc --noEmit", 30 | "dev:test": "jest --watch --testPathPattern '.*/src/.*'", 31 | "test:unit": "jest --config jest.config.unit.ts", 32 | "test:system": "jest --testTimeout 15000 --testPathPattern '.*/tests/.*' --verbose", 33 | "test": "jest --testTimeout 15000 --verbose", 34 | "build:docs:toc": "doctoc README.md", 35 | "build:docs": "yarn build:docs:toc", 36 | "prepack": "yarn build" 37 | }, 38 | "dependencies": { 39 | "@octokit/rest": "^18.12.0", 40 | "@types/debug": "^4.1.7", 41 | "@types/parse-git-config": "^3.0.1", 42 | "@types/parse-github-url": "^1.0.0", 43 | "chaindown": "^0.1.0-next.2", 44 | "chalk": "^4.1.0", 45 | "common-tags": "^1.8.2", 46 | "debug": "^4.3.3", 47 | "execa": "5", 48 | "fs-jetpack": "^4.3.1", 49 | "isomorphic-git": "^1.8.0", 50 | "parse-git-config": "^3.0.0", 51 | "parse-github-url": "^1.0.2", 52 | "request": "^2.88.2", 53 | "simple-git": "^2.23.0", 54 | "tslib": "^2.3.1", 55 | "yargs": "^17.3.1" 56 | }, 57 | "devDependencies": { 58 | "@prisma-labs/prettier-config": "0.1.0", 59 | "@swc/core": "1.2.198", 60 | "@swc/helpers": "0.3.17", 61 | "@swc/jest": "0.2.21", 62 | "@tsconfig/node14": "1.0.2", 63 | "@types/common-tags": "1.8.1", 64 | "@types/eslint": "8.4.3", 65 | "@types/jest": "27.5.2", 66 | "@types/node": "17.0.21", 67 | "@types/tmp": "0.2.3", 68 | "@typescript-eslint/eslint-plugin": "5.27.1", 69 | "@typescript-eslint/parser": "5.27.1", 70 | "doctoc": "1.4.0", 71 | "eslint": "8.17.0", 72 | "eslint-config-prettier": "8.5.0", 73 | "eslint-plugin-deprecation": "1.3.2", 74 | "eslint-plugin-only-warn": "1.0.3", 75 | "eslint-plugin-prefer-arrow": "1.2.3", 76 | "eslint-plugin-simple-import-sort": "7.0.0", 77 | "eslint-plugin-tsdoc": "0.2.16", 78 | "jest": "27.5.1", 79 | "jest-watch-select-projects": "2.0.0", 80 | "jest-watch-suspend": "1.1.2", 81 | "jest-watch-toggle-config": "2.0.1", 82 | "jest-watch-typeahead": "1.1.0", 83 | "konn": "0.7.0", 84 | "prettier": "2.6.2", 85 | "strip-ansi": "6.0.1", 86 | "tmp": "0.2.1", 87 | "ts-jest": "27.1.5", 88 | "ts-node": "10.8.1", 89 | "tsconfig-paths": "3.14.1", 90 | "type-fest": "2.13.0", 91 | "typescript": "4.7.3", 92 | "typescript-snapshots-plugin": "1.7.0", 93 | "typescript-transform-paths": "3.3.1" 94 | }, 95 | "prettier": "@prisma-labs/prettier-config" 96 | } 97 | -------------------------------------------------------------------------------- /src/cli/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as ChangeLog from '../lib/changelog' 3 | import { rootDebug } from '../lib/debug' 4 | import { runPullRequestRelease } from '../sdk/pr' 5 | import { runPreviewRelease } from '../sdk/preview' 6 | import { runStableRelease } from '../sdk/stable' 7 | import { getContext, getLocationContext } from '../utils/context' 8 | import { octokit } from '../utils/octokit' 9 | import { output } from '../utils/output' 10 | import { getPullRequestReleaseVersionForLocation } from '../utils/pr-release' 11 | import { getCurrentCommit } from '../utils/release' 12 | import { inspect } from 'util' 13 | import yargs from 'yargs' 14 | 15 | yargs(process.argv.slice(2)) 16 | .help() 17 | .strict() 18 | .recommendCommands() 19 | .version(false) 20 | .options({ 21 | json: { 22 | description: `format output as JSON`, 23 | boolean: true, 24 | default: false, 25 | alias: `j`, 26 | }, 27 | }) 28 | .command( 29 | `log`, 30 | `todo`, 31 | (yargs) => 32 | yargs.options({ 33 | markdown: { 34 | description: `format output as Markdown`, 35 | boolean: true, 36 | default: false, 37 | alias: `m`, 38 | }, 39 | }), 40 | async (flags): Promise => { 41 | const ctx = await getContext({ 42 | cwd: process.cwd(), 43 | readFromCIEnvironment: true, 44 | }) 45 | 46 | if (flags.json) { 47 | console.log(inspect(ctx.series, { depth: 20 })) 48 | return 49 | } 50 | 51 | console.log( 52 | ChangeLog.renderFromSeries(ctx.series, { 53 | as: flags.markdown ? `markdown` : `plain`, 54 | }) 55 | ) 56 | } 57 | ) 58 | .command( 59 | `stable`, 60 | `todo`, 61 | (yargs) => 62 | yargs.options({ 63 | trunk: { 64 | string: true, 65 | default: ``, 66 | description: `State which branch is trunk. Defaults to honoring the "base" branch setting in the GitHub repo settings.`, 67 | }, 68 | 'dry-run': { 69 | boolean: true, 70 | default: false, 71 | description: `output what the next version would be if released now`, 72 | alias: `d`, 73 | }, 74 | 'skip-npm': { 75 | boolean: true, 76 | default: false, 77 | description: `skip the step of publishing the package to npm`, 78 | }, 79 | }), 80 | async (flags) => { 81 | const message = await runStableRelease({ 82 | cwd: process.cwd(), 83 | changelog: true, 84 | dryRun: flags[`dry-run`], 85 | json: flags.json, 86 | progress: !flags.json, 87 | overrides: { 88 | skipNpm: flags[`skip-npm`], 89 | trunk: flags.trunk, 90 | }, 91 | }) 92 | output(message, { json: flags.json }) 93 | } 94 | ) 95 | .command( 96 | `preview`, 97 | `todo`, 98 | (yargs) => 99 | yargs.options({ 100 | trunk: { 101 | string: true, 102 | default: ``, 103 | description: `State which branch is trunk. Defaults to honoring the "base" branch setting in the GitHub repo settings.`, 104 | }, 105 | 'build-num': { 106 | number: true, 107 | description: `Force a build number. Should not be needed generally. For exceptional cases.`, 108 | alias: `n`, 109 | }, 110 | 'dry-run': { 111 | boolean: true, 112 | default: false, 113 | description: `output what the next version would be if released now`, 114 | alias: `d`, 115 | }, 116 | 'skip-npm': { 117 | boolean: true, 118 | default: false, 119 | description: `skip the step of publishing the package to npm`, 120 | }, 121 | }), 122 | async (flags) => { 123 | const message = await runPreviewRelease({ 124 | cwd: process.cwd(), 125 | changelog: true, 126 | dryRun: flags[`dry-run`], 127 | json: flags.json, 128 | progress: !flags.json, 129 | overrides: { 130 | skipNpm: flags[`skip-npm`], 131 | buildNum: flags[`build-num`], 132 | trunk: flags.trunk, 133 | }, 134 | }) 135 | output(message, { json: flags.json }) 136 | } 137 | ) 138 | .command( 139 | `pr`, 140 | `todo`, 141 | (yargs) => 142 | yargs.options({ 143 | 'dry-run': { 144 | boolean: true, 145 | default: false, 146 | description: `output what the next version would be if released now`, 147 | alias: `d`, 148 | }, 149 | json: { 150 | boolean: true, 151 | default: false, 152 | description: `format output as JSON`, 153 | alias: `j`, 154 | }, 155 | }), 156 | async (flags) => { 157 | const message = await runPullRequestRelease({ 158 | json: flags.json, 159 | progress: !flags.json, 160 | dryRun: flags[`dry-run`], 161 | }) 162 | output(message, { json: flags.json }) 163 | } 164 | ) 165 | .command( 166 | `preview-or-pr`, 167 | `todo`, 168 | (yargs) => 169 | yargs.options({ 170 | trunk: { 171 | string: true, 172 | default: ``, 173 | description: `State which branch is trunk. Defaults to honoring the "base" branch setting in the GitHub repo settings.`, 174 | }, 175 | 'dry-run': { 176 | boolean: true, 177 | default: false, 178 | description: `output what the next version would be if released now`, 179 | alias: `d`, 180 | }, 181 | json: { 182 | boolean: true, 183 | default: false, 184 | description: `format output as JSON`, 185 | alias: `j`, 186 | }, 187 | // preview 188 | 'build-num': { 189 | number: true, 190 | description: `Force a build number. Should not be needed generally. For exceptional cases.`, 191 | alias: `n`, 192 | }, 193 | 'skip-npm': { 194 | boolean: true, 195 | default: false, 196 | description: `skip the step of publishing the package to npm`, 197 | }, 198 | }), 199 | // eslint-disable-next-line 200 | async (flags) => { 201 | const context = await getLocationContext({ octokit }) 202 | 203 | if (context.currentBranch.pr) { 204 | const message = await runPullRequestRelease({ 205 | json: flags.json, 206 | progress: !flags.json, 207 | dryRun: flags[`dry-run`], 208 | }) 209 | output(message, { json: flags.json }) 210 | } else { 211 | const message = await runPreviewRelease({ 212 | cwd: process.cwd(), 213 | changelog: true, 214 | dryRun: flags[`dry-run`], 215 | json: flags.json, 216 | progress: !flags.json, 217 | overrides: { 218 | skipNpm: flags[`skip-npm`], 219 | buildNum: flags[`build-num`], 220 | trunk: flags.trunk, 221 | }, 222 | }) 223 | output(message, { json: flags.json }) 224 | } 225 | } 226 | ) 227 | .command( 228 | `get-current-pr-num`, 229 | `todo`, 230 | (yargs) => 231 | yargs.options({ 232 | optional: { 233 | boolean: true, 234 | default: false, 235 | description: `Exit 0 if a pr number cannot be found for whatever reason`, 236 | alias: `r`, 237 | }, 238 | }), 239 | async (flags) => { 240 | const context = await getLocationContext({ 241 | octokit, 242 | }) 243 | 244 | const prNum = context.currentBranch.pr?.number ?? null 245 | 246 | if (prNum !== null) { 247 | console.log(String(prNum)) 248 | } 249 | 250 | if (!flags[`optional`]) { 251 | process.exit(1) 252 | } 253 | } 254 | ) 255 | .command( 256 | `get-current-commit-version`, 257 | `todo`, 258 | (yargs) => 259 | yargs.options({ 260 | optional: { 261 | boolean: true, 262 | description: `Exit 0 if a version for the commit cannot be found`, 263 | default: false, 264 | alias: `r`, 265 | }, 266 | }), 267 | async (flags) => { 268 | const debug = rootDebug(__filename) 269 | // Try to get version from preview/stable release 270 | // stable release preferred over preview 271 | // preview release preferred over pr release 272 | // 273 | // Note: 274 | // 275 | // - PR release should not be possible on same commit as stable/preview 276 | // anyway 277 | // 278 | // - PR release is much more costly to calculate than others 279 | // 280 | 281 | const c = await getCurrentCommit() 282 | debug(`got current commit`, c) 283 | 284 | // todo these could have `v` prefix 285 | 286 | if (c.releases.stable) { 287 | debug(`counting stable release as version of this commit`) 288 | console.log(c.releases.stable.version) 289 | return 290 | } 291 | 292 | if (c.releases.preview) { 293 | debug(`counting preview release as version of this commit`) 294 | console.log(c.releases.preview.version) 295 | return 296 | } 297 | 298 | // Try to get version from pr release 299 | debug(`commit has no release information, checking for pr-releases`) 300 | 301 | const ctx = await getLocationContext({ octokit: octokit }) 302 | 303 | debug(`got location context`, ctx) 304 | 305 | if (ctx.currentBranch.pr) { 306 | const version = getPullRequestReleaseVersionForLocation({ 307 | packageName: ctx.package.name, 308 | prNum: ctx.currentBranch.pr.number, 309 | sha: c.sha, 310 | }) 311 | 312 | debug(`pr release version for this location context?`, { version }) 313 | 314 | if (version) { 315 | debug(`counting pr-release version as version of this commit`) 316 | console.log(version) 317 | return 318 | } 319 | } 320 | 321 | // Give up, with error if specified to 322 | const giveUpWithError = !flags[`optional`] 323 | 324 | debug(`giving up`, { giveUpWithError }) 325 | 326 | if (giveUpWithError) { 327 | process.exit(1) 328 | } 329 | } 330 | ).argv 331 | -------------------------------------------------------------------------------- /src/lib/__snapshots__/semver.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`incStable can bump major 1`] = ` 4 | Object { 5 | "major": 2, 6 | "minor": 0, 7 | "patch": 0, 8 | "version": "2.0.0", 9 | "vprefix": false, 10 | } 11 | `; 12 | 13 | exports[`incStable can bump minor 1`] = ` 14 | Object { 15 | "major": 1, 16 | "minor": 2, 17 | "patch": 0, 18 | "version": "1.2.0", 19 | "vprefix": false, 20 | } 21 | `; 22 | 23 | exports[`incStable can bump patch 1`] = ` 24 | Object { 25 | "major": 2, 26 | "minor": 0, 27 | "patch": 0, 28 | "version": "2.0.0", 29 | "vprefix": false, 30 | } 31 | `; 32 | 33 | exports[`incStable propagates vprefix 1`] = ` 34 | Object { 35 | "major": 2, 36 | "minor": 0, 37 | "patch": 0, 38 | "version": "2.0.0", 39 | "vprefix": true, 40 | } 41 | `; 42 | 43 | exports[`parse 0.0.1 1`] = ` 44 | Object { 45 | "major": 0, 46 | "minor": 0, 47 | "patch": 1, 48 | "version": "0.0.1", 49 | "vprefix": false, 50 | } 51 | `; 52 | 53 | exports[`parse 0.0.11 1`] = ` 54 | Object { 55 | "major": 0, 56 | "minor": 0, 57 | "patch": 11, 58 | "version": "0.0.11", 59 | "vprefix": false, 60 | } 61 | `; 62 | 63 | exports[`parse 0.1.0 1`] = ` 64 | Object { 65 | "major": 0, 66 | "minor": 1, 67 | "patch": 0, 68 | "version": "0.1.0", 69 | "vprefix": false, 70 | } 71 | `; 72 | 73 | exports[`parse 0.11.0 1`] = ` 74 | Object { 75 | "major": 0, 76 | "minor": 11, 77 | "patch": 0, 78 | "version": "0.11.0", 79 | "vprefix": false, 80 | } 81 | `; 82 | 83 | exports[`parse 1.0.0 1`] = ` 84 | Object { 85 | "major": 1, 86 | "minor": 0, 87 | "patch": 0, 88 | "version": "1.0.0", 89 | "vprefix": false, 90 | } 91 | `; 92 | 93 | exports[`parse 11.0.0 1`] = ` 94 | Object { 95 | "major": 11, 96 | "minor": 0, 97 | "patch": 0, 98 | "version": "11.0.0", 99 | "vprefix": false, 100 | } 101 | `; 102 | 103 | exports[`parse pre-releases 0.0.0-a.1 1`] = ` 104 | Object { 105 | "major": 0, 106 | "minor": 0, 107 | "patch": 0, 108 | "preRelease": Object { 109 | "buildNum": 1, 110 | "identifier": "a", 111 | }, 112 | "version": "0.0.0-a.1", 113 | "vprefix": false, 114 | } 115 | `; 116 | 117 | exports[`parse v-prefix v0.0.0 1`] = ` 118 | Object { 119 | "major": 0, 120 | "minor": 0, 121 | "patch": 0, 122 | "version": "0.0.0", 123 | "vprefix": true, 124 | } 125 | `; 126 | 127 | exports[`parse v-prefix v0.0.0-a.1 1`] = ` 128 | Object { 129 | "major": 0, 130 | "minor": 0, 131 | "patch": 0, 132 | "preRelease": Object { 133 | "buildNum": 1, 134 | "identifier": "a", 135 | }, 136 | "version": "0.0.0-a.1", 137 | "vprefix": true, 138 | } 139 | `; 140 | 141 | exports[`stableToPreview turns stable-release version into a pre-release one 1`] = ` 142 | Object { 143 | "major": 1, 144 | "minor": 2, 145 | "patch": 3, 146 | "preRelease": Object { 147 | "buildNum": 100, 148 | "identifier": "foobar", 149 | }, 150 | "version": "1.2.3-foobar.100", 151 | "vprefix": false, 152 | } 153 | `; 154 | -------------------------------------------------------------------------------- /src/lib/changelog/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`.render() with markdown renders release changelog for the current release series 1`] = ` 4 | "#### BREAKING CHANGES 5 | 6 | - shasha# a 7 | 8 | #### Features 9 | 10 | - shasha# b 11 | - shasha# (breaking) a 12 | 13 | #### Fixes 14 | 15 | - shasha# b 16 | - shasha# a 17 | 18 | #### Improvements 19 | 20 | - shasha# refactor: a 21 | - shasha# perf: a 22 | 23 | #### Chores 24 | 25 | - shasha# b 26 | - shasha# a 27 | 28 | #### Unspecified Changes 29 | 30 | - shasha# non conforming commit b 31 | - shasha# non conforming commit a 32 | " 33 | `; 34 | 35 | exports[`.render() with terminal renders changelog for the current release series 1`] = ` 36 | "BREAKING CHANGES 37 | 38 | shasha# a 39 | 40 | Features 41 | 42 | shasha# b 43 | shasha# (breaking) a 44 | 45 | Fixes 46 | 47 | shasha# b 48 | shasha# a 49 | 50 | Improvements 51 | 52 | shasha# refactor: a 53 | shasha# perf: a 54 | 55 | Chores 56 | 57 | shasha# b 58 | shasha# a 59 | 60 | Unspecified Changes 61 | 62 | shasha# non conforming commit b 63 | shasha# non conforming commit a 64 | " 65 | `; 66 | -------------------------------------------------------------------------------- /src/lib/changelog/data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module deals with building changelogs from multiple series. 3 | */ 4 | import { Commit } from '../../utils/release' 5 | 6 | export type Changelog = { 7 | /** 8 | * Any commit marked as breaking. 9 | */ 10 | breaking: { 11 | label: string 12 | commits: Commit[] 13 | } 14 | /** 15 | * Feat type commits. 16 | */ 17 | features: { 18 | label: string 19 | commits: Commit[] 20 | } 21 | /** 22 | * Fix type commits. 23 | */ 24 | fixes: { 25 | label: string 26 | commits: Commit[] 27 | } 28 | /** 29 | * Any type other than feat fix and chore. 30 | */ 31 | improvements: { 32 | label: string 33 | commits: Commit[] 34 | } 35 | /** 36 | * Commits that are intended to be ignored. 37 | */ 38 | chores: { 39 | label: string 40 | commits: Commit[] 41 | } 42 | /** 43 | * Commit mistakes, non-conforming tools, who knows. Shouldn't happen but can. 44 | */ 45 | unspecified: { 46 | label: string 47 | commits: Commit[] 48 | } 49 | } 50 | 51 | /** 52 | * Crate an empty changelog. 53 | */ 54 | export function empty(): Changelog { 55 | return { 56 | breaking: { commits: [], label: `BREAKING CHANGES` }, 57 | features: { commits: [], label: `Features` }, 58 | chores: { commits: [], label: `Chores` }, 59 | fixes: { commits: [], label: `Fixes` }, 60 | improvements: { commits: [], label: `Improvements` }, 61 | unspecified: { commits: [], label: `Unspecified Changes` }, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/changelog/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Series } from '../../utils/release' 2 | import { casesHandled } from '../utils' 3 | import { Changelog, empty } from './data' 4 | import { Markdown, Terminal } from './renderers' 5 | 6 | export function renderFromSeries(series: Series, options: RenderOptions): string { 7 | return render(fromSeries(series), options) 8 | } 9 | 10 | export type RenderOptions = { 11 | as: `plain` | `markdown` 12 | } 13 | 14 | /** 15 | * Render a changelog into a string using the chosen renderer (Markdown, Terminal, etc.). 16 | */ 17 | export function render(changelog: Changelog, opts: RenderOptions): string { 18 | if (opts.as === `markdown`) return Markdown.render(changelog) 19 | if (opts.as === `plain`) return Terminal.render(changelog) 20 | casesHandled(opts.as) 21 | } 22 | 23 | /** 24 | * Transform a series into a changelog. 25 | */ 26 | export function fromSeries(series: Series): Changelog { 27 | const log = empty() 28 | 29 | for (const c of series.commitsInNextStable) { 30 | if (c.message.parsed === null) { 31 | log.unspecified.commits.push(c) 32 | continue 33 | } 34 | 35 | const cp = c.message.parsed 36 | 37 | // breaking changes are collected as a group in addition to by type. 38 | if (cp.breakingChange) { 39 | log.breaking.commits.push(c) 40 | } 41 | 42 | if (cp.typeKind === `feat`) { 43 | log.features.commits.push(c) 44 | } else if (cp.typeKind === `fix`) { 45 | log.fixes.commits.push(c) 46 | } else if (cp.typeKind === `chore`) { 47 | log.chores.commits.push(c) 48 | } else if (cp.typeKind === `other`) { 49 | log.improvements.commits.push(c) 50 | } else { 51 | casesHandled(cp.typeKind) 52 | } 53 | } 54 | 55 | return log 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/changelog/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Release from '../../utils/release' 2 | import * as Changelog from './' 3 | import stripAnsi from 'strip-ansi' 4 | 5 | const mockCommits: Release.MockCommit[] = [ 6 | { message: `chore: b` }, 7 | { message: `fix: b` }, 8 | { message: `feat: b` }, 9 | { message: `refactor: a` }, 10 | { message: `non conforming commit b` }, 11 | { message: `chore: a` }, 12 | { message: `feat: a\n\nBREAKING CHANGE:\ntoto` }, 13 | { message: `perf: a` }, 14 | { message: `fix: a` }, 15 | { message: `non conforming commit a` }, 16 | { message: `feat: blah`, version: `0.1.0` }, 17 | ] 18 | 19 | describe(`.render() with markdown`, () => { 20 | const render = (...commits: Release.MockCommit[]) => { 21 | // return Changelog.Renderers.Markdown.render(Changelog.fromSeries(Release.fromMockCommits(commits))) 22 | return Changelog.render(Changelog.fromSeries(Release.fromMockCommits(commits)), { as: `markdown` }) 23 | } 24 | it(`renders release changelog for the current release series`, () => { 25 | const changelog = render(...mockCommits) 26 | expect(changelog).toMatchSnapshot() 27 | }) 28 | it(`shows breaking commits twice, once in breaking section and once in its native section`, () => { 29 | const changelog = render({ message: `a: foo\n\nBREAKING CHANGE:\nbar` }) 30 | expect(changelog).toMatch(/BREAKING CHANGES\n\n.*foo/) 31 | expect(changelog).toMatch(/Improvements\n\n.*foo/) 32 | }) 33 | it(`shows breaking label if commit breaking except within breaking section`, () => { 34 | const changelog = render({ message: `a: foo\n\nBREAKING CHANGE:\nbar` }) 35 | expect(changelog).toMatch(/Improvements\n\n.*(breaking).*foo/) 36 | }) 37 | it(`hides unknown section if no unknown commits`, () => { 38 | const changelog = render({ message: `a: b` }) 39 | expect(changelog).not.toMatch(Changelog.empty().unspecified.label) 40 | }) 41 | it(`improvements section prefixes commits with their type`, () => { 42 | const changelog = render({ message: `a: foo` }) 43 | expect(changelog).toMatch(/a: foo/) 44 | }) 45 | }) 46 | 47 | describe(`.render() with terminal`, () => { 48 | const render = (...commits: Release.MockCommit[]) => { 49 | return Changelog.render(Changelog.fromSeries(Release.fromMockCommits(commits)), { as: `plain` }) 50 | } 51 | 52 | it(`renders changelog for the current release series`, () => { 53 | const changelog = stripAnsi(render(...mockCommits)) 54 | expect(changelog).toMatchSnapshot() 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/lib/changelog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data' 2 | export * from './helpers' 3 | export * as Renderers from './renderers' 4 | -------------------------------------------------------------------------------- /src/lib/changelog/renderers/index.ts: -------------------------------------------------------------------------------- 1 | export * as Markdown from './markdown' 2 | export * as Terminal from './terminal' 3 | -------------------------------------------------------------------------------- /src/lib/changelog/renderers/markdown.ts: -------------------------------------------------------------------------------- 1 | import { Commit, shortSha } from '../../../utils/release' 2 | import { Changelog } from '../data' 3 | import * as Chaindown from 'chaindown' 4 | 5 | export function render(log: Changelog): string { 6 | const order: (keyof Omit)[] = [ 7 | `breaking`, 8 | `features`, 9 | `fixes`, 10 | `improvements`, 11 | `chores`, 12 | ] 13 | 14 | const doc = Chaindown.create() 15 | 16 | order 17 | .filter((sectionName) => { 18 | return log[sectionName].commits.length > 0 19 | }) 20 | .forEach((sectionName) => { 21 | if (sectionName === `breaking`) { 22 | doc 23 | .heading(4, log[sectionName].label) 24 | .list(log[sectionName].commits.map((c) => sectionCommit(c, { breaking: false }))) 25 | return 26 | } 27 | 28 | if (sectionName === `improvements`) { 29 | doc 30 | .heading(4, log[sectionName].label) 31 | .list(log[sectionName].commits.map((c) => sectionCommit(c, { type: true }))) 32 | return 33 | } 34 | 35 | doc.heading(4, log[sectionName].label).list(log[sectionName].commits.map((c) => sectionCommit(c))) 36 | }) 37 | 38 | if (log.unspecified.commits.length) { 39 | doc 40 | .heading(4, log.unspecified.label) 41 | .list(log.unspecified.commits.map((c) => `${shortSha(c)} ${c.message.raw}`)) 42 | } 43 | 44 | return doc.render({ level: 5 }) 45 | } 46 | 47 | function sectionCommit(c: Commit, opts?: { type?: boolean; breaking?: boolean }): string { 48 | const sha = shortSha(c) 49 | const type = opts?.type === true ? ` ` + c.message.parsed!.type + `:` : `` 50 | const description = ` ` + c.message.parsed!.description 51 | const breaking = opts?.breaking === false ? `` : c.message.parsed!.breakingChange ? ` (breaking)` : `` 52 | return `${sha}${breaking}${type}${description}` 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/changelog/renderers/terminal.ts: -------------------------------------------------------------------------------- 1 | import { Commit, shortSha } from '../../../utils/release' 2 | import { Changelog } from '../data' 3 | import Chalk from 'chalk' 4 | 5 | export function render(log: Changelog): string { 6 | const order: (keyof Omit)[] = [ 7 | `breaking`, 8 | `features`, 9 | `fixes`, 10 | `improvements`, 11 | `chores`, 12 | ] 13 | 14 | const doc = order 15 | .filter((sectionName) => { 16 | return log[sectionName].commits.length > 0 17 | }) 18 | .map((sectionName) => { 19 | if (sectionName === `breaking`) { 20 | return ( 21 | sectionTitle(log[sectionName].label) + 22 | `\n\n` + 23 | sectionCommits(log[sectionName].commits, { breaking: false }) + 24 | `\n` 25 | ) 26 | } 27 | 28 | if (sectionName === `improvements`) { 29 | return ( 30 | sectionTitle(log[sectionName].label) + 31 | `\n\n` + 32 | sectionCommits(log[sectionName].commits, { type: true }) + 33 | `\n` 34 | ) 35 | } 36 | 37 | return sectionTitle(log[sectionName].label) + `\n\n` + sectionCommits(log[sectionName].commits) + `\n` 38 | }) 39 | 40 | if (log.unspecified.commits.length) { 41 | doc.push( 42 | sectionTitle(log.unspecified.label) + 43 | `\n\n` + 44 | ` ` + 45 | log.unspecified.commits.map((c) => `${Chalk.gray(shortSha(c))} ${c.message.raw}`).join(`\n `) + 46 | `\n` 47 | ) 48 | } 49 | 50 | return doc.join(`\n`) 51 | } 52 | 53 | function sectionCommits(cs: Commit[], opts?: CommitRenderOpts): string { 54 | return cs.map((c) => sectionCommit(c, opts)).join(`\n`) 55 | } 56 | 57 | function sectionTitle(title: string): string { 58 | return Chalk.magenta(title) 59 | } 60 | 61 | type CommitRenderOpts = { type?: boolean; breaking?: boolean } 62 | 63 | function sectionCommit(c: Commit, opts?: CommitRenderOpts): string { 64 | const sha = Chalk.gray(shortSha(c)) 65 | const type = opts?.type === true ? ` ` + c.message.parsed!.type + `:` : `` 66 | const description = ` ` + c.message.parsed!.description 67 | const breaking = 68 | opts?.breaking === false ? `` : c.message.parsed!.breakingChange ? Chalk.red(` (breaking)`) : `` 69 | return ` ${sha}${breaking}${type}${description}` 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/conventional-commit.spec.ts: -------------------------------------------------------------------------------- 1 | import { calcBumpType, parse } from './conventional-commit' 2 | 3 | describe(calcBumpType.name, () => { 4 | it(`invalid message formats cause the commit to be ignored`, () => { 5 | expect(calcBumpType(false, [`unknown`])).toEqual(null) 6 | expect(calcBumpType(false, [`unknown`, `fix: 1`])).toEqual(`patch`) 7 | expect(calcBumpType(false, [`unknown`, `feat: 1`])).toEqual(`minor`) 8 | expect(calcBumpType(false, [`unknown\n\nBREAKING CHANGE: foo\nfoobar`])).toEqual(null) 9 | expect(calcBumpType(false, [`unknown`, `fix: 1\n\nBREAKING CHANGE: foo`])).toEqual(`major`) 10 | expect(calcBumpType(false, [`unknown`, `fix!: 1\n\nBREAKING CHANGE: foo`])).toEqual(`major`) 11 | expect(calcBumpType(false, [`unknown`, `fix!: 1`])).toEqual(`major`) 12 | }) 13 | 14 | describe(`initial development`, () => { 15 | it(`BREAKING CHANGE bumps minor`, () => { 16 | expect(calcBumpType(true, [`fix: 1`, `fix: 2\n\nBREAKING CHANGE: foobar`])).toEqual(`minor`) 17 | }) 18 | it(`initial development is completed by COMPLETES INITIAL DEVELOPMENT`, () => { 19 | expect(calcBumpType(true, [`fix: 1\n\nCOMPLETES INITIAL DEVELOPMENT`])).toEqual(`major`) 20 | }) 21 | }) 22 | 23 | describe(`post initial development`, () => { 24 | it(`COMPLETES INITIAL DEVELOPMENT is ignored`, () => { 25 | expect(calcBumpType(false, [`fix: 1\n\nCOMPLETES INITIAL DEVELOPMENT`])).toEqual(`patch`) 26 | }) 27 | 28 | it(`"fix" bumps patch`, () => { 29 | expect(calcBumpType(false, [`fix: 1`])).toEqual(`patch`) 30 | }) 31 | 32 | it(`"feat" bumps minor`, () => { 33 | expect(calcBumpType(false, [`feat: 1`])).toEqual(`minor`) 34 | }) 35 | 36 | it(`"feature" bumps minor`, () => { 37 | expect(calcBumpType(false, [`feat: 1`])).toEqual(`minor`) 38 | }) 39 | 40 | it(`presence of "BREAKING CHANGE:" bumps major`, () => { 41 | expect(calcBumpType(false, [`anything: 1\n\nBREAKING CHANGE:\nfoobar`])).toEqual(`major`) 42 | }) 43 | 44 | it(`an unknown change type bumps patch`, () => { 45 | expect(calcBumpType(false, [`anything: 1`])).toEqual(`patch`) 46 | }) 47 | 48 | it(`patch-level changes ignored if already bumped past patch`, () => { 49 | expect(calcBumpType(false, [`feat: 1`, `fix: 1`])).toEqual(`minor`) 50 | expect(calcBumpType(false, [`fix: 1`, `feat: 1`])).toEqual(`minor`) 51 | }) 52 | 53 | it(`feat-level changes ignored if already bumped past minor`, () => { 54 | expect(calcBumpType(false, [`feat: 1`, `fix: 1\n\nBREAKING CHANGE: foo`])).toEqual(`major`) 55 | expect(calcBumpType(false, [`fix: 1\n\nBREAKING CHANGE: foo`, `feat: 1`])).toEqual(`major`) 56 | }) 57 | 58 | it(`chore-type commits are ignored`, () => { 59 | expect(calcBumpType(false, [`chore: 1`])).toEqual(null) 60 | expect(calcBumpType(false, [`chore: 1`, `fix: 1`])).toEqual(`patch`) 61 | expect(calcBumpType(false, [`chore: 1`, `feat: 1`])).toEqual(`minor`) 62 | expect(calcBumpType(false, [`chore: 1\n\nBREAKING CHANGE: foo\nfoobar`])).toEqual(null) 63 | expect(calcBumpType(false, [`chore: 1`, `fix: 1\n\nBREAKING CHANGE: foo`])).toEqual(`major`) 64 | }) 65 | }) 66 | }) 67 | 68 | describe(parse.name, () => { 69 | // prettier-ignore 70 | it.each([ 71 | // type, description 72 | [`t: d`, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 73 | [`tt: dd`, { type: `tt`, description: `dd`, body: null, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 74 | // scope 75 | [`t(s): d`, { type: `t`, description: `d`, body: null, scope: `s`, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 76 | [`t(ss): d`, { type: `t`, description: `d`, body: null, scope: `ss`, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 77 | // body 78 | [`t: d\n\nb`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 79 | [`t: d\n\nbb\n1\n23`, { type: `t`, description: `d`, body: `bb\n1\n23`, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 80 | [`t: d\n\nb\n\nb f:1`, { type: `t`, description: `d`, body: `b\n\nb f:1`, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 81 | // footers 82 | [`t: d\n\nt1:1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1`, body:`1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 83 | [`t: d\n\nt1:\n\n1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1`, body:`1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 84 | [`t: d\n\nb\n\nt1:1`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`t1`, body:`1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 85 | [`t: d\n\nt1-1_1:1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1-1_1`, body:`1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 86 | [`t: d\n\nb\n\nt1:b1\n\nt2:b2`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`t1`, body:`b1` }, { type: `t2`, body: `b2` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 87 | [`t: d\n\nb\n\nt1:b1\n\nb1\n\nt2-2:b2\n\nb2`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`t1`, body:`b1\n\nb1` }, { type: `t2-2`, body: `b2\n\nb2` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 88 | // whitespace is trimmed 89 | [`t: d `, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 90 | [` t : d `, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 91 | [` t ( s ): d `, { type: `t`, description: `d`, body: null, scope: `s`, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 92 | [`t: d\n\nb\n\n f : 1 `, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`f`, body:`1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 93 | // we allow 0 or many spaces while cc-spec asks for exactly 1 94 | [`t:d`, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 95 | [`t: d`, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 96 | // invalids 97 | [`a`, null], 98 | [`a b`, null], 99 | [`a() b`, null], 100 | [`a(): b`, null], 101 | // spec invalid but we tolerate it: single line feed instead of two 102 | [`t: d\nb`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 103 | // breaking change 104 | [`t: d\n\nBREAKING CHANGE: foo`, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: `foo` , completesInitialDevelopment: false, typeKind: `other` }], 105 | [`t: d\n\nBREAKING-CHANGE:\n\nfoo\n\nbar `, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: `foo\n\nbar` , completesInitialDevelopment: false, typeKind: `other` }], 106 | [`t: d\n\nb\n\nBREAKING CHANGE: foo`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [], breakingChange: `foo` , completesInitialDevelopment: false, typeKind: `other` }], 107 | [`t: d\n\nb\n\nBREAKING CHANGE: foo\n\nt1:t1`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`t1`, body:`t1` }], breakingChange: `foo` , completesInitialDevelopment: false, typeKind: `other` }], 108 | [`t: d\n\nb\n\nt1:t1\n\nBREAKING CHANGE: foo `, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`t1`, body:`t1` }], breakingChange: `foo` , completesInitialDevelopment: false, typeKind: `other` }], 109 | // completing initial development 110 | // a spec extension https://github.com/conventional-commits/conventionalcommits.org/pull/214 111 | [`t: d\n\nCOMPLETES INITIAL DEVELOPMENT`, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null, completesInitialDevelopment: true, typeKind: `other` }], 112 | [`t: d\n\nCOMPLETES-INITIAL-DEVELOPMENT`, { type: `t`, description: `d`, body: null, scope: null, footers: [], breakingChange: null, completesInitialDevelopment: true, typeKind: `other` }], 113 | [`t: d\n\nb\n\nCOMPLETES-INITIAL-DEVELOPMENT`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [], breakingChange: null, completesInitialDevelopment: true, typeKind: `other` }], 114 | [`t: d\n\nb\n\nt1:b1\n\nCOMPLETES-INITIAL-DEVELOPMENT\n\nt2:b2`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [{ type:`t1`, body:`b1` },{ type:`t2`, body:`b2` }], breakingChange: null , completesInitialDevelopment: true, typeKind: `other` }], 115 | [`t: d\n\nb\n\nCOMPLETES-INITIAL-DEVELOPMENT\n\nBREAKING CHANGE:\n\nfoo`, { type: `t`, description: `d`, body: `b`, scope: null, footers: [], breakingChange: `foo`, completesInitialDevelopment: true, typeKind: `other` }], 116 | // not completing initial development 117 | [`t: d\n\n COMPLETES INITIAL DEVELOPMENT`, { type: `t`, description: `d`, body: `COMPLETES INITIAL DEVELOPMENT`, scope: null, footers: [], breakingChange: null, completesInitialDevelopment: false, typeKind: `other` }], 118 | [`t: d\n\n"COMPLETES INITIAL DEVELOPMENT"`, { type: `t`, description: `d`, body: `"COMPLETES INITIAL DEVELOPMENT"`, scope: null, footers: [], breakingChange: null, completesInitialDevelopment: false, typeKind: `other` }], 119 | // windows newlines 120 | [`t: d\r\n\r\nt1:b1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1`, body:`b1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 121 | [`t: d\r\n\r\nt1:\r\n\r\nb1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1`, body:`b1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 122 | // todo these tests show that we do not accurately retain windows newlines 123 | [`t: d\r\n\r\nt1:\r\n\r\nb1\r\n\r\nb1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1`, body:`b1\n\nb1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 124 | [`t: d\r\n\r\nt1:\r\n\r\nb1\r\n\r\n\r\nb1`, { type: `t`, description: `d`, body: null, scope: null, footers: [{ type:`t1`, body:`b1\n\n\r\nb1` }], breakingChange: null , completesInitialDevelopment: false, typeKind: `other` }], 125 | ])( 126 | `%s`, 127 | (given, expected) => { 128 | expect(parse(given)).toEqual(expected) 129 | } 130 | ) 131 | }) 132 | -------------------------------------------------------------------------------- /src/lib/conventional-commit.ts: -------------------------------------------------------------------------------- 1 | import * as Semver from './semver' 2 | 3 | /** 4 | * Given a list of conventional commit messages (subject and body, the entire 5 | * message for the commit) calculate what the package version containing these 6 | * changes should be. Returns `null` if all changes were meta or unconforming. 7 | */ 8 | export function calcBumpType( 9 | isInitialDevelopment: boolean, 10 | commitMessages: string[] 11 | ): null | Semver.MajMinPat { 12 | let semverPart: null | Semver.MajMinPat = null 13 | for (const m of commitMessages) { 14 | const cc = parse(m) 15 | 16 | // Commits that do not conform to conventional commit standard are discarded 17 | if (!cc) continue 18 | 19 | // Completing initial development is a spec extension 20 | // https://github.com/conventional-commits/conventionalcommits.org/pull/214 21 | if (isInitialDevelopment && cc.completesInitialDevelopment) return `major` 22 | 23 | if (isMetaChange(cc)) { 24 | // chore type commits are considered to not change the runtime in any way 25 | continue 26 | } 27 | 28 | // Nothing can be be higher so we've reached our final value effectively. 29 | if (cc.breakingChange) { 30 | // during initial development breaking changes are permitted without 31 | // having to bump the major. 32 | semverPart = isInitialDevelopment ? `minor` : `major` 33 | break 34 | } 35 | 36 | // If already at minor continue, now looking only for major changes 37 | if (semverPart === `minor`) { 38 | continue 39 | } 40 | 41 | if (isMinorChange(cc)) { 42 | semverPart = `minor` 43 | // during initial development breaking changes are permitted without 44 | // having to bump the major. Therefore, we know we won't get a bumpType 45 | // higher than this, can short-circuit. 46 | if (isInitialDevelopment) break 47 | else continue 48 | } 49 | 50 | semverPart = `patch` 51 | } 52 | 53 | return semverPart 54 | } 55 | 56 | function isMinorChange(conventionalCommit: ConventionalCommit): boolean { 57 | return [`feat`, `feature`].includes(conventionalCommit.type) 58 | } 59 | 60 | function isMetaChange(conventionalCommit: ConventionalCommit): boolean { 61 | return [`chore`].includes(conventionalCommit.type) 62 | } 63 | 64 | type Kind = `feat` | `fix` | `chore` | `other` 65 | 66 | export type ConventionalCommit = { 67 | typeKind: Kind 68 | type: string 69 | scope: null | string 70 | description: string 71 | body: null | string 72 | breakingChange: null | string 73 | footers: { type: string; body: string }[] 74 | completesInitialDevelopment: boolean 75 | } 76 | 77 | const pattern = /^([^:\r\n(!]+)(?:\(([^\r\n()]+)\))?(!)?:\s*([^\r\n]+)[\n\r]*(.*)$/s 78 | 79 | export function parse(message: string): null | ConventionalCommit { 80 | const result = message.match(pattern) 81 | if (!result) return null 82 | const [, type, scope, breakingChangeMarker, description, rest] = result 83 | 84 | let completesInitialDevelopment = false 85 | let breakingChange = breakingChangeMarker === undefined ? null : `No Explanation` 86 | let body = null 87 | let footers: ConventionalCommit[`footers`] = [] 88 | 89 | if (rest) { 90 | const rawFooters: string[] = [] 91 | 92 | let currFooter = -1 93 | let currSection = `body` 94 | for (const para of rest.split(/(?:\r?\n){2}/)) { 95 | if (para.match(/^COMPLETES[-\s]INITIAL[-\s]DEVELOPMENT\s*/)) { 96 | completesInitialDevelopment = true 97 | } else if (para.match(/^\s*BREAKING[-\s]CHANGE\s*:\s*.*/)) { 98 | currSection = `breaking_change` 99 | breakingChange = (breakingChange ?? ``) + `\n\n` + para.replace(/^BREAKING[-\s]CHANGE\s*:/, ``) 100 | } else if (para.match(/^\s*[\w-]+\s*:.*/)) { 101 | currSection = `footers` 102 | rawFooters.push(para) 103 | currFooter++ 104 | } else if (currSection === `body`) { 105 | body = (body ?? ``) + `\n\n` + para 106 | } else if (currSection === `breaking_change`) { 107 | breakingChange = (breakingChange ?? ``) + `\n\n` + para 108 | } else { 109 | rawFooters[currFooter] += `\n\n` + para 110 | } 111 | } 112 | 113 | footers = rawFooters.map((f) => { 114 | const [, type, body] = f.trim().split(/^\s*([\w-]+)\s*:/) 115 | return { 116 | type: type!.trim(), 117 | body: body!.trim(), 118 | } 119 | }) 120 | } 121 | 122 | const typeTrimmed = type!.trim() 123 | 124 | return { 125 | typeKind: getKind(typeTrimmed), 126 | type: typeTrimmed, 127 | scope: scope?.trim() ?? null, 128 | description: description!.trim(), 129 | body: body?.trim() ?? null, 130 | footers: footers ?? [], 131 | breakingChange: breakingChange?.trim() ?? null, 132 | completesInitialDevelopment, 133 | } 134 | } 135 | 136 | function getKind(s: string): Kind { 137 | if (s === `feat`) return `feat` 138 | if (s === `fix`) return `fix` 139 | if (s === `chore`) return `chore` 140 | return `other` 141 | } 142 | -------------------------------------------------------------------------------- /src/lib/debug.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import * as Path from 'path' 3 | 4 | export function rootDebug(componentName?: string) { 5 | let name = `dripip` 6 | if (componentName) { 7 | name += `:${Path.parse(componentName).name}` 8 | } 9 | return createDebug(name) 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/git2.ts: -------------------------------------------------------------------------------- 1 | import * as nodefs from 'fs' 2 | import isogit from 'isomorphic-git' 3 | import http from 'isomorphic-git/http/node' 4 | 5 | interface Input { 6 | cwd?: string 7 | } 8 | 9 | export type GitSyncStatus = `synced` | `not_synced` | `remote_needs_branch` 10 | 11 | class Git2 { 12 | private dir: string 13 | private fs: typeof nodefs 14 | private http: typeof http 15 | constructor(input?: Input) { 16 | this.dir = input?.cwd ?? process.cwd() 17 | this.fs = nodefs 18 | this.http = http 19 | } 20 | /** 21 | * Get the name of the currently checked out branch. 22 | */ 23 | getCurrentBranchName() { 24 | return isogit.currentBranch({ 25 | fs: this.fs, 26 | dir: this.dir, 27 | }) 28 | } 29 | /** 30 | * Check how the local branch is not in sync or is with the remote. 31 | * Ref: https://stackoverflow.com/questions/3258243/check-if-pull-needed-in-git 32 | */ 33 | async checkSyncStatus(input: { branchName: string }): Promise { 34 | const maybeRemoteUrl: unknown = await isogit.getConfig({ 35 | fs: this.fs, 36 | dir: this.dir, 37 | path: `remote.origin.url`, 38 | }) 39 | 40 | if (!maybeRemoteUrl) { 41 | throw new Error(`Could not find remoteUrl from the git config.`) 42 | } 43 | 44 | let remoteUrl = maybeRemoteUrl as string 45 | 46 | if (remoteUrl.startsWith(`git@github.com:`)) { 47 | remoteUrl = remoteUrl.replace(`git@github.com:`, `https://github.com/`) 48 | } 49 | if (!remoteUrl.endsWith(`.git`)) { 50 | remoteUrl = remoteUrl + `.git` 51 | } 52 | 53 | let remoteInfo 54 | 55 | try { 56 | remoteInfo = await isogit.getRemoteInfo({ 57 | http: this.http, 58 | url: remoteUrl, 59 | }) 60 | } catch (e) { 61 | throw new Error(`Failed to fetch remote info from ${remoteUrl} due to error:\n\n${e}`) 62 | } 63 | 64 | if (!remoteInfo.refs) { 65 | throw new Error(`Could not fetch refs`) 66 | } 67 | 68 | if (!remoteInfo.refs.heads) { 69 | throw new Error(`Could not fetch ref heads`) 70 | } 71 | 72 | if ( 73 | !Object.keys(remoteInfo.refs.heads).find((remoteBranchName) => remoteBranchName === input.branchName) 74 | ) { 75 | return `remote_needs_branch` 76 | } 77 | 78 | const localBranchHeadSha = await isogit.resolveRef({ fs: this.fs, dir: this.dir, ref: `HEAD` }) 79 | const remoteBranchHeadSha = remoteInfo.refs.heads[input.branchName] 80 | 81 | if (localBranchHeadSha === remoteBranchHeadSha) { 82 | return `synced` 83 | } 84 | 85 | return `not_synced` 86 | 87 | // todo https://github.com/isomorphic-git/isomorphic-git/issues/1110 88 | } 89 | } 90 | 91 | export const createGit = (input?: Input) => { 92 | return new Git2(input) 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/github-ci-environment.ts: -------------------------------------------------------------------------------- 1 | import { rootDebug } from './debug' 2 | 3 | const debug = rootDebug(__filename) 4 | 5 | export interface GitHubCIEnvironment { 6 | runId: number 7 | eventName: `pull_request` 8 | ref: null | string 9 | headRef: null | string 10 | repository: string 11 | parsed: { 12 | repo: { 13 | name: string 14 | owner: string 15 | } 16 | branchName: null | string 17 | prNum?: number 18 | } 19 | } 20 | 21 | /** 22 | * Parse the GitHub CI Environment. Returns null if parsing fails which should 23 | * mean it is not a GitHub CI Environment. 24 | * 25 | * @remarks 26 | * 27 | * GitHub docs: https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables 28 | */ 29 | export function parseGitHubCIEnvironment(): null | GitHubCIEnvironment { 30 | if (!isGitHubCIEnvironment()) return null 31 | 32 | let prNum: GitHubCIEnvironment[`parsed`][`prNum`] 33 | 34 | if (process.env.GITHUB_REF) { 35 | const match = process.env.GITHUB_REF.match(/refs\/pull\/(\d+)\/merge/) 36 | 37 | if (match) { 38 | debug(`found a pr number from github ci environment %s`, match[1]) 39 | prNum = parseInt(match[1]!, 10) 40 | } 41 | } 42 | 43 | const repoPath = process.env.GITHUB_REPOSITORY!.split(`/`) 44 | 45 | const repo = { 46 | owner: repoPath[0]!, 47 | name: repoPath[1]!, 48 | } 49 | 50 | let branchName: null | string = null 51 | if (process.env.GITHUB_HEAD_REF) { 52 | branchName = process.env.GITHUB_HEAD_REF 53 | } else if (process.env.GITHUB_REF) { 54 | branchName = process.env.GITHUB_REF.match(/^refs\/heads\/(.+)$/)?.[1] ?? null 55 | } 56 | 57 | return { 58 | runId: parseInt(process.env.GITHUB_RUN_ID!, 10), 59 | eventName: process.env.GITHUB_EVENT_NAME! as GitHubCIEnvironment[`eventName`], 60 | ref: process.env.GITHUB_REF ?? null, 61 | headRef: process.env.GITHUB_HEAD_REF ?? null, 62 | repository: process.env.GITHUB_REPOSITORY!, 63 | parsed: { 64 | prNum: prNum, 65 | repo: repo, 66 | branchName: branchName, 67 | }, 68 | } 69 | } 70 | 71 | /** 72 | * Check if the current process appears to be running in a GitHub CI environment. 73 | */ 74 | export function isGitHubCIEnvironment() { 75 | return process.env.GITHUB_RUN_ID !== undefined 76 | } 77 | -------------------------------------------------------------------------------- /src/lib/npm-auth.ts: -------------------------------------------------------------------------------- 1 | import { rootDebug } from './debug' 2 | import { isGitHubCIEnvironment } from './github-ci-environment' 3 | import * as fs from 'fs-jetpack' 4 | import * as os from 'os' 5 | import * as path from 'path' 6 | 7 | const debug = rootDebug(__filename) 8 | 9 | const TOKEN_ENV_VAR_NAME = `NPM_TOKEN` 10 | const npmrcFilePath = path.join(os.homedir(), `.npmrc`) 11 | 12 | /** 13 | * If in a CI environment and there is an NPM_TOKEN environment variable 14 | * then this function will setup an auth file that permits subsequent package 15 | * publishing commands. 16 | */ 17 | export function setupNPMAuthfileOnCI(): void { 18 | if (isGitHubCIEnvironment() && process.env.NPM_TOKEN) { 19 | const authContent = `//registry.npmjs.org/:_authToken=${process.env[TOKEN_ENV_VAR_NAME]}` 20 | debug(`writing or appending npm auth token to %s`, npmrcFilePath) 21 | fs.append(npmrcFilePath, authContent) 22 | } 23 | } 24 | 25 | /** 26 | * Read the npmrc file or return null if not found. 27 | */ 28 | function getNpmrcFile(): null | string { 29 | return fs.read(npmrcFilePath) ?? null 30 | } 31 | 32 | type PassReason = `npmrc_auth` | `npm_token_env_var` 33 | 34 | interface SetupPass { 35 | kind: `pass` 36 | reason: PassReason 37 | } 38 | 39 | type FailReason = `no_npmrc` | `npmrc_missing_auth` | `env_var_empty` | `no_env_var` 40 | 41 | interface SetupFail { 42 | kind: `fail` 43 | reasons: FailReason[] 44 | } 45 | 46 | /** 47 | * Find out whether NPM auth is setup or not. Setup means dripip upon publishing 48 | * will have credentials in place to work with the registry. Being setup is 49 | * satisfied by any of: 50 | * 51 | * - A non-empty NPM_TOKEN environment variable set 52 | * - An npmrc file containing auth 53 | */ 54 | export function validateNPMAuthSetup(): SetupPass | SetupFail { 55 | const token = process.env[TOKEN_ENV_VAR_NAME] ?? null 56 | 57 | if (token) { 58 | return { kind: `pass`, reason: `npm_token_env_var` } 59 | } 60 | 61 | const npmrc = getNpmrcFile() 62 | if (npmrc && npmrc.match(/_authToken=.+/)) { 63 | return { kind: `pass`, reason: `npmrc_auth` } 64 | } 65 | 66 | const fail: SetupFail = { kind: `fail`, reasons: [] } 67 | 68 | if (npmrc === null) { 69 | fail.reasons.push(`no_npmrc`) 70 | } else { 71 | fail.reasons.push(`npmrc_missing_auth`) 72 | } 73 | 74 | if (token === null) { 75 | fail.reasons.push(`no_env_var`) 76 | } else { 77 | fail.reasons.push(`env_var_empty`) 78 | } 79 | 80 | return fail 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/package-json.spec.ts: -------------------------------------------------------------------------------- 1 | // todo tests for p validation on read and write 2 | // todo return values 3 | // todo not found cases 4 | import { createWorkspace } from '../../tests/__lib/workspace' 5 | import * as PJ from './package-json' 6 | 7 | const ws = createWorkspace({ 8 | name: `package-json`, 9 | git: false, 10 | cache: { on: false }, 11 | }) 12 | 13 | let pj: PJ.PJ 14 | beforeEach(() => { 15 | pj = PJ.create(ws.dir.path) 16 | }) 17 | 18 | describe(`read`, () => { 19 | it(`reads a package json in cwd`, async () => { 20 | ws.fs.write(`package.json`, { name: `foo`, version: `0.0.0` }) 21 | expect(await pj.read()).toMatchInlineSnapshot(` 22 | Object { 23 | "name": "foo", 24 | "version": "0.0.0", 25 | } 26 | `) 27 | }) 28 | }) 29 | 30 | describe(`readSync`, () => { 31 | it(`synchronously reads a package json in cwd`, async () => { 32 | ws.fs.write(`package.json`, { name: `foo`, version: `0.0.0` }) 33 | expect(pj.readSync()).toMatchInlineSnapshot(` 34 | Object { 35 | "name": "foo", 36 | "version": "0.0.0", 37 | } 38 | `) 39 | }) 40 | }) 41 | 42 | describe(`write`, () => { 43 | it(`writes a package json in cwd`, async () => { 44 | await pj.write({ name: `foo`, version: `0.0.0` }) 45 | expect(ws.fs.read(`package.json`, `json`)).toMatchInlineSnapshot(` 46 | Object { 47 | "name": "foo", 48 | "version": "0.0.0", 49 | } 50 | `) 51 | }) 52 | }) 53 | 54 | describe(`update`, () => { 55 | it(`reads, updates, then writes back`, async () => { 56 | ws.fs.write(`package.json`, { name: `foo`, version: `0.0.0` }) 57 | await pj.update((p) => Object.assign(p, { version: `1.0.0` })) 58 | expect(ws.fs.read(`package.json`, `json`)).toMatchInlineSnapshot(` 59 | Object { 60 | "name": "foo", 61 | "version": "1.0.0", 62 | } 63 | `) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/lib/package-json.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-jetpack' 2 | import { PackageJson } from 'type-fest' 3 | 4 | type PackageJsonUpdater = (packageJson: Record) => Record 5 | 6 | /** 7 | * Read and validate the package.json from CWD. 8 | */ 9 | export async function getPackageJson(): Promise { 10 | const pj = await read(process.cwd()) 11 | return validate(pj) 12 | } 13 | 14 | /** 15 | * Read and validate the package.json from CWD. 16 | */ 17 | export function getPackageJsonSync(): ValidatedPackageJson { 18 | const pj = readSync(process.cwd()) 19 | return validate(pj) 20 | } 21 | 22 | type ValidatedPackageJson = PackageJson & { name: string } 23 | 24 | /** 25 | * Validate that the given package json is defined, has a valid name property. 26 | */ 27 | function validate(pj: PackageJson | undefined): ValidatedPackageJson { 28 | if (!pj) { 29 | throw new Error(`Could not find package.json`) 30 | } 31 | 32 | if (pj.name === undefined) { 33 | throw new Error(`package.json is missing name field`) 34 | } 35 | 36 | if (pj.name === ``) { 37 | throw new Error(`package.json name field is empty`) 38 | } 39 | 40 | return pj as ValidatedPackageJson 41 | } 42 | 43 | /** 44 | * Read the package.json file. 45 | */ 46 | export async function read(cwd: string): Promise { 47 | return fs.readAsync(fs.path(cwd, `package.json`), `json`) 48 | 49 | // if (!packageJson) { 50 | // throw new Error( 51 | // `Looked for but could not find a package.json file at ${packageJsonPath}. This file is required for publishing to the npm registry.` 52 | // ) 53 | // } 54 | 55 | // if (typeof packageJson !== 'object') { 56 | // throw new Error( 57 | // `Found a package.json file at ${packageJsonPath} but it appears to be malformed. It did not parse into an object but rather: ${packageJson}` 58 | // ) 59 | // } 60 | } 61 | 62 | /** 63 | * Read the package.json file synchronously. 64 | */ 65 | export function readSync(cwd: string): undefined | PackageJson { 66 | return fs.read(fs.path(cwd, `package.json`), `json`) 67 | } 68 | 69 | /** 70 | * Write the package.json file. 71 | */ 72 | export async function write(cwd: string, object: object): Promise { 73 | return fs.writeAsync(fs.path(cwd, `package.json`), object) 74 | } 75 | 76 | /** 77 | * Update the package.json located at cwd. The given updater function will 78 | * receive the parsed package contents and whatever is returned will be written 79 | * to disk. 80 | */ 81 | export async function update(cwd: string, updater: PackageJsonUpdater): Promise { 82 | const packageJson = await read(cwd) 83 | if (packageJson) { 84 | const packageJsonUpdated = await updater(packageJson) 85 | await fs.writeAsync(fs.path(cwd, `package.json`), packageJsonUpdated) 86 | } 87 | } 88 | 89 | export function create(cwd?: string) { 90 | cwd = cwd ?? process.cwd() 91 | return { 92 | read: read.bind(null, cwd), 93 | readSync: readSync.bind(null, cwd), 94 | write: write.bind(null, cwd), 95 | update: update.bind(null, cwd), 96 | } 97 | } 98 | 99 | export type PJ = ReturnType 100 | -------------------------------------------------------------------------------- /src/lib/pacman.ts: -------------------------------------------------------------------------------- 1 | import * as PJ from './package-json' 2 | import * as proc from './proc' 3 | import { casesHandled, errorFromMaybeError } from './utils' 4 | import * as fs from 'fs-jetpack' 5 | 6 | type PackageManagerType = `npm` | `yarn` 7 | 8 | /** 9 | * This module abstracts running package manager commands. It is useful when 10 | * your users might be using npm or yarn but your code needs to stay agnostic to 11 | * which one. 12 | */ 13 | 14 | /** 15 | * Detect if being run within a yarn or npm script. Ref 16 | * https://stackoverflow.com/questions/51768743/how-to-detect-that-the-script-is-running-with-npm-or-yarn/51793644#51793644 17 | */ 18 | function detectScriptRunner(): null | `npm` | `yarn` { 19 | if (process.env.npm_execpath?.match(/.+npm-cli.js$/)) return `npm` 20 | if (process.env.npm_execpath?.match(/.+yarn.js$/)) return `yarn` 21 | return null 22 | } 23 | 24 | /** 25 | * Publishes the package. 26 | * 27 | * About version handling and package.json: 28 | * 29 | * In the case of npm the given version will be written to the package.json 30 | * before publishing and then unwritten afterward. 31 | * 32 | * In the case of yarn, package.json is not touched at all. 33 | * 34 | * Either way, it should not be noticeable to the user of this function. 35 | */ 36 | async function publish(manType: PackageManagerType, input: { version: string; tag: string }): Promise { 37 | if (manType === `yarn`) { 38 | const runString = `yarn publish --tag ${input.tag} --no-git-tag-version --new-version ${input.version}` 39 | await proc.run(runString, { require: true }) 40 | } else if (manType === `npm`) { 41 | const pj = PJ.create(process.cwd()) 42 | const pjd = (await pj.read())! // assume present and valid package.json has been validated already 43 | await pj.write({ ...pjd, version: input.version }) 44 | const runString = `npm publish --tag ${input.tag}` 45 | await proc.run(runString, { require: true }) 46 | await pj.write(pjd) 47 | } else { 48 | casesHandled(manType) 49 | } 50 | } 51 | 52 | /** 53 | * Set an npm "dist-tag" to point at a particular version in the npm registry. 54 | * If the dist-tag does not exist it will be created. If it already exists it 55 | * will be moved without error. If it already points to the given version, 56 | * nothing will happen, including namely no error. 57 | */ 58 | async function tag( 59 | manType: PackageManagerType, 60 | packageName: string, 61 | input: { 62 | packageVersion: string 63 | tagName: string 64 | } 65 | ) { 66 | const runString = 67 | manType === `npm` 68 | ? `npm dist-tags add ${packageName}@${input.packageVersion} ${input.tagName}` 69 | : manType === `yarn` 70 | ? `yarn tag add ${packageName}@${input.packageVersion} ${input.tagName}` 71 | : casesHandled(manType) 72 | try { 73 | await proc.run(runString, { require: true }) 74 | } catch (maybeError) { 75 | const e = errorFromMaybeError(maybeError) 76 | if ( 77 | manType === `yarn` && 78 | e.message.match(/error Couldn't add tag./) && 79 | e.message.match(/error An unexpected error occurred: ""./) 80 | ) { 81 | try { 82 | fs.remove(`yarn-error.log`) 83 | } catch (e) { 84 | // silence error if for some reason we cannot clean up the yarn error 85 | // log because it is not important enough to handle/tell user about. 86 | } 87 | // Silence error here because there is a bug with yarn where tag change command errors out but 88 | // actually work, ref: https://github.com/yarnpkg/yarn/issues/7823 89 | } else { 90 | throw e 91 | } 92 | } 93 | } 94 | 95 | export async function create(input: { default: PackageManagerType }) { 96 | const packageManagerType = detectScriptRunner() ?? input.default 97 | const packageJson = PJ.getPackageJsonSync() 98 | return { 99 | publish: publish.bind(null, packageManagerType), 100 | tag: tag.bind(null, packageManagerType, packageJson.name), 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/proc.ts: -------------------------------------------------------------------------------- 1 | import { spawn,SpawnOptions } from 'child_process' 2 | import { stripIndent } from 'common-tags' 3 | import * as Path from 'path' 4 | 5 | export type SuccessfulRunResult = { 6 | command: string 7 | stderr: null | string // present if stdio using pipe mode 8 | stdout: null | string // present if stdio using pipe mode 9 | signal: null | string 10 | exitCode: null | number // present if optional (non-throw) mode 11 | error: null | Error // present if optional (non-throw) mode 12 | } 13 | 14 | export type RunOptions = SpawnOptions & { 15 | envAdditions?: Record 16 | require?: boolean 17 | } 18 | 19 | /** 20 | * A wrapper around spawn, easier to use. 21 | */ 22 | export async function run(commandRaw: string, options?: RunOptions): Promise { 23 | const command = parseCommandString(commandRaw) 24 | const env = options?.envAdditions ? { ...process.env, ...options.envAdditions } : process.env 25 | 26 | const child = spawn(command.name, command.args, { 27 | ...options, 28 | env, 29 | }) 30 | 31 | // TODO use proper signal typing, see child exit cb types 32 | const result = await new Promise((resolve, reject) => { 33 | // NOTE "exit" may fire after "error", in which case it will be a noop 34 | // as per how promises work. 35 | 36 | // When spawn is executed in pipe mode, then we buffer up the data for 37 | // later inspection 38 | // TODO return type should use conditional types to express mapping 39 | // between stdio option settings and resulting returned std err/out buffers. 40 | let stderr: null | string = null 41 | let stdout: null | string = null 42 | 43 | if (child.stderr) { 44 | stderr = `` 45 | child.stderr.on(`data`, bufferStderr) 46 | } 47 | 48 | if (child.stdout) { 49 | stdout = `` 50 | child.stdout.on(`data`, bufferStdout) 51 | } 52 | 53 | function bufferStderr(chunk: any) { 54 | stderr += String(chunk) 55 | } 56 | 57 | function bufferStdout(chunk: any) { 58 | stdout += String(chunk) 59 | } 60 | 61 | child.once(`error`, (error) => { 62 | const richError = createCommandError({ 63 | command: commandRaw, 64 | underlyingError: error, 65 | stderr, 66 | stdout, 67 | signal: null, 68 | exitCode: null, 69 | }) 70 | 71 | if (options?.require === true) { 72 | cleanup() 73 | reject(richError) 74 | } else { 75 | cleanup() 76 | resolve({ 77 | command: commandRaw, 78 | stdout, 79 | stderr, 80 | signal: null, 81 | error: richError, 82 | exitCode: null, 83 | }) 84 | } 85 | }) 86 | 87 | child.once(`exit`, (exitCode, signal) => { 88 | const error = isFailedExitCode(exitCode) 89 | ? createCommandError({ 90 | command: commandRaw, 91 | underlyingError: null, 92 | signal, 93 | stderr, 94 | stdout, 95 | exitCode, 96 | }) 97 | : null 98 | 99 | if (options?.require === true && isFailedExitCode(exitCode)) { 100 | cleanup() 101 | reject(error) 102 | } else { 103 | cleanup() 104 | resolve({ 105 | command: commandRaw, 106 | signal, 107 | stderr, 108 | stdout, 109 | exitCode, 110 | error, 111 | }) 112 | } 113 | }) 114 | 115 | function cleanup() { 116 | child.stderr?.removeListener(`data`, bufferStderr) 117 | child.stdout?.removeListener(`data`, bufferStdout) 118 | } 119 | }) 120 | 121 | return result 122 | } 123 | 124 | /** 125 | * Util that binds a command to run making it easy to abstract a parent command. 126 | * 127 | * @example 128 | * 129 | * const git = createRunner('git') 130 | * const result = await git('status') 131 | */ 132 | export const createRunner = (cwd: string): typeof run => { 133 | return (cmd, opts) => { 134 | return run(cmd, { ...opts, cwd }) 135 | } 136 | } 137 | 138 | /** 139 | * Create an error enriched with properties related to the run result. 140 | */ 141 | function createCommandError({ 142 | command, 143 | signal, 144 | stderr, 145 | stdout, 146 | exitCode, 147 | underlyingError, 148 | }: Omit & { 149 | underlyingError: null | Error 150 | }): Error { 151 | const error = new Error(stripIndent` 152 | The following command failed to complete successfully: 153 | 154 | ${command} 155 | 156 | It ended with this exit code: 157 | 158 | ${exitCode} 159 | 160 | This underlying error occurred (null = none occurred): 161 | 162 | ${underlyingError} 163 | 164 | It received signal (null = no signal received): 165 | 166 | ${signal} 167 | 168 | It output on stderr (null = not spawned in pipe mode): 169 | 170 | ${stderr} 171 | 172 | It output on stdout (null = not spawned in pipe mode): 173 | 174 | ${stdout} 175 | `) 176 | // @ts-ignore 177 | error.exitCode = exitCode 178 | // @ts-ignore 179 | error.signal = signal 180 | // @ts-ignore 181 | error.stderr = stderr 182 | // @ts-ignore 183 | error.stdout = stdout 184 | return error 185 | } 186 | 187 | /** 188 | * Convert a command string into something the standard lib child process module 189 | * APIs will accept. 190 | */ 191 | function parseCommandString(cmd: string): { name: string; args: string[] } { 192 | const [name, ...args] = cmd.split(` `) 193 | 194 | return { 195 | name: name!, 196 | args, 197 | } 198 | } 199 | 200 | /** 201 | * Is the given exit code a failure or success? Intended for internal use, 202 | * handles null which is convenient for this module. 203 | */ 204 | function isFailedExitCode(exitCode: null | number): boolean { 205 | return typeof exitCode === `number` && exitCode !== 0 206 | } 207 | 208 | /** 209 | * Check if this process was created from the bin of the given project or not. 210 | * @param packageJsonPath 211 | */ 212 | export function isProcessFromProjectBin(packageJsonPath: string): boolean { 213 | const processBinPath = process.argv[1]! 214 | const processBinDirPath = Path.dirname(processBinPath) 215 | const projectBinDirPath = Path.join(Path.dirname(packageJsonPath), `node_modules/.bin`) 216 | return processBinDirPath !== projectBinDirPath 217 | } 218 | 219 | /** 220 | * Log a meaningful semantic error message sans stack track and then crash 221 | * the program with exit code 1. Parameters are a passthrough to `console.error`. 222 | */ 223 | export function fatal(format: string, ...vars: unknown[]): never { 224 | console.error(format, ...vars) 225 | process.exit(1) 226 | } 227 | -------------------------------------------------------------------------------- /src/lib/publish-changelog.ts: -------------------------------------------------------------------------------- 1 | import { Octokit, ReleaseByTagRes } from '../utils/octokit' 2 | import { Release } from '../utils/release' 3 | import { isPreview, isStable, PreviewVer, renderStyledVersion } from './semver' 4 | import { errorFromMaybeError } from './utils' 5 | import { inspect } from 'util' 6 | 7 | interface Repo { 8 | owner: string 9 | name: string 10 | } 11 | 12 | interface Input { 13 | octokit: Octokit 14 | repo: Repo 15 | /** 16 | * Uses the release to manage the changelog changes. A preview release will result 17 | * in a pre-release github release. A stable release will result in the 18 | * preview github release being cleared of notes and pointed toward the latest 19 | * stable commit sha. 20 | */ 21 | release: Release & { head: { sha: string } } 22 | /** 23 | * The Changelog content. 24 | */ 25 | body: string 26 | options?: { 27 | /** 28 | * Mark the GitHub release as a draft rather than published. 29 | */ 30 | draft?: boolean 31 | } 32 | } 33 | 34 | /** 35 | * Publish a changelog. The given git tag should have already been pushed to the 36 | * remote. If the release is a preview then the github release will be made 37 | * against the pre-release identifier name. Otherwise the github release will be 38 | * made against the styled version. 39 | */ 40 | export async function publishChangelog(input: Input) { 41 | const { 42 | octokit, 43 | release, 44 | repo: { owner, name: repo }, 45 | } = input 46 | 47 | let res: any 48 | try { 49 | if (isStable(release.version)) { 50 | res = await octokit.repos.createRelease({ 51 | owner, 52 | repo, 53 | prerelease: false, 54 | tag_name: renderStyledVersion(release.version), 55 | draft: input.options?.draft ?? false, 56 | body: input.body, 57 | }) 58 | const existingPreviewRelease = await maybeGetRelease({ octokit, owner, repo, tag: `next` }) 59 | if (existingPreviewRelease) { 60 | res = await octokit.repos.updateRelease({ 61 | owner, 62 | repo, 63 | release_id: existingPreviewRelease.data.id, 64 | target_commitish: release.head.sha, 65 | body: `None since last stable.`, 66 | }) 67 | } 68 | } else if (isPreview(release.version)) { 69 | const v = release.version as PreviewVer 70 | const tag = v.preRelease.identifier 71 | const existingPreviewRelease = await maybeGetRelease({ octokit, owner, repo, tag }) 72 | 73 | if (!existingPreviewRelease) { 74 | res = await octokit.repos.createRelease({ 75 | owner, 76 | repo, 77 | prerelease: true, 78 | tag_name: v.preRelease.identifier, 79 | draft: input.options?.draft ?? false, 80 | body: input.body, 81 | }) 82 | } else { 83 | res = await octokit.repos.updateRelease({ 84 | owner, 85 | repo, 86 | release_id: existingPreviewRelease.data.id, 87 | body: input.body, 88 | }) 89 | } 90 | } else { 91 | // Should never happen if used correctly. 92 | throw new Error( 93 | `WARNING: release notes are not supported for this kind of release: ${inspect(release)}` 94 | ) 95 | } 96 | } catch (e) { 97 | throw new Error(`Failed to publish changelog\n\n${inspect(e)}`) 98 | } 99 | return res 100 | } 101 | 102 | async function maybeGetRelease(input: { 103 | octokit: Octokit 104 | owner: string 105 | repo: string 106 | tag: string 107 | }): Promise { 108 | let res = null 109 | try { 110 | res = await input.octokit.repos.getReleaseByTag({ 111 | owner: input.owner, 112 | repo: input.repo, 113 | tag: input.tag, 114 | }) 115 | } catch (maybeError) { 116 | const error = errorFromMaybeError(maybeError) as Error & { status: number } 117 | if (error.status !== 404) { 118 | throw new Error( 119 | `Failed to fetch releases for tag ${input.tag} on repo ${input.owner}/${input.repo}.\n\n${inspect( 120 | error 121 | )}` 122 | ) 123 | } 124 | } 125 | return res 126 | } 127 | -------------------------------------------------------------------------------- /src/lib/publish-package.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module handles the concerns of publishing. It handles interaction with 3 | * git tagging, pushing to the git origin, the package registry, etc. 4 | */ 5 | 6 | import * as Git from './git' 7 | import { isGitHubCIEnvironment } from './github-ci-environment' 8 | import * as Pacman from './pacman' 9 | import createGit from 'simple-git/promise' 10 | 11 | type Options = { 12 | /** 13 | * Should publishing to npm take place? 14 | * 15 | * @defaultValue true 16 | */ 17 | npm?: boolean 18 | /** 19 | * Should the semver git tag have a "v" prefix. 20 | * 21 | * @defaultValue false 22 | */ 23 | gitTagVPrefix?: boolean 24 | /** 25 | * Should each given dist tag have a corresponding git tag made? 26 | * 27 | * @defaultValue 'all' 28 | */ 29 | gitTag?: `all` | `just_version` | `just_dist_tags` | `none` 30 | } 31 | 32 | const optionDefaults: Options = { 33 | gitTagVPrefix: false, 34 | npm: true, 35 | gitTag: `all`, 36 | } 37 | 38 | export interface Release { 39 | /** 40 | * The version to publish. 41 | */ 42 | version: string 43 | /** 44 | * The npm dist tag to use for this release. 45 | */ 46 | distTag: string 47 | /** 48 | * Additional dist tags to use for this release. 49 | * 50 | * @remarks 51 | * 52 | * When publishing it is sometimes desirable to update other dist tags to 53 | * point at the new version. For example "next" should never fall behind 54 | * stable, etc. 55 | */ 56 | extraDistTags?: string[] 57 | /** 58 | * Release notes. 59 | */ 60 | notes?: string 61 | } 62 | 63 | export interface PublishPlan { 64 | release: Release 65 | options?: Options 66 | } 67 | 68 | /** 69 | * Events that provide insight into the progress of the publishing process. 70 | */ 71 | type ProgressMessage = 72 | | { kind: `extra_dist_tag_updated`; distTag: string } 73 | | { kind: `package_published` } 74 | | { kind: `package_json_reverted` } 75 | | { kind: `version_git_tag_created` } 76 | | { kind: `extra_dist_tag_git_tag_created`; distTag: string } 77 | | { kind: `extra_dist_tag_git_tag_pushed`; distTag: string } 78 | 79 | /** 80 | * Run the publishing process. 81 | * 82 | * 1. Change package.json version field to be new version. 83 | * 2. npm publish --tag next. 84 | * 3. discard package.json change. 85 | * 4. git tag {newVer}. 86 | * 5. git tag next. 87 | * 6. git push --tags. 88 | * 89 | */ 90 | export async function* publishPackage(input: PublishPlan): AsyncGenerator { 91 | const release = input.release 92 | const opts = { 93 | ...optionDefaults, 94 | ...input.options, 95 | } 96 | 97 | if (opts.npm) { 98 | // publish to the npm registry 99 | // 100 | // If we are using a script runner then publish with that same tool. Otherwise 101 | // default to using npm. The reason we need to do this is that problems occur 102 | // when mixing tools. For example `yarn run ...` will lead to a spawn of `npm 103 | // publish` failing due to an authentication error. 104 | const pacman = await Pacman.create({ default: `npm` }) 105 | await pacman.publish({ version: release.version, tag: release.distTag }) 106 | yield { kind: `package_published` } 107 | 108 | // todo parallel optimize? 109 | if (release.extraDistTags) { 110 | for (const distTag of release.extraDistTags) { 111 | await pacman.tag({ packageVersion: release.version, tagName: distTag }) 112 | yield { kind: `extra_dist_tag_updated`, distTag } 113 | } 114 | } 115 | } 116 | 117 | // While the fields of the package.json should not have changed, its 118 | // formatting, like indentation level, might have. We do not want to leave a 119 | // dirty working directory on the user's system. 120 | // 121 | // TODO no invariant in system that checks that package.json was not modified 122 | // before beginning the publishing process. In other words we may be losing 123 | // user work here. This check should be in strict mode. 124 | const git = createGit() 125 | await setupGitUsernameAndEmailOnCI(git) 126 | await git.checkout(`package.json`) 127 | yield { kind: `package_json_reverted` } 128 | 129 | // Tag the git commit 130 | // 131 | const versionTag = opts.gitTagVPrefix ? `v` + release.version : release.version 132 | 133 | if (opts.gitTag === `all` || opts.gitTag === `just_version`) { 134 | await git.addAnnotatedTag(versionTag, versionTag) 135 | // Avoid general git push tags otherwise we could run into trying to push e.g. 136 | // old `next` tag (dist-tags, forced later) that was since updated on remote 137 | // by CI––assuming user is doing a publish from their machine (maybe stable 138 | // for example). 139 | // Ref: https://stackoverflow.com/questions/23212452/how-to-only-push-a-specific-tag-to-remote 140 | await git.raw([`push`, `origin`, `refs/tags/${versionTag}`]) 141 | yield { kind: `version_git_tag_created` } 142 | } 143 | 144 | // Tag the git commit with the given dist tag names 145 | // 146 | if (opts.gitTag === `all` || opts.gitTag === `just_dist_tags`) { 147 | // todo parallel optimize? 148 | const distTags = [release.distTag, ...(release.extraDistTags ?? [])] 149 | for (const distTag of distTags) { 150 | // dist tags are movable pointers. Except for init case it is expected to 151 | // exist in the git repo. So use force to move the tag. 152 | // https://stackoverflow.com/questions/8044583/how-can-i-move-a-tag-on-a-git-branch-to-a-different-commit 153 | // todo provide nice semantic descriptions for each dist tag 154 | await git.raw([`tag`, `--force`, `--message`, distTag, distTag]) 155 | yield { kind: `extra_dist_tag_git_tag_created`, distTag } 156 | await git.raw([`push`, `--force`, `--tags`]) 157 | yield { kind: `extra_dist_tag_git_tag_pushed`, distTag } 158 | } 159 | } 160 | } 161 | 162 | // todo turn this into a check 163 | /** 164 | * On CI set the local Git config user email and name if not already set on the 165 | * machine. If not CI this function is a no-op. 166 | * 167 | * @remarks 168 | * 169 | * It can happen that no user name or email is setup on a machine for git. 170 | * Certain git commands fail in that case like creating annotated tags. Ref: 171 | * https://stackoverflow.com/questions/11656761/git-please-tell-me-who-you-are-error. 172 | * 173 | * TODO refactor using https://github.com/isomorphic-git/isomorphic-git/issues/236#issuecomment-533889774 174 | * To move away from simple-git. 175 | */ 176 | const setupGitUsernameAndEmailOnCI = async (git: Git.Simple) => { 177 | if (!isGitHubCIEnvironment()) return 178 | 179 | const [email, name] = await Promise.all([ 180 | git.raw([`config`, `--get`, `user.email`]), 181 | git.raw([`config`, `--get`, `user.name`]), 182 | ]) 183 | 184 | const promises = [] 185 | 186 | if (!email) { 187 | promises.push(git.addConfig(`user.name`, `dripip`)) 188 | } 189 | if (!name) { 190 | promises.push(git.addConfig(`user.email`, `dripip@prisma.io`)) 191 | } 192 | 193 | await Promise.all(promises) 194 | } 195 | -------------------------------------------------------------------------------- /src/lib/semver.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Semver from './semver' 2 | 3 | describe(`parse`, () => { 4 | it(`0.0.1`, () => { 5 | expect(Semver.parse(`0.0.1`)).toMatchSnapshot() 6 | }) 7 | 8 | it(`0.1.0`, () => { 9 | expect(Semver.parse(`0.1.0`)).toMatchSnapshot() 10 | }) 11 | 12 | it(`1.0.0`, () => { 13 | expect(Semver.parse(`1.0.0`)).toMatchSnapshot() 14 | }) 15 | 16 | it(`0.0.11`, () => { 17 | expect(Semver.parse(`0.0.11`)).toMatchSnapshot() 18 | }) 19 | 20 | it(`0.11.0`, () => { 21 | expect(Semver.parse(`0.11.0`)).toMatchSnapshot() 22 | }) 23 | 24 | it(`11.0.0`, () => { 25 | expect(Semver.parse(`11.0.0`)).toMatchSnapshot() 26 | }) 27 | 28 | describe(`v-prefix`, () => { 29 | it(`v0.0.0`, () => { 30 | expect(Semver.parse(`v0.0.0`)).toMatchSnapshot() 31 | }) 32 | 33 | it(`v0.0.0-a.1`, () => { 34 | expect(Semver.parse(`v0.0.0-a.1`)).toMatchSnapshot() 35 | }) 36 | }) 37 | 38 | describe(`pre-releases`, () => { 39 | it(`0.0.0-a.1`, () => { 40 | expect(Semver.parse(`0.0.0-a.1`)).toMatchSnapshot() 41 | }) 42 | }) 43 | 44 | describe(`rejects`, () => { 45 | it.each([[`1`], [``], [`a.b.c`], [`0.0.0-`], [`0.0.0-1`], [`0.0.0-a`], [`0.0.0-a.a`]])(`%s`, (caze) => { 46 | expect(Semver.parse(caze)).toBeNull() 47 | }) 48 | }) 49 | }) 50 | 51 | describe(`incStable`, () => { 52 | it(`can bump patch`, () => { 53 | expect(Semver.incStable(`major`, Semver.createStable(1, 1, 1))).toMatchSnapshot() 54 | }) 55 | it(`can bump minor`, () => { 56 | expect(Semver.incStable(`minor`, Semver.createStable(1, 1, 1))).toMatchSnapshot() 57 | }) 58 | it(`can bump major`, () => { 59 | expect(Semver.incStable(`major`, Semver.createStable(1, 1, 1))).toMatchSnapshot() 60 | }) 61 | it(`propagates vprefix`, () => { 62 | expect(Semver.incStable(`major`, Semver.createStable(1, 1, 1, { vprefix: true }))).toMatchSnapshot() 63 | }) 64 | }) 65 | 66 | describe(`stableToPreview`, () => { 67 | it(`turns stable-release version into a pre-release one`, () => { 68 | expect(Semver.stableToPreview(Semver.createStable(1, 2, 3), `foobar`, 100)).toMatchSnapshot() 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /src/lib/semver.ts: -------------------------------------------------------------------------------- 1 | export type Ver = StableVer | PreviewVer | PullRequestVer 2 | 3 | export type StableVer = { 4 | version: string 5 | vprefix: boolean 6 | major: number 7 | minor: number 8 | patch: number 9 | } 10 | 11 | export type PreviewVer = { 12 | version: string 13 | vprefix: boolean 14 | major: number 15 | minor: number 16 | patch: number 17 | preRelease: { 18 | identifier: string 19 | buildNum: number 20 | } 21 | } 22 | 23 | export type PullRequestVer = { 24 | version: string 25 | vprefix: boolean 26 | major: number 27 | minor: number 28 | patch: number 29 | preRelease: { 30 | identifier: `pr` 31 | prNum: number 32 | shortSha: string 33 | } 34 | } 35 | 36 | export type MajMinPat = `major` | `minor` | `patch` 37 | 38 | /** 39 | * Calculate the stable increment to a given version. 40 | */ 41 | export function incStable(bumpType: MajMinPat, v: Ver): Ver { 42 | const { vprefix, major, minor, patch } = v 43 | switch (bumpType) { 44 | case `major`: 45 | return createStable(major + 1, 0, 0, { vprefix }) 46 | case `minor`: 47 | return createStable(major, minor + 1, 0, { vprefix }) 48 | case `patch`: 49 | return createStable(major, minor, patch + 1, { vprefix }) 50 | } 51 | } 52 | 53 | /** 54 | * Add pre-release info to a stable release. In other words convert a stable 55 | * release into a pre-release one. 56 | */ 57 | export function stableToPreview(v: StableVer, identifier: string, buildNum: number): PreviewVer { 58 | return createPreRelease(v.major, v.minor, v.patch, identifier, buildNum) 59 | } 60 | 61 | /** 62 | * Create a semantic pre-release version programatically. 63 | */ 64 | export function createPreRelease( 65 | major: number, 66 | minor: number, 67 | patch: number, 68 | identifier: string, 69 | buildNum: number, 70 | opts?: { vprefix: boolean } 71 | ): PreviewVer { 72 | return { 73 | version: `${major}.${minor}.${patch}-${identifier}.${buildNum}`, 74 | vprefix: opts?.vprefix ?? false, 75 | major, 76 | minor, 77 | patch, 78 | preRelease: { 79 | buildNum, 80 | identifier, 81 | }, 82 | } 83 | } 84 | 85 | /** 86 | * Create a semantic version programatically. 87 | */ 88 | export function createStable( 89 | major: number, 90 | minor: number, 91 | patch: number, 92 | opts?: { vprefix: boolean } 93 | ): StableVer { 94 | return { 95 | major, 96 | minor, 97 | patch, 98 | vprefix: opts?.vprefix ?? false, 99 | version: `${major}.${minor}.${patch}`, 100 | } 101 | } 102 | 103 | /** 104 | * Is the given version a PR one? 105 | */ 106 | export function isPullRequest(v: Ver): v is PullRequestVer { 107 | return typeof (v as PullRequestVer)?.preRelease?.prNum === `number` 108 | } 109 | 110 | /** 111 | * Is the given version a preview one? 112 | */ 113 | export function isPreview(v: Ver): v is PreviewVer { 114 | return (v as any).preRelease !== undefined 115 | } 116 | 117 | /** 118 | * Is the given version a stable one? 119 | */ 120 | export function isStable(v: Ver): v is StableVer { 121 | return !isPreview(v) && !isPullRequest(v) 122 | } 123 | 124 | /** 125 | * Parse a version that you believe should be a preview variant. If not, an error is thrown. 126 | */ 127 | export function parsePreview(ver: string): null | PreviewVer { 128 | const result = parse(ver) 129 | if (result === null) return null 130 | if ((result as any).preRelease) { 131 | return result as PreviewVer 132 | } 133 | throw new Error(`Given version string ${ver} could not be parsed as a preview.`) 134 | } 135 | 136 | /** 137 | * Parse a version string into structured data. 138 | */ 139 | export function parse(ver: string): null | StableVer | PreviewVer { 140 | const result = ver.match(/^(v)?(\d+).(\d+).(\d+)$|^(v)?(\d+).(\d+).(\d+)-(\w+).(\d+)$/) 141 | 142 | if (result === null) return null 143 | 144 | if (result[6]) { 145 | const vprefix = result[5] === `v` 146 | const major = parseInt(result[6]!, 10) 147 | const minor = parseInt(result[7]!, 10) 148 | const patch = parseInt(result[8]!, 10) 149 | const identifier = result[9]! 150 | const buildNum = parseInt(result[10]!, 10) 151 | return { 152 | version: `${major}.${minor}.${patch}-${identifier}.${buildNum}`, 153 | vprefix, 154 | major, 155 | minor, 156 | patch, 157 | preRelease: { 158 | identifier, 159 | buildNum, 160 | }, 161 | } 162 | } 163 | 164 | const vprefix = result[1] === `v` 165 | const major = parseInt(result[2]!, 10) 166 | const minor = parseInt(result[3]!, 10) 167 | const patch = parseInt(result[4]!, 10) 168 | return { 169 | version: `${major}.${minor}.${patch}`, 170 | vprefix, 171 | major, 172 | minor, 173 | patch, 174 | } 175 | } 176 | 177 | export function setBuildNum(ver: PreviewVer, buildNum: number): PreviewVer { 178 | return { 179 | ...ver, 180 | preRelease: { 181 | ...ver.preRelease, 182 | buildNum, 183 | }, 184 | version: `${ver.major}.${ver.minor}.${ver.patch}-${ver.preRelease.identifier}.${buildNum}`, 185 | } 186 | } 187 | 188 | export const zeroVer = createStable(0, 0, 0) 189 | 190 | export const zeroBuildNum = 0 191 | 192 | /** 193 | * Render the given version. This will result in a valid semver value, thus, 194 | * without the vprefix (if enabled at all). If you want the vprefix see `renderStyledVersion`. 195 | */ 196 | export function renderVersion(v: Ver): string { 197 | if (isPullRequest(v)) { 198 | return `${v.major}.${v.minor}.${v.patch}-${v.preRelease.identifier}.${v.preRelease.prNum}.${v.preRelease.shortSha}` 199 | } else if (isPreview(v)) { 200 | return `${v.major}.${v.minor}.${v.patch}-${v.preRelease.identifier}.${v.preRelease.buildNum}` 201 | } else if (isStable(v)) { 202 | return `${v.major}.${v.minor}.${v.patch}` 203 | } else { 204 | throw new Error(`should never happen`) 205 | } 206 | } 207 | 208 | /** 209 | * Render the given version including the vprefix if enabled. Do not use this in 210 | * places where a valid semver is expected since vprefix is not valid semver. 211 | */ 212 | export function renderStyledVersion(v: Ver): string { 213 | const vprefix = v.vprefix ? `v` : `` 214 | return `${vprefix}${renderVersion(v)}` 215 | } 216 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | export const errorFromMaybeError = (e: unknown) => { 4 | if (e instanceof Error) { 5 | return e 6 | } 7 | return Error(String(e)) 8 | } 9 | 10 | export function dump(arg: unknown): void { 11 | console.error(inspect(arg, { depth: 30 })) 12 | } 13 | /** 14 | * Indent a string that spans multiple lines. 15 | */ 16 | export function indentBlock(size: number, block: string): string { 17 | return block 18 | .split(`\n`) 19 | .map((line) => range(size).map(constant(` `)).join(``) + line) 20 | .join(`\n`) 21 | } 22 | 23 | export const indentBlock4 = indentBlock.bind(null, 4) 24 | 25 | /** 26 | * Create a function that will only ever return the given value when called. 27 | */ 28 | export const constant = (x: T): (() => T) => { 29 | return function () { 30 | return x 31 | } 32 | } 33 | 34 | /** 35 | * Create a range of integers. 36 | */ 37 | const range = (times: number): number[] => { 38 | const list: number[] = [] 39 | while (list.length < times) { 40 | list.push(list.length + 1) 41 | } 42 | return list 43 | } 44 | 45 | // type IndexableKeyTypes = string | number | symbol 46 | 47 | // type Indexable = Record 48 | 49 | // type JustIndexableTypes = T extends IndexableKeyTypes ? T : never 50 | 51 | // type KeysMatching = NonNullable< 52 | // { 53 | // [RecKey in keyof Rec]: Rec[RecKey] extends Keys ? RecKey : never 54 | // }[keyof Rec] 55 | // > 56 | 57 | // export type GroupBy> = { 58 | // [KV in JustIndexableTypes]?: Array ? T : never> 59 | // } 60 | 61 | // type IndexableKeys = KeysMatching 62 | 63 | // export function groupByProp< 64 | // Obj extends Indexable, 65 | // KeyName extends IndexableKeys 66 | // >(xs: Obj[], keyName: KeyName): GroupBy { 67 | // type KeyValue = JustIndexableTypes 68 | // const seed = {} as GroupBy 69 | 70 | // return xs.reduce((groupings, x) => { 71 | // const groupName = x[keyName] as KeyValue 72 | 73 | // if (groupings[groupName] === undefined) { 74 | // groupings[groupName] = [] 75 | // } 76 | 77 | // // We know the group will exist, given above initializer. 78 | // groupings[groupName]!.push( 79 | // x as Obj extends Record ? Obj : never 80 | // ) 81 | 82 | // return groupings 83 | // }, seed) 84 | // } 85 | 86 | /** 87 | * Use this to make assertion at end of if-else chain that all members of a 88 | * union have been accounted for. 89 | */ 90 | export function casesHandled(x: never): never { 91 | throw new Error(`A case was not handled for value: ${x}`) 92 | } 93 | 94 | /** 95 | * Determine if the given array or object is empty. 96 | */ 97 | export function isEmpty(x: {} | unknown[]): boolean { 98 | return Array.isArray(x) ? x.length === 0 : Object.keys(x).length > 0 99 | } 100 | 101 | /** 102 | * Pause in time for given milliseconds. 103 | */ 104 | export function delay(milliseconds: number): Promise { 105 | return new Promise((resolve) => { 106 | setTimeout(resolve, 1000) 107 | }) 108 | } 109 | 110 | /** 111 | * Like Array.findIndex but working backwards from end of array. 112 | */ 113 | export function findIndexFromEnd(xs: T[], f: (x: T) => boolean): number { 114 | for (let index = xs.length - 1; index > -1; index--) { 115 | if (f(xs[index]!)) return index 116 | } 117 | return -1 118 | } 119 | 120 | /** 121 | * Get the last element of an array. 122 | */ 123 | export function last(xs: T[]): null | T { 124 | if (xs.length === 0) return null 125 | return xs[xs.length - 1]! 126 | } 127 | 128 | export function numericAscending(n1: number, n2: number): -1 | 0 | 1 { 129 | if (n1 < n2) return -1 130 | if (n1 > n2) return 1 131 | return 0 132 | } 133 | -------------------------------------------------------------------------------- /src/sdk/pr.spec.ts: -------------------------------------------------------------------------------- 1 | import { fixture } from '../../tests/__providers__/fixture' 2 | import { git } from '../../tests/__providers__/git' 3 | import { runPullRequestRelease } from './pr' 4 | import { konn, providers } from 'konn' 5 | 6 | const ctx = konn() 7 | .useBeforeAll(providers.dir()) 8 | .useBeforeAll(git()) 9 | .beforeAll((ctx) => { 10 | return { 11 | runPullRequestRelease: () => { 12 | return runPullRequestRelease({ 13 | cwd: ctx.fs.cwd(), 14 | json: true, 15 | dryRun: true, 16 | progress: false, 17 | readFromCIEnvironment: false, 18 | }) 19 | }, 20 | } 21 | }) 22 | .useBeforeEach(fixture({ use: `git-repo-dripip-system-tests`, into: `.git` })) 23 | .done() 24 | 25 | it(`preflight check that user is on branch with open pr`, async () => { 26 | await ctx.git.checkout({ ref: `no-open-pr` }) 27 | const msg = await ctx.runPullRequestRelease() 28 | expect(msg).toMatchInlineSnapshot(` 29 | Object { 30 | "data": Object { 31 | "release": null, 32 | "report": Object { 33 | "errors": Array [ 34 | Object { 35 | "code": "pr_release_without_open_pr", 36 | "details": Object {}, 37 | "summary": "Pull-Request releases are only supported on branches with _open_ pull-requests", 38 | }, 39 | ], 40 | "passes": Array [ 41 | Object { 42 | "code": "npm_auth_not_setup", 43 | "details": Object {}, 44 | "summary": "You must have npm auth setup to publish to the registry", 45 | }, 46 | ], 47 | "stops": Array [], 48 | }, 49 | }, 50 | "kind": "ok", 51 | "type": "dry_run", 52 | } 53 | `) 54 | }) 55 | 56 | it(`makes a release for the current commit, updating pr dist tag, and version format`, async () => { 57 | await ctx.git.checkout({ ref: `open-pr` }) 58 | const msg = await ctx.runPullRequestRelease() 59 | expect(msg).toMatchInlineSnapshot(` 60 | Object { 61 | "data": Object { 62 | "publishPlan": Object { 63 | "options": Object { 64 | "gitTag": "none", 65 | }, 66 | "release": Object { 67 | "distTag": "pr.162", 68 | "version": "0.0.0-pr.162.1.1deb48e", 69 | }, 70 | }, 71 | "report": Object { 72 | "errors": Array [], 73 | "passes": Array [ 74 | Object { 75 | "code": "npm_auth_not_setup", 76 | "details": Object {}, 77 | "summary": "You must have npm auth setup to publish to the registry", 78 | }, 79 | Object { 80 | "code": "pr_release_without_open_pr", 81 | "details": Object {}, 82 | "summary": "Pull-Request releases are only supported on branches with _open_ pull-requests", 83 | }, 84 | ], 85 | "stops": Array [], 86 | }, 87 | }, 88 | "kind": "ok", 89 | "type": "dry_run", 90 | } 91 | `) 92 | }) 93 | -------------------------------------------------------------------------------- /src/sdk/pr.ts: -------------------------------------------------------------------------------- 1 | import { setupNPMAuthfileOnCI } from '../lib/npm-auth' 2 | import { publishPackage, PublishPlan } from '../lib/publish-package' 3 | import { PullRequestVer } from '../lib/semver' 4 | import { getContext } from '../utils/context' 5 | import { npmAuthSetup } from '../utils/context-checkers' 6 | import { check, guard, Validator } from '../utils/context-guard' 7 | import { createDidNotPublish, createDidPublish, createDryRun } from '../utils/output' 8 | import { getNextPreReleaseBuildNum } from '../utils/pr-release' 9 | 10 | interface Options { 11 | dryRun: boolean 12 | progress: boolean 13 | json: boolean 14 | cwd?: string 15 | readFromCIEnvironment?: boolean 16 | } 17 | 18 | export const runPullRequestRelease = async (options: Options) => { 19 | const cwd = options.cwd ?? process.cwd() 20 | const readFromCIEnvironment = options.readFromCIEnvironment ?? true 21 | const context = await getContext({ cwd, readFromCIEnvironment }) 22 | 23 | const report = check({ context }) 24 | .errorUnless(npmAuthSetup()) 25 | .errorUnless(branchHasOpenPR()) 26 | // todo only we if can figure the commits since last pr release 27 | // .must(haveMeaningfulCommitsInTheSeries()) 28 | .run() 29 | 30 | // if we have preflight errors then we won't be able to calculate the 31 | // release info. But if doing a dry-run we want to be consistent with 32 | // other commands that embed report into returned data rather than 33 | // throwing an error. 34 | if (report.errors.length && options.dryRun) { 35 | return createDryRun({ 36 | report: report, 37 | release: null, // cannot compute without PR info 38 | }) 39 | } 40 | 41 | if (report.errors.length) { 42 | guard({ context, report, json: options.json }) 43 | } 44 | 45 | if (report.stops.length) { 46 | return createDidNotPublish({ reasons: report.stops }) 47 | } 48 | 49 | const versionPrefix = `0.0.0-pr.${context.currentBranch.pr!.number}.` 50 | const versionBuildNum = getNextPreReleaseBuildNum(context.package.name, versionPrefix) 51 | const version = `${versionPrefix}${versionBuildNum}.${context.series.current.sha.slice(0, 7)}` 52 | 53 | const versionInfo = { 54 | major: 0, 55 | minor: 0, 56 | patch: 0, 57 | // prettier-ignore 58 | version: version, 59 | vprefix: false, 60 | preRelease: { 61 | identifier: `pr`, 62 | prNum: context.currentBranch.pr!.number, 63 | shortSha: context.series.current.sha.slice(0, 7), 64 | }, 65 | } as PullRequestVer 66 | 67 | // todo show publish plan in dryrun for other commands too 68 | const publishPlan: PublishPlan = { 69 | release: { 70 | distTag: `pr.${context.currentBranch.pr!.number}`, 71 | version: versionInfo.version, 72 | }, 73 | options: { 74 | gitTag: `none`, 75 | }, 76 | } 77 | 78 | if (options.dryRun) { 79 | return createDryRun({ 80 | report: report, 81 | publishPlan: publishPlan, 82 | }) 83 | } 84 | 85 | setupNPMAuthfileOnCI() 86 | 87 | for await (const progress of publishPackage(publishPlan)) { 88 | // turn this func into async iterator and yield structured progress 89 | if (options.progress) { 90 | console.log(progress) 91 | } 92 | } 93 | 94 | return createDidPublish({ release: publishPlan.release }) 95 | } 96 | 97 | // 98 | // Validators 99 | // 100 | 101 | function branchHasOpenPR(): Validator { 102 | return { 103 | code: `pr_release_without_open_pr`, 104 | summary: `Pull-Request releases are only supported on branches with _open_ pull-requests`, 105 | run(ctx) { 106 | return ctx.currentBranch.pr !== null 107 | }, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/sdk/preview.spec.ts: -------------------------------------------------------------------------------- 1 | import { fixture } from '../../tests/__providers__/fixture' 2 | import { git } from '../../tests/__providers__/git' 3 | import { Options, runPreviewRelease } from './preview' 4 | import { konn, providers } from 'konn' 5 | 6 | const ctx = konn() 7 | .useBeforeAll(providers.dir()) 8 | .useBeforeAll(git()) 9 | .beforeAll((ctx) => { 10 | return { 11 | runPullRequestRelease: (opts?: Partial) => { 12 | return runPreviewRelease({ 13 | cwd: ctx.fs.cwd(), 14 | json: true, 15 | dryRun: true, 16 | progress: false, 17 | changelog: true, 18 | readFromCIEnvironment: false, 19 | ...opts, 20 | }).then((result) => { 21 | if (result.data?.changelog) { 22 | // eslint-disable-next-line 23 | result.data.changelog = result.data.changelog.replace(/- [a-z0-9]{7}/, `__sha__`) 24 | } 25 | return result 26 | }) 27 | }, 28 | } 29 | }) 30 | .useBeforeEach(fixture({ use: `git-init`, into: `.git` })) 31 | .done() 32 | 33 | it(`if build-num flag passed, the build number is forced to be it`, async () => { 34 | await ctx.git.commit(`fix: foo`) 35 | await ctx.git.tag(`0.1.0`) 36 | await ctx.git.commit(`feat: foo`) 37 | const result = await ctx.runPullRequestRelease({ overrides: { buildNum: 2 } }) 38 | expect(result).toMatchInlineSnapshot(` 39 | Object { 40 | "data": Object { 41 | "changelog": "#### Features 42 | 43 | __sha__ foo 44 | ", 45 | "commits": Array [ 46 | Object { 47 | "parsed": Object { 48 | "body": null, 49 | "breakingChange": null, 50 | "completesInitialDevelopment": false, 51 | "description": "foo", 52 | "footers": Array [], 53 | "scope": null, 54 | "type": "feat", 55 | "typeKind": "feat", 56 | }, 57 | "raw": "feat: foo", 58 | }, 59 | ], 60 | "release": Object { 61 | "bumpType": "minor", 62 | "version": Object { 63 | "major": 0, 64 | "minor": 2, 65 | "patch": 0, 66 | "preRelease": Object { 67 | "buildNum": 2, 68 | "identifier": "next", 69 | }, 70 | "version": "0.2.0-next.2", 71 | "vprefix": false, 72 | }, 73 | }, 74 | "report": Object { 75 | "errors": Array [], 76 | "passes": Array [ 77 | Object { 78 | "code": "npm_auth_not_setup", 79 | "details": Object {}, 80 | "summary": "You must have npm auth setup to publish to the registry", 81 | }, 82 | Object { 83 | "code": "must_be_on_trunk", 84 | "details": Object {}, 85 | "summary": "You must be on the trunk branch", 86 | }, 87 | Object { 88 | "code": "preview_on_commit_with_preview_and_or_stable", 89 | "details": Object {}, 90 | "summary": "A preview release requires the commit to have no existing stable or preview release.", 91 | }, 92 | Object { 93 | "code": "series_empty", 94 | "details": Object {}, 95 | "summary": "A preview release must have at least one commit since the last preview", 96 | }, 97 | Object { 98 | "code": "series_only_has_meaningless_commits", 99 | "details": Object {}, 100 | "summary": "A preview release must have at least one semantic commit", 101 | }, 102 | ], 103 | "stops": Array [], 104 | }, 105 | }, 106 | "kind": "ok", 107 | "type": "dry_run", 108 | } 109 | `) 110 | }) 111 | 112 | describe(`preflight checks`, () => { 113 | it(`no preview release already present`, async () => { 114 | await ctx.git.commit(`fix: thing`) 115 | await ctx.git.tag(`v1.2.3-next.1`) 116 | const result = await ctx.runPullRequestRelease() 117 | expect(result.data.report).toMatchInlineSnapshot(` 118 | Object { 119 | "errors": Array [ 120 | Object { 121 | "code": "preview_on_commit_with_preview_and_or_stable", 122 | "details": Object { 123 | "subCode": "preview", 124 | }, 125 | "summary": "A preview release requires the commit to have no existing stable or preview release.", 126 | }, 127 | ], 128 | "passes": Array [ 129 | Object { 130 | "code": "npm_auth_not_setup", 131 | "details": Object {}, 132 | "summary": "You must have npm auth setup to publish to the registry", 133 | }, 134 | Object { 135 | "code": "must_be_on_trunk", 136 | "details": Object {}, 137 | "summary": "You must be on the trunk branch", 138 | }, 139 | Object { 140 | "code": "series_empty", 141 | "details": Object {}, 142 | "summary": "A preview release must have at least one commit since the last preview", 143 | }, 144 | ], 145 | "stops": Array [ 146 | Object { 147 | "code": "series_only_has_meaningless_commits", 148 | "details": Object {}, 149 | "summary": "A preview release must have at least one semantic commit", 150 | }, 151 | ], 152 | } 153 | `) 154 | }) 155 | 156 | it(`no stable release already present`, async () => { 157 | await ctx.git.commit(`fix: thing`) 158 | await ctx.git.tag(`v1.2.3`) 159 | const result = await ctx.runPullRequestRelease() 160 | expect(result.data.report).toMatchInlineSnapshot(` 161 | Object { 162 | "errors": Array [ 163 | Object { 164 | "code": "preview_on_commit_with_preview_and_or_stable", 165 | "details": Object { 166 | "subCode": "stable", 167 | }, 168 | "summary": "A preview release requires the commit to have no existing stable or preview release.", 169 | }, 170 | ], 171 | "passes": Array [ 172 | Object { 173 | "code": "npm_auth_not_setup", 174 | "details": Object {}, 175 | "summary": "You must have npm auth setup to publish to the registry", 176 | }, 177 | Object { 178 | "code": "must_be_on_trunk", 179 | "details": Object {}, 180 | "summary": "You must be on the trunk branch", 181 | }, 182 | Object { 183 | "code": "series_only_has_meaningless_commits", 184 | "details": Object {}, 185 | "summary": "A preview release must have at least one semantic commit", 186 | }, 187 | ], 188 | "stops": Array [ 189 | Object { 190 | "code": "series_empty", 191 | "details": Object {}, 192 | "summary": "A preview release must have at least one commit since the last preview", 193 | }, 194 | ], 195 | } 196 | `) 197 | }) 198 | 199 | it(`no stable AND preview release already present (shows graceful aggregate reporting of the cases)`, async () => { 200 | await ctx.git.commit(`feat: initial commit`) 201 | await ctx.git.tag(`v1.2.3`) 202 | await ctx.git.tag(`v1.2.3-next.1`) 203 | const result = await ctx.runPullRequestRelease() 204 | expect(result.data.report).toMatchInlineSnapshot(` 205 | Object { 206 | "errors": Array [ 207 | Object { 208 | "code": "preview_on_commit_with_preview_and_or_stable", 209 | "details": Object { 210 | "subCode": "preview_and_stable", 211 | }, 212 | "summary": "A preview release requires the commit to have no existing stable or preview release.", 213 | }, 214 | ], 215 | "passes": Array [ 216 | Object { 217 | "code": "npm_auth_not_setup", 218 | "details": Object {}, 219 | "summary": "You must have npm auth setup to publish to the registry", 220 | }, 221 | Object { 222 | "code": "must_be_on_trunk", 223 | "details": Object {}, 224 | "summary": "You must be on the trunk branch", 225 | }, 226 | Object { 227 | "code": "series_only_has_meaningless_commits", 228 | "details": Object {}, 229 | "summary": "A preview release must have at least one semantic commit", 230 | }, 231 | ], 232 | "stops": Array [ 233 | Object { 234 | "code": "series_empty", 235 | "details": Object {}, 236 | "summary": "A preview release must have at least one commit since the last preview", 237 | }, 238 | ], 239 | } 240 | `) 241 | }) 242 | 243 | // TODO maybe... this is quite the edge-case and would charge all users a 244 | // latency fee wherein every stable preview release requires a pr check 245 | // anyway just to see if this super weird case is occurring... 246 | it.todo(`fails semantically if trunk and pr detected because that demands conflicting reactions`) 247 | }) 248 | -------------------------------------------------------------------------------- /src/sdk/preview.ts: -------------------------------------------------------------------------------- 1 | import * as Changelog from '../lib/changelog' 2 | import { setupNPMAuthfileOnCI } from '../lib/npm-auth' 3 | import { publishChangelog } from '../lib/publish-changelog' 4 | import { publishPackage, PublishPlan } from '../lib/publish-package' 5 | import { PreviewVer, setBuildNum } from '../lib/semver' 6 | import { getContext } from '../utils/context' 7 | import { isTrunk, npmAuthSetup } from '../utils/context-checkers' 8 | import { check, guard, Validator } from '../utils/context-guard' 9 | import { octokit } from '../utils/octokit' 10 | import { createDidNotPublish, createDidPublish, createDryRun } from '../utils/output' 11 | import { getNextPreview, isNoReleaseReason, Release } from '../utils/release' 12 | 13 | export interface Options { 14 | cwd?: string 15 | dryRun: boolean 16 | json: boolean 17 | progress: boolean 18 | changelog: boolean 19 | overrides?: { 20 | skipNpm?: boolean 21 | buildNum?: number 22 | trunk?: string 23 | } 24 | readFromCIEnvironment?: boolean 25 | } 26 | 27 | export const runPreviewRelease = async (options: Options) => { 28 | const cwd = options.cwd ?? process.cwd() 29 | const readFromCIEnvironment = options.readFromCIEnvironment ?? true 30 | const context = await getContext({ 31 | cwd, 32 | readFromCIEnvironment, 33 | overrides: { 34 | trunk: options.overrides?.trunk ?? null, 35 | }, 36 | }) 37 | 38 | // TODO validate for missing or faulty package.json 39 | // TODO validate for dirty git status 40 | // TODO validate for found releases that fall outside the subset we support. 41 | // For example #.#.#-foobar.1 is something we would not know what to do 42 | // with. A good default is probably to hard-error when these are 43 | // encountered. But offer a flag/config option called e.g. "ignore_unsupport_pre_release_identifiers" 44 | // TODO handle edge case: not a git repo 45 | // TODO handle edge case: a git repo with no commits 46 | // TODO nicer tag rendering: 47 | // 1. for annotated tags should the message 48 | // 2. show the tag author name 49 | // 3. show the the date the tag was made 50 | 51 | const report = check({ context }) 52 | .errorUnless(npmAuthSetup()) 53 | .errorUnless(isTrunk()) 54 | .errorUnless(notAlreadyStableOrPreviewReleased()) 55 | .stopUnless(haveCommitsInTheSeries()) 56 | .stopUnless(haveMeaningfulCommitsInTheSeries()) 57 | .run() 58 | 59 | const maybeRelease = getNextPreview(context.series) 60 | 61 | if (options.overrides?.buildNum !== undefined && !isNoReleaseReason(maybeRelease)) { 62 | maybeRelease.version = setBuildNum(maybeRelease.version as PreviewVer, options.overrides.buildNum) 63 | } 64 | 65 | const changelog = Changelog.renderFromSeries(context.series, { as: `markdown` }) 66 | 67 | if (options.dryRun) { 68 | return createDryRun({ 69 | report: report, 70 | release: maybeRelease, 71 | commits: context.series.commitsInNextPreview.map((c) => c.message), 72 | changelog: changelog, 73 | }) 74 | } 75 | 76 | if (report.errors.length) { 77 | guard({ context: context, report, json: options.json }) 78 | } 79 | 80 | if (report.stops.length) { 81 | return createDidNotPublish({ reasons: report.stops }) 82 | } 83 | 84 | const release = maybeRelease as Release // now validated 85 | 86 | const publishPlan: PublishPlan = { 87 | release: { 88 | distTag: `next`, 89 | version: release.version.version, 90 | }, 91 | options: { 92 | npm: options.overrides?.skipNpm !== true, 93 | }, 94 | } 95 | 96 | setupNPMAuthfileOnCI() 97 | 98 | for await (const progress of publishPackage(publishPlan)) { 99 | if (options.progress) { 100 | console.log(progress) 101 | } 102 | } 103 | 104 | const releaseInfo = { 105 | ...release, 106 | head: { 107 | sha: context.series.current.sha, 108 | }, 109 | } 110 | 111 | if (options.changelog && !options.dryRun) { 112 | await publishChangelog({ 113 | octokit: octokit, 114 | release: releaseInfo, 115 | repo: context.githubRepo, 116 | body: changelog, 117 | }) 118 | } 119 | 120 | return createDidPublish({ release: { notes: changelog, ...publishPlan.release } }) 121 | } 122 | 123 | // 124 | // Validators 125 | // 126 | 127 | function haveCommitsInTheSeries(): Validator { 128 | return { 129 | code: `series_empty`, 130 | summary: `A preview release must have at least one commit since the last preview`, 131 | run(ctx) { 132 | const release = getNextPreview(ctx.series) 133 | return release !== `empty_series` 134 | }, 135 | } 136 | } 137 | 138 | function haveMeaningfulCommitsInTheSeries(): Validator { 139 | return { 140 | code: `series_only_has_meaningless_commits`, 141 | summary: `A preview release must have at least one semantic commit`, 142 | run(ctx) { 143 | const release = getNextPreview(ctx.series) 144 | return release !== `no_meaningful_change` 145 | // todo 146 | // hint: // 'All commits are either meta or not conforming to conventional commit. No release will be made.', 147 | }, 148 | } 149 | } 150 | 151 | function notAlreadyStableOrPreviewReleased(): Validator { 152 | return { 153 | code: `preview_on_commit_with_preview_and_or_stable`, 154 | summary: `A preview release requires the commit to have no existing stable or preview release.`, 155 | run(ctx) { 156 | if (ctx.series.current.releases.stable && ctx.series.current.releases.preview) { 157 | return { kind: `fail`, details: { subCode: `preview_and_stable` } } 158 | } 159 | 160 | if (ctx.series.current.releases.stable) { 161 | return { kind: `fail`, details: { subCode: `stable` } } 162 | } 163 | 164 | if (ctx.series.current.releases.preview) { 165 | return { kind: `fail`, details: { subCode: `preview` } } 166 | } 167 | 168 | return true 169 | }, 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/sdk/stable.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO test that context honors the base branch setting of the repo 2 | 3 | // async function setupPackageJson() { 4 | // await ctx.fs.writeAsync('package.json', { 5 | // name: 'foo', 6 | // version: '0.0.0-ignoreme', 7 | // }) 8 | // } 9 | 10 | import { fixture } from '../../tests/__providers__/fixture' 11 | import { git } from '../../tests/__providers__/git' 12 | import { Options, runStableRelease } from './stable' 13 | import { konn, providers } from 'konn' 14 | 15 | const ctx = konn() 16 | .useBeforeAll(providers.dir()) 17 | .useBeforeAll(git()) 18 | .beforeAll((ctx) => { 19 | return { 20 | runStableRelease: (opts?: Partial) => { 21 | return runStableRelease({ 22 | cwd: ctx.fs.cwd(), 23 | json: true, 24 | dryRun: true, 25 | progress: false, 26 | changelog: true, 27 | ...opts, 28 | readFromCIEnvironment: false, 29 | }) 30 | }, 31 | } 32 | }) 33 | .useBeforeEach(fixture({ use: `git-repo-dripip-system-tests`, into: `.git` })) 34 | .done() 35 | 36 | describe(`preflight requirements include that`, () => { 37 | it(`the branch is trunk`, async () => { 38 | await ctx.git.commit(`feat: foo`) 39 | await ctx.git.branch({ ref: `foo` }) 40 | const result = await ctx.runStableRelease() 41 | // todo doesn't make sense to show both of these errors at once, 42 | // only check for sync once on-trunk established 43 | expect(result.data.report.errors).toMatchInlineSnapshot(` 44 | Array [ 45 | Object { 46 | "code": "must_be_on_trunk", 47 | "details": Object {}, 48 | "summary": "You must be on the trunk branch", 49 | }, 50 | Object { 51 | "code": "branch_not_synced_with_remote", 52 | "details": Object { 53 | "syncStatus": "remote_needs_branch", 54 | }, 55 | "summary": "Your branch must be synced with the remote", 56 | }, 57 | ] 58 | `) 59 | }) 60 | 61 | // TODO need a flag like --queued-releases which permits releasing on 62 | // potentially not the latest commit of trunk. Think of a CI situation with 63 | // race-condition PR merges. 64 | it(`the branch is synced with remote (needs push)`, async () => { 65 | await ctx.git.commit(`some work`) 66 | const result = await ctx.runStableRelease() 67 | expect(result.data.report.errors).toMatchInlineSnapshot(` 68 | Array [ 69 | Object { 70 | "code": "branch_not_synced_with_remote", 71 | "details": Object { 72 | "syncStatus": "not_synced", 73 | }, 74 | "summary": "Your branch must be synced with the remote", 75 | }, 76 | ] 77 | `) 78 | }) 79 | 80 | it(`the branch is synced with remote (needs pull)`, async () => { 81 | await ctx.git.hardReset({ ref: `head~1`, branch: `main` }) 82 | const result = await ctx.runStableRelease() 83 | expect(result.data.report.errors).toMatchInlineSnapshot(` 84 | Array [ 85 | Object { 86 | "code": "branch_not_synced_with_remote", 87 | "details": Object { 88 | "syncStatus": "not_synced", 89 | }, 90 | "summary": "Your branch must be synced with the remote", 91 | }, 92 | ] 93 | `) 94 | }) 95 | 96 | it(`the branch is synced with remote (diverged)`, async () => { 97 | await ctx.git.hardReset({ ref: `head~1`, branch: `main` }) 98 | await ctx.git.commit(`foo`) 99 | const result = await ctx.runStableRelease() 100 | expect(result.data.report.errors).toMatchInlineSnapshot(` 101 | Array [ 102 | Object { 103 | "code": "branch_not_synced_with_remote", 104 | "details": Object { 105 | "syncStatus": "not_synced", 106 | }, 107 | "summary": "Your branch must be synced with the remote", 108 | }, 109 | ] 110 | `) 111 | }) 112 | 113 | it(`check that the commit does not already have a stable release present`, async () => { 114 | await ctx.git.commit(`fix: 1`) 115 | await ctx.git.tag(`1.0.0`) 116 | const result = await ctx.runStableRelease() 117 | expect(result.data.report.stops).toMatchInlineSnapshot(` 118 | Array [ 119 | Object { 120 | "code": "commit_already_has_stable_release", 121 | "details": Object {}, 122 | "summary": "A stable release requires the commit to have no existing stable release", 123 | }, 124 | ] 125 | `) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /src/sdk/stable.ts: -------------------------------------------------------------------------------- 1 | import * as Changelog from '../lib/changelog' 2 | import { setupNPMAuthfileOnCI } from '../lib/npm-auth' 3 | import { publishChangelog } from '../lib/publish-changelog' 4 | import { publishPackage, PublishPlan } from '../lib/publish-package' 5 | import { getContext } from '../utils/context' 6 | import { branchSynced, isTrunk, npmAuthSetup } from '../utils/context-checkers' 7 | import { check, guard, Validator } from '../utils/context-guard' 8 | import { octokit } from '../utils/octokit' 9 | import { createDidNotPublish, createDidPublish, createDryRun } from '../utils/output' 10 | import { getNextStable, Release } from '../utils/release' 11 | 12 | const haveMeaningfulCommitsInTheSeries = (): Validator => { 13 | return { 14 | code: `series_only_has_meaningless_commits`, 15 | summary: `A stable release must have at least one semantic commit`, 16 | run(ctx) { 17 | const release = getNextStable(ctx.series) 18 | return release !== `no_meaningful_change` 19 | // todo 20 | // hint: // 'All commits are either meta or not conforming to conventional commit. No release will be made.', 21 | }, 22 | } 23 | } 24 | 25 | const notAlreadyStableRelease = (): Validator => { 26 | return { 27 | code: `commit_already_has_stable_release`, 28 | summary: `A stable release requires the commit to have no existing stable release`, 29 | run(ctx) { 30 | return ctx.series.current.releases.stable === null 31 | }, 32 | } 33 | } 34 | 35 | export interface Options { 36 | cwd?: string 37 | dryRun: boolean 38 | json: boolean 39 | progress: boolean 40 | changelog: boolean 41 | overrides?: { 42 | skipNpm?: boolean 43 | buildNum?: number 44 | trunk?: string 45 | } 46 | readFromCIEnvironment?: boolean 47 | } 48 | 49 | export const runStableRelease = async (options: Options) => { 50 | const cwd = options.cwd ?? process.cwd() 51 | const readFromCIEnvironment = options.readFromCIEnvironment ?? true 52 | const context = await getContext({ 53 | cwd, 54 | readFromCIEnvironment, 55 | overrides: { 56 | trunk: options.overrides?.trunk ?? null, 57 | }, 58 | }) 59 | 60 | const report = check({ context }) 61 | .errorUnless(npmAuthSetup()) 62 | .errorUnless(isTrunk()) 63 | .errorUnless(branchSynced()) 64 | .stopUnless(notAlreadyStableRelease()) 65 | .stopUnless(haveMeaningfulCommitsInTheSeries()) 66 | .run() 67 | 68 | const maybeRelease = getNextStable(context.series) 69 | const changelog = Changelog.renderFromSeries(context.series, { as: `markdown` }) 70 | 71 | if (options.dryRun) { 72 | return createDryRun({ 73 | report, 74 | release: maybeRelease, 75 | commits: context.series.commitsInNextStable, 76 | changelog: changelog, 77 | }) 78 | } 79 | 80 | if (report.errors.length) { 81 | guard({ context, report, json: options.json }) 82 | } 83 | 84 | if (report.stops.length) { 85 | return createDidNotPublish({ reasons: report.stops }) 86 | } 87 | 88 | const release = maybeRelease as Release // now validated 89 | 90 | const publishPlan: PublishPlan = { 91 | release: { 92 | version: release.version.version, 93 | distTag: `latest`, 94 | extraDistTags: [`next`], 95 | }, 96 | options: { 97 | npm: options.overrides?.skipNpm !== true, 98 | }, 99 | } 100 | 101 | setupNPMAuthfileOnCI() 102 | 103 | for await (const progress of publishPackage(publishPlan)) { 104 | if (!options.json) { 105 | console.log(progress) 106 | } 107 | } 108 | 109 | const releaseInfo = { 110 | ...release, 111 | head: { 112 | sha: context.series.current.sha, 113 | }, 114 | } 115 | 116 | if (options.changelog && !options.dryRun) { 117 | await publishChangelog({ 118 | octokit: octokit, 119 | release: releaseInfo, 120 | repo: context.githubRepo, 121 | body: changelog, 122 | }) 123 | } 124 | 125 | return createDidPublish({ release: { notes: changelog, ...publishPlan.release } }) 126 | } 127 | -------------------------------------------------------------------------------- /src/utils/context-checkers.ts: -------------------------------------------------------------------------------- 1 | import { validateNPMAuthSetup } from '../lib/npm-auth' 2 | import { Validator } from './context-guard' 3 | 4 | export const isTrunk = (): Validator => { 5 | return { 6 | code: `must_be_on_trunk`, 7 | summary: `You must be on the trunk branch`, 8 | run(ctx) { 9 | return ctx.currentBranch.isTrunk 10 | }, 11 | } 12 | } 13 | 14 | export const branchSynced = (): Validator => { 15 | return { 16 | code: `branch_not_synced_with_remote`, 17 | summary: `Your branch must be synced with the remote`, 18 | run(ctx) { 19 | if (ctx.currentBranch.syncStatus === `synced`) { 20 | return true 21 | } else { 22 | return { 23 | kind: `fail`, 24 | details: { 25 | syncStatus: ctx.currentBranch.syncStatus, 26 | }, 27 | } 28 | } 29 | }, 30 | } 31 | } 32 | 33 | export const npmAuthSetup = (): Validator => { 34 | return { 35 | code: `npm_auth_not_setup`, 36 | summary: `You must have npm auth setup to publish to the registry`, 37 | run() { 38 | const result = validateNPMAuthSetup() 39 | 40 | if (result.kind === `pass`) { 41 | return true 42 | } else { 43 | return { kind: `fail`, details: { reasons: result.reasons } } 44 | } 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/context-guard.ts: -------------------------------------------------------------------------------- 1 | import * as Context from './context' 2 | 3 | type Options = { 4 | context: Context.Context 5 | } 6 | 7 | /** 8 | * 9 | * @example 10 | * 11 | * ```ts 12 | * guard(ctx) 13 | * .must(isTrunk()) 14 | * .check(flags.upToDate ? 'must' : 'prefer', upToDate()) 15 | * 16 | * if (flags.npmPublish) { 17 | * guard.must(publishRights()) 18 | * } 19 | * 20 | * guard.run() 21 | * ``` 22 | */ 23 | export function check(options: Options) { 24 | const contextualizedCheckers: Requirement[] = [] 25 | 26 | const api = { 27 | check(level: Requirement[`level`], validator: Validator) { 28 | contextualizedCheckers.push({ level, validator }) 29 | return api 30 | }, 31 | stopUnless(checker: Validator) { 32 | return api.check(`prefer`, checker) 33 | }, 34 | errorUnless(checker: Validator) { 35 | return api.check(`must`, checker) 36 | }, 37 | run() { 38 | return contextualizedCheckers.reduce((results, requirement) => { 39 | const resultSugar = requirement.validator.run(options.context) 40 | const result = typeof resultSugar === `boolean` ? booleanResult(resultSugar) : resultSugar 41 | 42 | const group = 43 | result.kind === `pass` 44 | ? results.passes 45 | : requirement.level === `must` 46 | ? results.errors 47 | : results.stops 48 | 49 | group.push({ 50 | details: result.details, 51 | summary: requirement.validator.summary, 52 | code: requirement.validator.code, 53 | }) 54 | 55 | return results 56 | }, createReport()) 57 | }, 58 | } 59 | 60 | return api 61 | } 62 | 63 | /** 64 | * 65 | */ 66 | function createReport(): Report { 67 | return { 68 | stops: [], 69 | errors: [], 70 | passes: [], 71 | } 72 | } 73 | 74 | /** 75 | * 76 | */ 77 | interface EnforceInput { 78 | context: Context.Context 79 | report: Report 80 | json?: boolean 81 | } 82 | 83 | /** 84 | * Enforce the result of a report. Throws errors/stop-signal that will halt the 85 | * CLI if needed. 86 | */ 87 | export function guard(input: EnforceInput): void { 88 | if (input.report.errors.length) { 89 | if (input.json) { 90 | if (input.report.errors.length) { 91 | throw new JSONCLIError({ 92 | context: input.context, 93 | failures: input.report.errors, 94 | }) 95 | } 96 | } else { 97 | throw new PassthroughCLIError( 98 | input.report.errors 99 | .map((failure) => { 100 | return failure.summary 101 | }) 102 | .join(`\n - `) 103 | ) 104 | } 105 | } 106 | 107 | if (input.report.stops.length) { 108 | throw new CLIStop({ reasons: input.report.stops, json: input.json }) 109 | } 110 | } 111 | 112 | interface Requirement { 113 | validator: Validator 114 | level: `prefer` | `must` 115 | } 116 | 117 | export interface ValidationResult { 118 | code: Validator[`code`] 119 | summary: string 120 | details: Record 121 | } 122 | 123 | interface Report { 124 | errors: ValidationResult[] 125 | stops: ValidationResult[] 126 | passes: ValidationResult[] 127 | } 128 | 129 | // 130 | // Errors for halting CLI process 131 | // 132 | 133 | export class JSONCLIError extends Error { 134 | code = `JSONError` 135 | 136 | constructor(private errorObject: object) { 137 | super(`...todo...`) 138 | } 139 | 140 | render(): string { 141 | return JSON.stringify(this.errorObject) 142 | } 143 | } 144 | 145 | export class PassthroughCLIError extends Error { 146 | code = `PassthroughError` 147 | 148 | constructor(private errorString: string) { 149 | super(`...todo...`) 150 | } 151 | 152 | render(): string { 153 | return this.errorString 154 | } 155 | } 156 | 157 | export class CLIStop extends Error { 158 | code = `Stop` 159 | 160 | constructor(private input: { reasons: ValidationResult[]; json?: boolean }) { 161 | super(`...todo...`) 162 | } 163 | 164 | render(): string { 165 | if (this.input.json) { 166 | return JSON.stringify({ reasons: this.input.reasons }) 167 | } else { 168 | return `Nothing to do:\n\n` + this.input.reasons.map((r) => r.summary).join(`\n -`) 169 | } 170 | } 171 | } 172 | 173 | // 174 | // Base Either-like & validator types 175 | // 176 | 177 | export interface Validator { 178 | code: string 179 | summary: string 180 | run(ctx: Context.Context): Pass | Fail | boolean 181 | } 182 | 183 | interface Pass { 184 | kind: `pass` 185 | details: Record 186 | } 187 | 188 | interface Fail { 189 | kind: `fail` 190 | details: Record 191 | } 192 | 193 | export type Result = Pass | Fail 194 | 195 | export function booleanResult(bool: boolean): Result { 196 | return bool ? { kind: `pass`, details: {} } : { kind: `fail`, details: {} } 197 | } 198 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import * as Git from '../lib/git' 2 | import { createGit, GitSyncStatus } from '../lib/git2' 3 | import { parseGitHubCIEnvironment } from '../lib/github-ci-environment' 4 | import * as PackageJson from '../lib/package-json' 5 | import { octokit } from './octokit' 6 | import * as Rel from './release' 7 | 8 | export interface PullRequestContext { 9 | number: number 10 | } 11 | 12 | export interface Options { 13 | cwd: string 14 | /** 15 | * When building the context should the CI environment be checked for data? 16 | * Useful to boost performance. 17 | * 18 | * @defaultValue true 19 | */ 20 | readFromCIEnvironment: boolean 21 | overrides?: { 22 | /** 23 | * @defaultValue null 24 | */ 25 | trunk?: null | string 26 | } 27 | } 28 | 29 | export interface LocationContext { 30 | package: { 31 | name: string 32 | } 33 | githubRepo: { 34 | owner: string 35 | name: string 36 | trunkBranch: string 37 | } 38 | currentBranch: { 39 | name: string 40 | isTrunk: boolean 41 | syncStatus: GitSyncStatus 42 | pr: null | PullRequestContext 43 | } 44 | } 45 | 46 | export interface Context extends LocationContext { 47 | series: Rel.Series 48 | } 49 | 50 | export const getContext = async (options: Options): Promise => { 51 | const locationContext = await getLocationContext({ octokit, options }) 52 | const series = await Rel.getCurrentSeries({ cwd: options.cwd }) 53 | return { series, ...locationContext } 54 | } 55 | 56 | /** 57 | * Get location-oriented contextual information. Does not consider the release 58 | * series but things like current branch, repo, pr etc. 59 | */ 60 | export const getLocationContext = async ({ 61 | octokit, 62 | options, 63 | }: { 64 | octokit: any 65 | options?: Options 66 | }): Promise => { 67 | const git = createGit({ cwd: options?.cwd }) 68 | const readFromCIEnvironment = options?.readFromCIEnvironment ?? true 69 | 70 | let githubCIEnvironment = null 71 | 72 | if (readFromCIEnvironment) { 73 | githubCIEnvironment = parseGitHubCIEnvironment() 74 | } 75 | 76 | // Get repo info 77 | 78 | let repoInfo 79 | 80 | repoInfo = githubCIEnvironment?.parsed.repo 81 | 82 | if (!repoInfo) { 83 | repoInfo = await Git.parseGitHubRepoInfoFromGitConfig({ cwd: options?.cwd }) 84 | } 85 | 86 | // Get which branch is trunk, overridable 87 | 88 | let trunkBranch: string 89 | 90 | if (options?.overrides?.trunk) { 91 | trunkBranch = options.overrides.trunk 92 | } else { 93 | let githubRepo 94 | try { 95 | githubRepo = await octokit.repos.get({ 96 | owner: repoInfo.owner, 97 | repo: repoInfo.name, 98 | }) 99 | } catch (e) { 100 | throw new Error( 101 | `Failed to fetch repo info from ${repoInfo.owner}/${repoInfo.name} in order to get the default branch.\n\n${e}` 102 | ) 103 | } 104 | 105 | trunkBranch = githubRepo.data.default_branch 106 | } 107 | 108 | // Get the branch 109 | 110 | // const branchesSummary = await git.branch({}) 111 | 112 | let currentBranchName = await git.getCurrentBranchName() 113 | 114 | if (!currentBranchName && githubCIEnvironment && githubCIEnvironment.parsed.branchName) { 115 | currentBranchName = githubCIEnvironment.parsed.branchName 116 | } 117 | 118 | if (!currentBranchName) { 119 | throw new Error(`Could not get current branch name`) 120 | } 121 | 122 | // Get the pr 123 | 124 | let pr: LocationContext[`currentBranch`][`pr`] = null 125 | 126 | if (githubCIEnvironment && githubCIEnvironment.parsed.prNum) { 127 | pr = { 128 | number: githubCIEnvironment.parsed.prNum, 129 | } 130 | } else { 131 | const head = `${repoInfo.owner}:${currentBranchName}` 132 | const owner = repoInfo.owner 133 | const repo = repoInfo.name 134 | const state = `open` 135 | let maybePR 136 | 137 | try { 138 | maybePR = (await octokit.pulls.list({ owner, repo, head, state })).data[0] 139 | } catch (e) { 140 | throw new Error( 141 | `Failed to fetch ${state} pull requests from ${owner}/${repo} for head ${head} in order to find out if this branch has an open pull-request.\n\n${e}` 142 | ) 143 | } 144 | 145 | if (maybePR) { 146 | pr = { 147 | number: maybePR.number, 148 | } 149 | } 150 | } 151 | 152 | // get the branch sync status 153 | 154 | const syncStatus = await git.checkSyncStatus({ 155 | branchName: currentBranchName, 156 | }) 157 | 158 | // get package info 159 | 160 | const packageJson = await PackageJson.getPackageJson() 161 | 162 | return { 163 | package: { 164 | name: packageJson.name, 165 | }, 166 | githubRepo: { 167 | owner: repoInfo.owner, 168 | name: repoInfo.name, 169 | trunkBranch: trunkBranch, 170 | }, 171 | currentBranch: { 172 | name: currentBranchName, 173 | isTrunk: currentBranchName === trunkBranch, 174 | pr: pr, 175 | syncStatus: syncStatus, 176 | }, 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/utils/octokit.ts: -------------------------------------------------------------------------------- 1 | import { Octokit as OctokitClassLike } from '@octokit/rest' 2 | 3 | export type ReleaseByTagRes = Awaited> 4 | 5 | export const octokit = new OctokitClassLike({ 6 | auth: process.env.GITHUB_TOKEN, 7 | }) 8 | 9 | export type Octokit = typeof octokit 10 | -------------------------------------------------------------------------------- /src/utils/output.ts: -------------------------------------------------------------------------------- 1 | import { Release } from '../lib/publish-package' 2 | import { casesHandled } from '../lib/utils' 3 | import { ValidationResult } from './context-guard' 4 | import { format, inspect } from 'util' 5 | 6 | type Ok> = { 7 | kind: `ok` 8 | type: T 9 | data: D 10 | } 11 | 12 | export type Exception> = { 13 | kind: `exception` 14 | type: string 15 | data: { 16 | summary: string 17 | context: C 18 | } 19 | } 20 | 21 | type Message = Ok | Exception 22 | 23 | /** 24 | * Passthrough to the underlying output mechanism. 25 | */ 26 | export function outputRaw(message: string): void { 27 | console.log(message) 28 | } 29 | 30 | type OutputOptions = { 31 | json: boolean 32 | } 33 | 34 | /** 35 | * This module encapsulates the various structures that dripip may output. 36 | * Precise data details stay local throughout the codebase but core concerns 37 | * like data envelop and serialization are encoded here. 38 | */ 39 | 40 | /** 41 | * Output Ok data to stdout. Use this to handle general output. 42 | */ 43 | export function outputOk(type: string, data: Record): void { 44 | outputMessage(createOk(type, data)) 45 | } 46 | 47 | /** 48 | * Output Exception data to stdout. Unlike an error, exceptions are failure 49 | * scenarios that are known to be possible, and thus handled gracefully. 50 | * 51 | * @param identifier This is a meaningful exception slug like `no_permissions`. 52 | * Use this for to organize exceptions into a catalogue. 53 | * 54 | * @param summary Free form text that briefly explains what the exception 55 | * is, why it is happening, etc. Do not rely on rich context rendering here. 56 | * Instead prefer to put the data/variables at hand into the context object. 57 | * 58 | * @param context Arbitrary data that relates to the exception at hand. 59 | */ 60 | export function outputException( 61 | identifier: string, 62 | summary: string, 63 | opts: OutputOptions & { context: Record } 64 | ): void { 65 | output(createException(identifier, { summary, context: opts.context }), { 66 | json: opts.json, 67 | }) 68 | } 69 | 70 | export function output(message: Message, opts: OutputOptions): void { 71 | if (opts.json) { 72 | outputMessage(message) 73 | } else { 74 | if (message.kind === `exception`) { 75 | let s = `` 76 | s += `An exception occurred: ${message.type}\n` 77 | s += `\n` 78 | s += message.data.summary 79 | if (message.data.context && Object.keys(message.data.context).length > 0) { 80 | s += `\n` 81 | s += format(`%j`, message.data.context) 82 | } 83 | outputRaw(s) 84 | } else if (message.kind === `ok`) { 85 | let s = `` 86 | s += inspect(message.data) 87 | // todo pretty printing 88 | outputRaw(s) 89 | } else { 90 | casesHandled(message) 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * See output version docs. 97 | */ 98 | export function createOk(type: T, data: Record): Ok { 99 | return { kind: `ok`, type, data } 100 | } 101 | 102 | type DryRun = Ok<`dry_run`> 103 | 104 | export function createDryRun(data: Record): DryRun { 105 | return createOk(`dry_run`, data) 106 | } 107 | 108 | /** 109 | * See output version docs. 110 | */ 111 | export function createException( 112 | type: string, 113 | { 114 | summary, 115 | context, 116 | }: { 117 | summary: string 118 | context?: Record 119 | } 120 | ): Exception { 121 | return { 122 | kind: `exception`, 123 | type, 124 | data: { 125 | summary, 126 | context: context ?? {}, 127 | }, 128 | } 129 | } 130 | 131 | export function createDidNotPublish(data: { reasons: ValidationResult[] }) { 132 | return createOk(`did_not_publish`, data) 133 | } 134 | 135 | export function createDidPublish(data: { release: Release }) { 136 | return createOk(`did_publish`, data) 137 | } 138 | 139 | /** 140 | * Output Message to stdout. 141 | */ 142 | export function outputMessage(msg: Message): void { 143 | outputJson(msg) 144 | } 145 | 146 | /** 147 | * Output JSON to stdout. 148 | */ 149 | export function outputJson(msg: object): void { 150 | process.stdout.write(JSON.stringify(msg)) 151 | } 152 | -------------------------------------------------------------------------------- /src/utils/pr-release.ts: -------------------------------------------------------------------------------- 1 | import { rootDebug } from '../lib/debug' 2 | import { numericAscending } from '../lib/utils' 3 | import { spawnSync } from 'child_process' 4 | import * as Execa from 'execa' 5 | 6 | const debug = rootDebug(__filename) 7 | 8 | /** 9 | * Get the pr release version for the given commit. A search is made through the 10 | * package's versions for this pr/commit combination. Returns `null` if no 11 | * release version can be found. 12 | */ 13 | export function getPullRequestReleaseVersionForLocation(input: { 14 | packageName: string 15 | prNum: number 16 | sha: string 17 | }): null | string { 18 | const shortSHA = input.sha.slice(0, 7) 19 | const versions = getPackageVersions(input.packageName) 20 | const pattern = new RegExp(`0\\.0\\.0-pr\\.${input.prNum}\\.\\d+\\.${shortSHA}`) 21 | debug(`looking for version matching pattern %O`, pattern) 22 | const version = versions.find((v) => v.match(pattern) !== null) ?? null 23 | return version 24 | } 25 | 26 | /** 27 | * Get the next build num for a pre-release series. 28 | * 29 | * The pre-release series is the group versions published matching the given prefix. 30 | * 31 | * The build num is assumed to be digits immediately following the prefix up to 32 | * the end of the version or up to the next `.`. 33 | * 34 | * If a version matches prefix but then not the pattern described above then it 35 | * is discarded. 36 | * 37 | * If no versions are found with given prefix then 1 is returned. 38 | * 39 | * If versions are found, then the greatest build number is incremented by 1 and 40 | * then returned. 41 | */ 42 | export function getNextPreReleaseBuildNum(packageName: string, prefix: string): number { 43 | const versions = getPackageVersions(packageName) 44 | const nextBuildNum = getNextPreReleaseBuildNumFromVersions(prefix, versions) 45 | return nextBuildNum 46 | } 47 | 48 | /** 49 | * Pure helper for getting next build num of a pre-release series. 50 | */ 51 | function getNextPreReleaseBuildNumFromVersions(prefix: string, versions: string[]): number { 52 | const filteredSorted = versions 53 | .filter((v) => v.startsWith(prefix)) 54 | .map((v) => { 55 | const match = v.slice(prefix.length).match(/^(\d+)$|^(\d+)\./) 56 | if (match === null) return null 57 | if (match[1] !== undefined) return match[1] 58 | if (match[2] !== undefined) return match[2] 59 | // never 60 | return null 61 | }) 62 | .filter((v) => v !== null) 63 | .map((v) => Number(v)) 64 | .sort(numericAscending) 65 | 66 | if (filteredSorted.length === 0) return 1 67 | return filteredSorted.pop()! + 1 68 | } 69 | 70 | // // todo put into unit test 71 | // const vs = [ 72 | // '0.0.0-pr.30.1.1079baa', 73 | // '0.0.0-pr.30.2.1c2e772', 74 | // '0.0.0-pr.30.5.3a9ec9f', 75 | // '0.0.0-pr.30.3.6f29f57', 76 | // '0.0.0-pr.30.46.ee27408', 77 | // '0.2.0', 78 | // '0.2.7', 79 | // '0.2.8-next.1', 80 | // '0.2.8', 81 | // '0.2.9-next.2', 82 | // '0.2.9', 83 | // '0.3.0', 84 | // '0.3.1', 85 | // '0.3.2', 86 | // '0.4.0', 87 | // '0.5.0', 88 | // '0.6.0-next.1', 89 | // '0.6.0-next.2', 90 | // '0.6.0', 91 | // '0.6.1-next.1', 92 | // '0.6.1-next.2', 93 | // '0.6.1', 94 | // ] 95 | 96 | // // getNextPreReleaseBuildNumFromVersions('0.0.0-pr.30.', vs) //? 97 | 98 | // '1.1079baa'.match(/^(\d+)$|^(\d+)\./) //? 99 | // '1079'.match(/^(\d+)$|^(\d+)\./) //? 100 | // '1079baa'.match(/^(\d+)$|^(\d+)\./) //? 101 | 102 | /** 103 | * Get all versions ever released for the given package, including pre-releases. 104 | */ 105 | function getPackageVersions(packageName: string): string[] { 106 | // todo do http request instead of cli spawn for perf 107 | const result = Execa.commandSync(`npm show ${packageName} versions --json`) 108 | if (result.failed) throw new Error(result.stderr) 109 | return JSON.parse(result.stdout) 110 | } 111 | -------------------------------------------------------------------------------- /src/utils/release.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO the gitlog permits cases that aren't valid under these tests. Namely 2 | // that it can be an arbitrary git log. However buildSeries works on the 3 | // assumption that the latest stable and all subsequent commits has been 4 | // fetched. 5 | 6 | import * as Git from '../lib/git' 7 | import * as Rel from './release' 8 | 9 | describe(`buildSeries`, () => { 10 | it(`one breaking change commit makes the series breaking`, () => { 11 | expect(gitlog(n, breaking, p).hasBreakingChange).toEqual(true) 12 | }) 13 | it(`tracks if is initial development`, () => { 14 | expect(gitlog(p).isInitialDevelopment).toEqual(true) 15 | }) 16 | it(`tracks if is not initial development`, () => { 17 | expect(gitlog(s, p).isInitialDevelopment).toEqual(false) 18 | }) 19 | it(``, () => { 20 | expect(() => gitlog()).toThrowErrorMatchingSnapshot() 21 | }) 22 | it(`none`, () => { 23 | expect(gitlog(n)).toMatchSnapshot() 24 | }) 25 | it(`none preview`, () => { 26 | expect(gitlog(n, p)).toMatchSnapshot() 27 | }) 28 | it(`preview none none preview`, () => { 29 | expect(gitlog(p, n, n, p)).toMatchSnapshot() 30 | }) 31 | it(`stable none`, () => { 32 | expect(gitlog(s, n)).toMatchSnapshot() 33 | }) 34 | it(`stable none none preview none`, () => { 35 | expect(gitlog(s, n, n, p, n)).toMatchSnapshot() 36 | }) 37 | it(`stable none none preview preview preview none preview`, () => { 38 | expect(gitlog(s, n, n, p, p, p, n, p)).toMatchSnapshot() 39 | }) 40 | }) 41 | 42 | // 43 | // Helpers 44 | // 45 | 46 | type RawLogEntryValues = [string, string, string] 47 | 48 | let pNum: number 49 | let sMaj: number 50 | let noneCounter: number 51 | 52 | beforeEach(() => { 53 | pNum = 0 54 | noneCounter = 1 55 | sMaj = 0 56 | }) 57 | 58 | /** 59 | * breaking change commit 60 | */ 61 | function breaking(): RawLogEntryValues { 62 | const ver = `${sMaj}.0.0-next.${pNum}` 63 | return [`sha`, `tag: ${ver}`, `foo: bar\n\nBREAKING CHANGE: foo`] 64 | } 65 | 66 | /** 67 | * preview-released commit 68 | */ 69 | function p(): RawLogEntryValues { 70 | pNum++ 71 | const ver = `${sMaj}.0.0-next.${pNum}` 72 | return [`sha`, `tag: ${ver}`, `foo @ ${ver}`] 73 | } 74 | 75 | /** 76 | * stable-released commit 77 | */ 78 | function s(): RawLogEntryValues { 79 | sMaj++ 80 | pNum = 0 81 | const ver = `${sMaj}.0.0` 82 | return [`sha`, `tag: ${ver}`, `foo @ ${ver}`] 83 | } 84 | 85 | /** 86 | * unreleased commit 87 | */ 88 | function n(): RawLogEntryValues { 89 | return [`sha`, ``, `fix: thing ${noneCounter++}`] 90 | } 91 | 92 | function gitlog(...actions: (() => RawLogEntryValues)[]): Rel.Series { 93 | const log = Git.parseRawLog(Git.serializeLog(actions.map((f) => f()))).map(Git.parseLogRefs) 94 | const pstable = actions[0] === s ? (log.pop() as Git.LogEntry) : null 95 | return Rel.buildSeries([pstable, log]) 96 | } 97 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/main 2 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = false 5 | logallrefupdates = true 6 | [remote "origin"] 7 | url = git@github.com:prisma-labs/dripip-system-tests.git 8 | fetch = +refs/heads/*:refs/remotes/origin/* 9 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/description: -------------------------------------------------------------------------------- 1 | Unnamed repository; edit this file 'description' to name the repository. 2 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/applypatch-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message taken by 4 | # applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. The hook is 8 | # allowed to edit the commit message file. 9 | # 10 | # To enable this hook, rename this file to "applypatch-msg". 11 | 12 | . git-sh-setup 13 | commitmsg="$(git rev-parse --git-path hooks/commit-msg)" 14 | test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} 15 | : 16 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | # 9 | # To enable this hook, rename this file to "commit-msg". 10 | 11 | # Uncomment the below to add a Signed-off-by line to the message. 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg 13 | # hook is more suited to it. 14 | # 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 17 | 18 | # This example catches duplicate Signed-off-by lines. 19 | 20 | test "" = "$(grep '^Signed-off-by: ' "$1" | 21 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { 22 | echo >&2 Duplicate Signed-off-by lines. 23 | exit 1 24 | } 25 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/fsmonitor-watchman.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use IPC::Open2; 6 | 7 | # An example hook script to integrate Watchman 8 | # (https://facebook.github.io/watchman/) with git to speed up detecting 9 | # new and modified files. 10 | # 11 | # The hook is passed a version (currently 1) and a time in nanoseconds 12 | # formatted as a string and outputs to stdout all files that have been 13 | # modified since the given time. Paths must be relative to the root of 14 | # the working tree and separated by a single NUL. 15 | # 16 | # To enable this hook, rename this file to "query-watchman" and set 17 | # 'git config core.fsmonitor .git/hooks/query-watchman' 18 | # 19 | my ($version, $time) = @ARGV; 20 | 21 | # Check the hook interface version 22 | 23 | if ($version == 1) { 24 | # convert nanoseconds to seconds 25 | $time = int $time / 1000000000; 26 | } else { 27 | die "Unsupported query-fsmonitor hook version '$version'.\n" . 28 | "Falling back to scanning...\n"; 29 | } 30 | 31 | my $git_work_tree; 32 | if ($^O =~ 'msys' || $^O =~ 'cygwin') { 33 | $git_work_tree = Win32::GetCwd(); 34 | $git_work_tree =~ tr/\\/\//; 35 | } else { 36 | require Cwd; 37 | $git_work_tree = Cwd::cwd(); 38 | } 39 | 40 | my $retry = 1; 41 | 42 | launch_watchman(); 43 | 44 | sub launch_watchman { 45 | 46 | my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') 47 | or die "open2() failed: $!\n" . 48 | "Falling back to scanning...\n"; 49 | 50 | # In the query expression below we're asking for names of files that 51 | # changed since $time but were not transient (ie created after 52 | # $time but no longer exist). 53 | # 54 | # To accomplish this, we're using the "since" generator to use the 55 | # recency index to select candidate nodes and "fields" to limit the 56 | # output to file names only. Then we're using the "expression" term to 57 | # further constrain the results. 58 | # 59 | # The category of transient files that we want to ignore will have a 60 | # creation clock (cclock) newer than $time_t value and will also not 61 | # currently exist. 62 | 63 | my $query = <<" END"; 64 | ["query", "$git_work_tree", { 65 | "since": $time, 66 | "fields": ["name"], 67 | "expression": ["not", ["allof", ["since", $time, "cclock"], ["not", "exists"]]] 68 | }] 69 | END 70 | 71 | print CHLD_IN $query; 72 | close CHLD_IN; 73 | my $response = do {local $/; }; 74 | 75 | die "Watchman: command returned no output.\n" . 76 | "Falling back to scanning...\n" if $response eq ""; 77 | die "Watchman: command returned invalid output: $response\n" . 78 | "Falling back to scanning...\n" unless $response =~ /^\{/; 79 | 80 | my $json_pkg; 81 | eval { 82 | require JSON::XS; 83 | $json_pkg = "JSON::XS"; 84 | 1; 85 | } or do { 86 | require JSON::PP; 87 | $json_pkg = "JSON::PP"; 88 | }; 89 | 90 | my $o = $json_pkg->new->utf8->decode($response); 91 | 92 | if ($retry > 0 and $o->{error} and $o->{error} =~ m/unable to resolve root .* directory (.*) is not watched/) { 93 | print STDERR "Adding '$git_work_tree' to watchman's watch list.\n"; 94 | $retry--; 95 | qx/watchman watch "$git_work_tree"/; 96 | die "Failed to make watchman watch '$git_work_tree'.\n" . 97 | "Falling back to scanning...\n" if $? != 0; 98 | 99 | # Watchman will always return all files on the first query so 100 | # return the fast "everything is dirty" flag to git and do the 101 | # Watchman query just to get it over with now so we won't pay 102 | # the cost in git to look up each individual file. 103 | print "/\0"; 104 | eval { launch_watchman() }; 105 | exit 0; 106 | } 107 | 108 | die "Watchman: $o->{error}.\n" . 109 | "Falling back to scanning...\n" if $o->{error}; 110 | 111 | binmode STDOUT, ":utf8"; 112 | local $, = "\0"; 113 | print @{$o->{files}}; 114 | } 115 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/post-update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare a packed repository for use over 4 | # dumb transports. 5 | # 6 | # To enable this hook, rename this file to "post-update". 7 | 8 | exec git update-server-info 9 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/pre-applypatch.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed 4 | # by applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. 8 | # 9 | # To enable this hook, rename this file to "pre-applypatch". 10 | 11 | . git-sh-setup 12 | precommit="$(git rev-parse --git-path hooks/pre-commit)" 13 | test -x "$precommit" && exec "$precommit" ${1+"$@"} 14 | : 15 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/pre-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | 10 | if git rev-parse --verify HEAD >/dev/null 2>&1 11 | then 12 | against=HEAD 13 | else 14 | # Initial commit: diff against an empty tree object 15 | against=$(git hash-object -t tree /dev/null) 16 | fi 17 | 18 | # If you want to allow non-ASCII filenames set this variable to true. 19 | allownonascii=$(git config --bool hooks.allownonascii) 20 | 21 | # Redirect output to stderr. 22 | exec 1>&2 23 | 24 | # Cross platform projects tend to avoid non-ASCII filenames; prevent 25 | # them from being added to the repository. We exploit the fact that the 26 | # printable range starts at the space character and ends with tilde. 27 | if [ "$allownonascii" != "true" ] && 28 | # Note that the use of brackets around a tr range is ok here, (it's 29 | # even required, for portability to Solaris 10's /usr/bin/tr), since 30 | # the square bracket bytes happen to fall in the designated range. 31 | test $(git diff --cached --name-only --diff-filter=A -z $against | 32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 33 | then 34 | cat <<\EOF 35 | Error: Attempt to add a non-ASCII file name. 36 | 37 | This can cause problems if you want to work with people on other platforms. 38 | 39 | To be portable it is advisable to rename the file. 40 | 41 | If you know what you are doing you can disable this check using: 42 | 43 | git config hooks.allownonascii true 44 | EOF 45 | exit 1 46 | fi 47 | 48 | # If there are whitespace errors, print the offending file names and fail. 49 | exec git diff-index --check --cached $against -- 50 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/pre-push.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # An example hook script to verify what is about to be pushed. Called by "git 4 | # push" after it has checked the remote status, but before anything has been 5 | # pushed. If this script exits with a non-zero status nothing will be pushed. 6 | # 7 | # This hook is called with the following parameters: 8 | # 9 | # $1 -- Name of the remote to which the push is being done 10 | # $2 -- URL to which the push is being done 11 | # 12 | # If pushing without using a named remote those arguments will be equal. 13 | # 14 | # Information about the commits which are being pushed is supplied as lines to 15 | # the standard input in the form: 16 | # 17 | # 18 | # 19 | # This sample shows how to prevent push of commits where the log message starts 20 | # with "WIP" (work in progress). 21 | 22 | remote="$1" 23 | url="$2" 24 | 25 | z40=0000000000000000000000000000000000000000 26 | 27 | while read local_ref local_sha remote_ref remote_sha 28 | do 29 | if [ "$local_sha" = $z40 ] 30 | then 31 | # Handle delete 32 | : 33 | else 34 | if [ "$remote_sha" = $z40 ] 35 | then 36 | # New branch, examine all commits 37 | range="$local_sha" 38 | else 39 | # Update to existing branch, examine new commits 40 | range="$remote_sha..$local_sha" 41 | fi 42 | 43 | # Check for WIP commit 44 | commit=`git rev-list -n 1 --grep '^WIP' "$range"` 45 | if [ -n "$commit" ] 46 | then 47 | echo >&2 "Found WIP commit in $local_ref, not pushing" 48 | exit 1 49 | fi 50 | fi 51 | done 52 | 53 | exit 0 54 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/pre-rebase.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) 2006, 2008 Junio C Hamano 4 | # 5 | # The "pre-rebase" hook is run just before "git rebase" starts doing 6 | # its job, and can prevent the command from running by exiting with 7 | # non-zero status. 8 | # 9 | # The hook is called with the following parameters: 10 | # 11 | # $1 -- the upstream the series was forked from. 12 | # $2 -- the branch being rebased (or empty when rebasing the current branch). 13 | # 14 | # This sample shows how to prevent topic branches that are already 15 | # merged to 'next' branch from getting rebased, because allowing it 16 | # would result in rebasing already published history. 17 | 18 | publish=next 19 | basebranch="$1" 20 | if test "$#" = 2 21 | then 22 | topic="refs/heads/$2" 23 | else 24 | topic=`git symbolic-ref HEAD` || 25 | exit 0 ;# we do not interrupt rebasing detached HEAD 26 | fi 27 | 28 | case "$topic" in 29 | refs/heads/??/*) 30 | ;; 31 | *) 32 | exit 0 ;# we do not interrupt others. 33 | ;; 34 | esac 35 | 36 | # Now we are dealing with a topic branch being rebased 37 | # on top of master. Is it OK to rebase it? 38 | 39 | # Does the topic really exist? 40 | git show-ref -q "$topic" || { 41 | echo >&2 "No such branch $topic" 42 | exit 1 43 | } 44 | 45 | # Is topic fully merged to master? 46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"` 47 | if test -z "$not_in_master" 48 | then 49 | echo >&2 "$topic is fully merged to master; better remove it." 50 | exit 1 ;# we could allow it, but there is no point. 51 | fi 52 | 53 | # Is topic ever merged to next? If so you should not be rebasing it. 54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` 55 | only_next_2=`git rev-list ^master ${publish} | sort` 56 | if test "$only_next_1" = "$only_next_2" 57 | then 58 | not_in_topic=`git rev-list "^$topic" master` 59 | if test -z "$not_in_topic" 60 | then 61 | echo >&2 "$topic is already up to date with master" 62 | exit 1 ;# we could allow it, but there is no point. 63 | else 64 | exit 0 65 | fi 66 | else 67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` 68 | /usr/bin/perl -e ' 69 | my $topic = $ARGV[0]; 70 | my $msg = "* $topic has commits already merged to public branch:\n"; 71 | my (%not_in_next) = map { 72 | /^([0-9a-f]+) /; 73 | ($1 => 1); 74 | } split(/\n/, $ARGV[1]); 75 | for my $elem (map { 76 | /^([0-9a-f]+) (.*)$/; 77 | [$1 => $2]; 78 | } split(/\n/, $ARGV[2])) { 79 | if (!exists $not_in_next{$elem->[0]}) { 80 | if ($msg) { 81 | print STDERR $msg; 82 | undef $msg; 83 | } 84 | print STDERR " $elem->[1]\n"; 85 | } 86 | } 87 | ' "$topic" "$not_in_next" "$not_in_master" 88 | exit 1 89 | fi 90 | 91 | <<\DOC_END 92 | 93 | This sample hook safeguards topic branches that have been 94 | published from being rewound. 95 | 96 | The workflow assumed here is: 97 | 98 | * Once a topic branch forks from "master", "master" is never 99 | merged into it again (either directly or indirectly). 100 | 101 | * Once a topic branch is fully cooked and merged into "master", 102 | it is deleted. If you need to build on top of it to correct 103 | earlier mistakes, a new topic branch is created by forking at 104 | the tip of the "master". This is not strictly necessary, but 105 | it makes it easier to keep your history simple. 106 | 107 | * Whenever you need to test or publish your changes to topic 108 | branches, merge them into "next" branch. 109 | 110 | The script, being an example, hardcodes the publish branch name 111 | to be "next", but it is trivial to make it configurable via 112 | $GIT_DIR/config mechanism. 113 | 114 | With this workflow, you would want to know: 115 | 116 | (1) ... if a topic branch has ever been merged to "next". Young 117 | topic branches can have stupid mistakes you would rather 118 | clean up before publishing, and things that have not been 119 | merged into other branches can be easily rebased without 120 | affecting other people. But once it is published, you would 121 | not want to rewind it. 122 | 123 | (2) ... if a topic branch has been fully merged to "master". 124 | Then you can delete it. More importantly, you should not 125 | build on top of it -- other people may already want to 126 | change things related to the topic as patches against your 127 | "master", so if you need further changes, it is better to 128 | fork the topic (perhaps with the same name) afresh from the 129 | tip of "master". 130 | 131 | Let's look at this example: 132 | 133 | o---o---o---o---o---o---o---o---o---o "next" 134 | / / / / 135 | / a---a---b A / / 136 | / / / / 137 | / / c---c---c---c B / 138 | / / / \ / 139 | / / / b---b C \ / 140 | / / / / \ / 141 | ---o---o---o---o---o---o---o---o---o---o---o "master" 142 | 143 | 144 | A, B and C are topic branches. 145 | 146 | * A has one fix since it was merged up to "next". 147 | 148 | * B has finished. It has been fully merged up to "master" and "next", 149 | and is ready to be deleted. 150 | 151 | * C has not merged to "next" at all. 152 | 153 | We would want to allow C to be rebased, refuse A, and encourage 154 | B to be deleted. 155 | 156 | To compute (1): 157 | 158 | git rev-list ^master ^topic next 159 | git rev-list ^master next 160 | 161 | if these match, topic has not merged in next at all. 162 | 163 | To compute (2): 164 | 165 | git rev-list master..topic 166 | 167 | if this is empty, it is fully merged to "master". 168 | 169 | DOC_END 170 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/pre-receive.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to make use of push options. 4 | # The example simply echoes all push options that start with 'echoback=' 5 | # and rejects all pushes when the "reject" push option is used. 6 | # 7 | # To enable this hook, rename this file to "pre-receive". 8 | 9 | if test -n "$GIT_PUSH_OPTION_COUNT" 10 | then 11 | i=0 12 | while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" 13 | do 14 | eval "value=\$GIT_PUSH_OPTION_$i" 15 | case "$value" in 16 | echoback=*) 17 | echo "echo from the pre-receive-hook: ${value#*=}" >&2 18 | ;; 19 | reject) 20 | exit 1 21 | esac 22 | i=$((i + 1)) 23 | done 24 | fi 25 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/prepare-commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare the commit log message. 4 | # Called by "git commit" with the name of the file that has the 5 | # commit message, followed by the description of the commit 6 | # message's source. The hook's purpose is to edit the commit 7 | # message file. If the hook fails with a non-zero status, 8 | # the commit is aborted. 9 | # 10 | # To enable this hook, rename this file to "prepare-commit-msg". 11 | 12 | # This hook includes three examples. The first one removes the 13 | # "# Please enter the commit message..." help message. 14 | # 15 | # The second includes the output of "git diff --name-status -r" 16 | # into the message, just before the "git status" output. It is 17 | # commented because it doesn't cope with --amend or with squashed 18 | # commits. 19 | # 20 | # The third example adds a Signed-off-by line to the message, that can 21 | # still be edited. This is rarely a good idea. 22 | 23 | COMMIT_MSG_FILE=$1 24 | COMMIT_SOURCE=$2 25 | SHA1=$3 26 | 27 | /usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" 28 | 29 | # case "$COMMIT_SOURCE,$SHA1" in 30 | # ,|template,) 31 | # /usr/bin/perl -i.bak -pe ' 32 | # print "\n" . `git diff --cached --name-status -r` 33 | # if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; 34 | # *) ;; 35 | # esac 36 | 37 | # SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 38 | # git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" 39 | # if test -z "$COMMIT_SOURCE" 40 | # then 41 | # /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" 42 | # fi 43 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/hooks/update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to block unannotated tags from entering. 4 | # Called by "git receive-pack" with arguments: refname sha1-old sha1-new 5 | # 6 | # To enable this hook, rename this file to "update". 7 | # 8 | # Config 9 | # ------ 10 | # hooks.allowunannotated 11 | # This boolean sets whether unannotated tags will be allowed into the 12 | # repository. By default they won't be. 13 | # hooks.allowdeletetag 14 | # This boolean sets whether deleting tags will be allowed in the 15 | # repository. By default they won't be. 16 | # hooks.allowmodifytag 17 | # This boolean sets whether a tag may be modified after creation. By default 18 | # it won't be. 19 | # hooks.allowdeletebranch 20 | # This boolean sets whether deleting branches will be allowed in the 21 | # repository. By default they won't be. 22 | # hooks.denycreatebranch 23 | # This boolean sets whether remotely creating branches will be denied 24 | # in the repository. By default this is allowed. 25 | # 26 | 27 | # --- Command line 28 | refname="$1" 29 | oldrev="$2" 30 | newrev="$3" 31 | 32 | # --- Safety check 33 | if [ -z "$GIT_DIR" ]; then 34 | echo "Don't run this script from the command line." >&2 35 | echo " (if you want, you could supply GIT_DIR then run" >&2 36 | echo " $0 )" >&2 37 | exit 1 38 | fi 39 | 40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then 41 | echo "usage: $0 " >&2 42 | exit 1 43 | fi 44 | 45 | # --- Config 46 | allowunannotated=$(git config --bool hooks.allowunannotated) 47 | allowdeletebranch=$(git config --bool hooks.allowdeletebranch) 48 | denycreatebranch=$(git config --bool hooks.denycreatebranch) 49 | allowdeletetag=$(git config --bool hooks.allowdeletetag) 50 | allowmodifytag=$(git config --bool hooks.allowmodifytag) 51 | 52 | # check for no description 53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") 54 | case "$projectdesc" in 55 | "Unnamed repository"* | "") 56 | echo "*** Project description file hasn't been set" >&2 57 | exit 1 58 | ;; 59 | esac 60 | 61 | # --- Check types 62 | # if $newrev is 0000...0000, it's a commit to delete a ref. 63 | zero="0000000000000000000000000000000000000000" 64 | if [ "$newrev" = "$zero" ]; then 65 | newrev_type=delete 66 | else 67 | newrev_type=$(git cat-file -t $newrev) 68 | fi 69 | 70 | case "$refname","$newrev_type" in 71 | refs/tags/*,commit) 72 | # un-annotated tag 73 | short_refname=${refname##refs/tags/} 74 | if [ "$allowunannotated" != "true" ]; then 75 | echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 77 | exit 1 78 | fi 79 | ;; 80 | refs/tags/*,delete) 81 | # delete tag 82 | if [ "$allowdeletetag" != "true" ]; then 83 | echo "*** Deleting a tag is not allowed in this repository" >&2 84 | exit 1 85 | fi 86 | ;; 87 | refs/tags/*,tag) 88 | # annotated tag 89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 90 | then 91 | echo "*** Tag '$refname' already exists." >&2 92 | echo "*** Modifying a tag is not allowed in this repository." >&2 93 | exit 1 94 | fi 95 | ;; 96 | refs/heads/*,commit) 97 | # branch 98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then 99 | echo "*** Creating a branch is not allowed in this repository" >&2 100 | exit 1 101 | fi 102 | ;; 103 | refs/heads/*,delete) 104 | # delete branch 105 | if [ "$allowdeletebranch" != "true" ]; then 106 | echo "*** Deleting a branch is not allowed in this repository" >&2 107 | exit 1 108 | fi 109 | ;; 110 | refs/remotes/*,commit) 111 | # tracking branch 112 | ;; 113 | refs/remotes/*,delete) 114 | # delete tracking branch 115 | if [ "$allowdeletebranch" != "true" ]; then 116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 117 | exit 1 118 | fi 119 | ;; 120 | *) 121 | # Anything else (is there anything else?) 122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 123 | exit 1 124 | ;; 125 | esac 126 | 127 | # --- Finished 128 | exit 0 129 | -------------------------------------------------------------------------------- /tests/__fixtures/git-init/info/exclude: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | # *~ 7 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/main 2 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = false 5 | logallrefupdates = true 6 | [remote "origin"] 7 | url = git@github.com:prisma-labs/dripip-system-tests.git 8 | fetch = +refs/heads/*:refs/remotes/origin/* 9 | [branch "main"] 10 | remote = origin 11 | merge = refs/heads/main 12 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/description: -------------------------------------------------------------------------------- 1 | Unnamed repository; edit this file 'description' to name the repository. 2 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/applypatch-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message taken by 4 | # applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. The hook is 8 | # allowed to edit the commit message file. 9 | # 10 | # To enable this hook, rename this file to "applypatch-msg". 11 | 12 | . git-sh-setup 13 | commitmsg="$(git rev-parse --git-path hooks/commit-msg)" 14 | test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} 15 | : 16 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | # 9 | # To enable this hook, rename this file to "commit-msg". 10 | 11 | # Uncomment the below to add a Signed-off-by line to the message. 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg 13 | # hook is more suited to it. 14 | # 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 17 | 18 | # This example catches duplicate Signed-off-by lines. 19 | 20 | test "" = "$(grep '^Signed-off-by: ' "$1" | 21 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { 22 | echo >&2 Duplicate Signed-off-by lines. 23 | exit 1 24 | } 25 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/fsmonitor-watchman.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use IPC::Open2; 6 | 7 | # An example hook script to integrate Watchman 8 | # (https://facebook.github.io/watchman/) with git to speed up detecting 9 | # new and modified files. 10 | # 11 | # The hook is passed a version (currently 1) and a time in nanoseconds 12 | # formatted as a string and outputs to stdout all files that have been 13 | # modified since the given time. Paths must be relative to the root of 14 | # the working tree and separated by a single NUL. 15 | # 16 | # To enable this hook, rename this file to "query-watchman" and set 17 | # 'git config core.fsmonitor .git/hooks/query-watchman' 18 | # 19 | my ($version, $time) = @ARGV; 20 | 21 | # Check the hook interface version 22 | 23 | if ($version == 1) { 24 | # convert nanoseconds to seconds 25 | $time = int $time / 1000000000; 26 | } else { 27 | die "Unsupported query-fsmonitor hook version '$version'.\n" . 28 | "Falling back to scanning...\n"; 29 | } 30 | 31 | my $git_work_tree; 32 | if ($^O =~ 'msys' || $^O =~ 'cygwin') { 33 | $git_work_tree = Win32::GetCwd(); 34 | $git_work_tree =~ tr/\\/\//; 35 | } else { 36 | require Cwd; 37 | $git_work_tree = Cwd::cwd(); 38 | } 39 | 40 | my $retry = 1; 41 | 42 | launch_watchman(); 43 | 44 | sub launch_watchman { 45 | 46 | my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') 47 | or die "open2() failed: $!\n" . 48 | "Falling back to scanning...\n"; 49 | 50 | # In the query expression below we're asking for names of files that 51 | # changed since $time but were not transient (ie created after 52 | # $time but no longer exist). 53 | # 54 | # To accomplish this, we're using the "since" generator to use the 55 | # recency index to select candidate nodes and "fields" to limit the 56 | # output to file names only. Then we're using the "expression" term to 57 | # further constrain the results. 58 | # 59 | # The category of transient files that we want to ignore will have a 60 | # creation clock (cclock) newer than $time_t value and will also not 61 | # currently exist. 62 | 63 | my $query = <<" END"; 64 | ["query", "$git_work_tree", { 65 | "since": $time, 66 | "fields": ["name"], 67 | "expression": ["not", ["allof", ["since", $time, "cclock"], ["not", "exists"]]] 68 | }] 69 | END 70 | 71 | print CHLD_IN $query; 72 | close CHLD_IN; 73 | my $response = do {local $/; }; 74 | 75 | die "Watchman: command returned no output.\n" . 76 | "Falling back to scanning...\n" if $response eq ""; 77 | die "Watchman: command returned invalid output: $response\n" . 78 | "Falling back to scanning...\n" unless $response =~ /^\{/; 79 | 80 | my $json_pkg; 81 | eval { 82 | require JSON::XS; 83 | $json_pkg = "JSON::XS"; 84 | 1; 85 | } or do { 86 | require JSON::PP; 87 | $json_pkg = "JSON::PP"; 88 | }; 89 | 90 | my $o = $json_pkg->new->utf8->decode($response); 91 | 92 | if ($retry > 0 and $o->{error} and $o->{error} =~ m/unable to resolve root .* directory (.*) is not watched/) { 93 | print STDERR "Adding '$git_work_tree' to watchman's watch list.\n"; 94 | $retry--; 95 | qx/watchman watch "$git_work_tree"/; 96 | die "Failed to make watchman watch '$git_work_tree'.\n" . 97 | "Falling back to scanning...\n" if $? != 0; 98 | 99 | # Watchman will always return all files on the first query so 100 | # return the fast "everything is dirty" flag to git and do the 101 | # Watchman query just to get it over with now so we won't pay 102 | # the cost in git to look up each individual file. 103 | print "/\0"; 104 | eval { launch_watchman() }; 105 | exit 0; 106 | } 107 | 108 | die "Watchman: $o->{error}.\n" . 109 | "Falling back to scanning...\n" if $o->{error}; 110 | 111 | binmode STDOUT, ":utf8"; 112 | local $, = "\0"; 113 | print @{$o->{files}}; 114 | } 115 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/post-update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare a packed repository for use over 4 | # dumb transports. 5 | # 6 | # To enable this hook, rename this file to "post-update". 7 | 8 | exec git update-server-info 9 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/pre-applypatch.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed 4 | # by applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. 8 | # 9 | # To enable this hook, rename this file to "pre-applypatch". 10 | 11 | . git-sh-setup 12 | precommit="$(git rev-parse --git-path hooks/pre-commit)" 13 | test -x "$precommit" && exec "$precommit" ${1+"$@"} 14 | : 15 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/pre-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | 10 | if git rev-parse --verify HEAD >/dev/null 2>&1 11 | then 12 | against=HEAD 13 | else 14 | # Initial commit: diff against an empty tree object 15 | against=$(git hash-object -t tree /dev/null) 16 | fi 17 | 18 | # If you want to allow non-ASCII filenames set this variable to true. 19 | allownonascii=$(git config --bool hooks.allownonascii) 20 | 21 | # Redirect output to stderr. 22 | exec 1>&2 23 | 24 | # Cross platform projects tend to avoid non-ASCII filenames; prevent 25 | # them from being added to the repository. We exploit the fact that the 26 | # printable range starts at the space character and ends with tilde. 27 | if [ "$allownonascii" != "true" ] && 28 | # Note that the use of brackets around a tr range is ok here, (it's 29 | # even required, for portability to Solaris 10's /usr/bin/tr), since 30 | # the square bracket bytes happen to fall in the designated range. 31 | test $(git diff --cached --name-only --diff-filter=A -z $against | 32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 33 | then 34 | cat <<\EOF 35 | Error: Attempt to add a non-ASCII file name. 36 | 37 | This can cause problems if you want to work with people on other platforms. 38 | 39 | To be portable it is advisable to rename the file. 40 | 41 | If you know what you are doing you can disable this check using: 42 | 43 | git config hooks.allownonascii true 44 | EOF 45 | exit 1 46 | fi 47 | 48 | # If there are whitespace errors, print the offending file names and fail. 49 | exec git diff-index --check --cached $against -- 50 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/pre-push.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # An example hook script to verify what is about to be pushed. Called by "git 4 | # push" after it has checked the remote status, but before anything has been 5 | # pushed. If this script exits with a non-zero status nothing will be pushed. 6 | # 7 | # This hook is called with the following parameters: 8 | # 9 | # $1 -- Name of the remote to which the push is being done 10 | # $2 -- URL to which the push is being done 11 | # 12 | # If pushing without using a named remote those arguments will be equal. 13 | # 14 | # Information about the commits which are being pushed is supplied as lines to 15 | # the standard input in the form: 16 | # 17 | # 18 | # 19 | # This sample shows how to prevent push of commits where the log message starts 20 | # with "WIP" (work in progress). 21 | 22 | remote="$1" 23 | url="$2" 24 | 25 | z40=0000000000000000000000000000000000000000 26 | 27 | while read local_ref local_sha remote_ref remote_sha 28 | do 29 | if [ "$local_sha" = $z40 ] 30 | then 31 | # Handle delete 32 | : 33 | else 34 | if [ "$remote_sha" = $z40 ] 35 | then 36 | # New branch, examine all commits 37 | range="$local_sha" 38 | else 39 | # Update to existing branch, examine new commits 40 | range="$remote_sha..$local_sha" 41 | fi 42 | 43 | # Check for WIP commit 44 | commit=`git rev-list -n 1 --grep '^WIP' "$range"` 45 | if [ -n "$commit" ] 46 | then 47 | echo >&2 "Found WIP commit in $local_ref, not pushing" 48 | exit 1 49 | fi 50 | fi 51 | done 52 | 53 | exit 0 54 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/pre-rebase.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) 2006, 2008 Junio C Hamano 4 | # 5 | # The "pre-rebase" hook is run just before "git rebase" starts doing 6 | # its job, and can prevent the command from running by exiting with 7 | # non-zero status. 8 | # 9 | # The hook is called with the following parameters: 10 | # 11 | # $1 -- the upstream the series was forked from. 12 | # $2 -- the branch being rebased (or empty when rebasing the current branch). 13 | # 14 | # This sample shows how to prevent topic branches that are already 15 | # merged to 'next' branch from getting rebased, because allowing it 16 | # would result in rebasing already published history. 17 | 18 | publish=next 19 | basebranch="$1" 20 | if test "$#" = 2 21 | then 22 | topic="refs/heads/$2" 23 | else 24 | topic=`git symbolic-ref HEAD` || 25 | exit 0 ;# we do not interrupt rebasing detached HEAD 26 | fi 27 | 28 | case "$topic" in 29 | refs/heads/??/*) 30 | ;; 31 | *) 32 | exit 0 ;# we do not interrupt others. 33 | ;; 34 | esac 35 | 36 | # Now we are dealing with a topic branch being rebased 37 | # on top of master. Is it OK to rebase it? 38 | 39 | # Does the topic really exist? 40 | git show-ref -q "$topic" || { 41 | echo >&2 "No such branch $topic" 42 | exit 1 43 | } 44 | 45 | # Is topic fully merged to master? 46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"` 47 | if test -z "$not_in_master" 48 | then 49 | echo >&2 "$topic is fully merged to master; better remove it." 50 | exit 1 ;# we could allow it, but there is no point. 51 | fi 52 | 53 | # Is topic ever merged to next? If so you should not be rebasing it. 54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` 55 | only_next_2=`git rev-list ^master ${publish} | sort` 56 | if test "$only_next_1" = "$only_next_2" 57 | then 58 | not_in_topic=`git rev-list "^$topic" master` 59 | if test -z "$not_in_topic" 60 | then 61 | echo >&2 "$topic is already up to date with master" 62 | exit 1 ;# we could allow it, but there is no point. 63 | else 64 | exit 0 65 | fi 66 | else 67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` 68 | /usr/bin/perl -e ' 69 | my $topic = $ARGV[0]; 70 | my $msg = "* $topic has commits already merged to public branch:\n"; 71 | my (%not_in_next) = map { 72 | /^([0-9a-f]+) /; 73 | ($1 => 1); 74 | } split(/\n/, $ARGV[1]); 75 | for my $elem (map { 76 | /^([0-9a-f]+) (.*)$/; 77 | [$1 => $2]; 78 | } split(/\n/, $ARGV[2])) { 79 | if (!exists $not_in_next{$elem->[0]}) { 80 | if ($msg) { 81 | print STDERR $msg; 82 | undef $msg; 83 | } 84 | print STDERR " $elem->[1]\n"; 85 | } 86 | } 87 | ' "$topic" "$not_in_next" "$not_in_master" 88 | exit 1 89 | fi 90 | 91 | <<\DOC_END 92 | 93 | This sample hook safeguards topic branches that have been 94 | published from being rewound. 95 | 96 | The workflow assumed here is: 97 | 98 | * Once a topic branch forks from "master", "master" is never 99 | merged into it again (either directly or indirectly). 100 | 101 | * Once a topic branch is fully cooked and merged into "master", 102 | it is deleted. If you need to build on top of it to correct 103 | earlier mistakes, a new topic branch is created by forking at 104 | the tip of the "master". This is not strictly necessary, but 105 | it makes it easier to keep your history simple. 106 | 107 | * Whenever you need to test or publish your changes to topic 108 | branches, merge them into "next" branch. 109 | 110 | The script, being an example, hardcodes the publish branch name 111 | to be "next", but it is trivial to make it configurable via 112 | $GIT_DIR/config mechanism. 113 | 114 | With this workflow, you would want to know: 115 | 116 | (1) ... if a topic branch has ever been merged to "next". Young 117 | topic branches can have stupid mistakes you would rather 118 | clean up before publishing, and things that have not been 119 | merged into other branches can be easily rebased without 120 | affecting other people. But once it is published, you would 121 | not want to rewind it. 122 | 123 | (2) ... if a topic branch has been fully merged to "master". 124 | Then you can delete it. More importantly, you should not 125 | build on top of it -- other people may already want to 126 | change things related to the topic as patches against your 127 | "master", so if you need further changes, it is better to 128 | fork the topic (perhaps with the same name) afresh from the 129 | tip of "master". 130 | 131 | Let's look at this example: 132 | 133 | o---o---o---o---o---o---o---o---o---o "next" 134 | / / / / 135 | / a---a---b A / / 136 | / / / / 137 | / / c---c---c---c B / 138 | / / / \ / 139 | / / / b---b C \ / 140 | / / / / \ / 141 | ---o---o---o---o---o---o---o---o---o---o---o "master" 142 | 143 | 144 | A, B and C are topic branches. 145 | 146 | * A has one fix since it was merged up to "next". 147 | 148 | * B has finished. It has been fully merged up to "master" and "next", 149 | and is ready to be deleted. 150 | 151 | * C has not merged to "next" at all. 152 | 153 | We would want to allow C to be rebased, refuse A, and encourage 154 | B to be deleted. 155 | 156 | To compute (1): 157 | 158 | git rev-list ^master ^topic next 159 | git rev-list ^master next 160 | 161 | if these match, topic has not merged in next at all. 162 | 163 | To compute (2): 164 | 165 | git rev-list master..topic 166 | 167 | if this is empty, it is fully merged to "master". 168 | 169 | DOC_END 170 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/pre-receive.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to make use of push options. 4 | # The example simply echoes all push options that start with 'echoback=' 5 | # and rejects all pushes when the "reject" push option is used. 6 | # 7 | # To enable this hook, rename this file to "pre-receive". 8 | 9 | if test -n "$GIT_PUSH_OPTION_COUNT" 10 | then 11 | i=0 12 | while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" 13 | do 14 | eval "value=\$GIT_PUSH_OPTION_$i" 15 | case "$value" in 16 | echoback=*) 17 | echo "echo from the pre-receive-hook: ${value#*=}" >&2 18 | ;; 19 | reject) 20 | exit 1 21 | esac 22 | i=$((i + 1)) 23 | done 24 | fi 25 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/prepare-commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare the commit log message. 4 | # Called by "git commit" with the name of the file that has the 5 | # commit message, followed by the description of the commit 6 | # message's source. The hook's purpose is to edit the commit 7 | # message file. If the hook fails with a non-zero status, 8 | # the commit is aborted. 9 | # 10 | # To enable this hook, rename this file to "prepare-commit-msg". 11 | 12 | # This hook includes three examples. The first one removes the 13 | # "# Please enter the commit message..." help message. 14 | # 15 | # The second includes the output of "git diff --name-status -r" 16 | # into the message, just before the "git status" output. It is 17 | # commented because it doesn't cope with --amend or with squashed 18 | # commits. 19 | # 20 | # The third example adds a Signed-off-by line to the message, that can 21 | # still be edited. This is rarely a good idea. 22 | 23 | COMMIT_MSG_FILE=$1 24 | COMMIT_SOURCE=$2 25 | SHA1=$3 26 | 27 | /usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" 28 | 29 | # case "$COMMIT_SOURCE,$SHA1" in 30 | # ,|template,) 31 | # /usr/bin/perl -i.bak -pe ' 32 | # print "\n" . `git diff --cached --name-status -r` 33 | # if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; 34 | # *) ;; 35 | # esac 36 | 37 | # SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 38 | # git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" 39 | # if test -z "$COMMIT_SOURCE" 40 | # then 41 | # /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" 42 | # fi 43 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/hooks/update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to block unannotated tags from entering. 4 | # Called by "git receive-pack" with arguments: refname sha1-old sha1-new 5 | # 6 | # To enable this hook, rename this file to "update". 7 | # 8 | # Config 9 | # ------ 10 | # hooks.allowunannotated 11 | # This boolean sets whether unannotated tags will be allowed into the 12 | # repository. By default they won't be. 13 | # hooks.allowdeletetag 14 | # This boolean sets whether deleting tags will be allowed in the 15 | # repository. By default they won't be. 16 | # hooks.allowmodifytag 17 | # This boolean sets whether a tag may be modified after creation. By default 18 | # it won't be. 19 | # hooks.allowdeletebranch 20 | # This boolean sets whether deleting branches will be allowed in the 21 | # repository. By default they won't be. 22 | # hooks.denycreatebranch 23 | # This boolean sets whether remotely creating branches will be denied 24 | # in the repository. By default this is allowed. 25 | # 26 | 27 | # --- Command line 28 | refname="$1" 29 | oldrev="$2" 30 | newrev="$3" 31 | 32 | # --- Safety check 33 | if [ -z "$GIT_DIR" ]; then 34 | echo "Don't run this script from the command line." >&2 35 | echo " (if you want, you could supply GIT_DIR then run" >&2 36 | echo " $0 )" >&2 37 | exit 1 38 | fi 39 | 40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then 41 | echo "usage: $0 " >&2 42 | exit 1 43 | fi 44 | 45 | # --- Config 46 | allowunannotated=$(git config --bool hooks.allowunannotated) 47 | allowdeletebranch=$(git config --bool hooks.allowdeletebranch) 48 | denycreatebranch=$(git config --bool hooks.denycreatebranch) 49 | allowdeletetag=$(git config --bool hooks.allowdeletetag) 50 | allowmodifytag=$(git config --bool hooks.allowmodifytag) 51 | 52 | # check for no description 53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") 54 | case "$projectdesc" in 55 | "Unnamed repository"* | "") 56 | echo "*** Project description file hasn't been set" >&2 57 | exit 1 58 | ;; 59 | esac 60 | 61 | # --- Check types 62 | # if $newrev is 0000...0000, it's a commit to delete a ref. 63 | zero="0000000000000000000000000000000000000000" 64 | if [ "$newrev" = "$zero" ]; then 65 | newrev_type=delete 66 | else 67 | newrev_type=$(git cat-file -t $newrev) 68 | fi 69 | 70 | case "$refname","$newrev_type" in 71 | refs/tags/*,commit) 72 | # un-annotated tag 73 | short_refname=${refname##refs/tags/} 74 | if [ "$allowunannotated" != "true" ]; then 75 | echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 77 | exit 1 78 | fi 79 | ;; 80 | refs/tags/*,delete) 81 | # delete tag 82 | if [ "$allowdeletetag" != "true" ]; then 83 | echo "*** Deleting a tag is not allowed in this repository" >&2 84 | exit 1 85 | fi 86 | ;; 87 | refs/tags/*,tag) 88 | # annotated tag 89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 90 | then 91 | echo "*** Tag '$refname' already exists." >&2 92 | echo "*** Modifying a tag is not allowed in this repository." >&2 93 | exit 1 94 | fi 95 | ;; 96 | refs/heads/*,commit) 97 | # branch 98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then 99 | echo "*** Creating a branch is not allowed in this repository" >&2 100 | exit 1 101 | fi 102 | ;; 103 | refs/heads/*,delete) 104 | # delete branch 105 | if [ "$allowdeletebranch" != "true" ]; then 106 | echo "*** Deleting a branch is not allowed in this repository" >&2 107 | exit 1 108 | fi 109 | ;; 110 | refs/remotes/*,commit) 111 | # tracking branch 112 | ;; 113 | refs/remotes/*,delete) 114 | # delete tracking branch 115 | if [ "$allowdeletebranch" != "true" ]; then 116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 117 | exit 1 118 | fi 119 | ;; 120 | *) 121 | # Anything else (is there anything else?) 122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 123 | exit 1 124 | ;; 125 | esac 126 | 127 | # --- Finished 128 | exit 0 129 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/index: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prisma-labs/dripip/88a3d78a2358394befb8921d66cc857e548911b1/tests/__fixtures/git-repo-dripip-system-tests/index -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/info/exclude: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | # *~ 7 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/objects/pack/pack-d07ed83ba7cb14007fba9bbba58713e09c775de1.idx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prisma-labs/dripip/88a3d78a2358394befb8921d66cc857e548911b1/tests/__fixtures/git-repo-dripip-system-tests/objects/pack/pack-d07ed83ba7cb14007fba9bbba58713e09c775de1.idx -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/objects/pack/pack-d07ed83ba7cb14007fba9bbba58713e09c775de1.pack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prisma-labs/dripip/88a3d78a2358394befb8921d66cc857e548911b1/tests/__fixtures/git-repo-dripip-system-tests/objects/pack/pack-d07ed83ba7cb14007fba9bbba58713e09c775de1.pack -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/refs/heads/main: -------------------------------------------------------------------------------- 1 | b5956b630d3231730da64992a734870a9487fa7e 2 | -------------------------------------------------------------------------------- /tests/__fixtures/git-repo-dripip-system-tests/refs/remotes/origin/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/remotes/origin/main 2 | -------------------------------------------------------------------------------- /tests/__lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as proc from '../../src/lib/proc' 2 | import { errorFromMaybeError } from '../../src/lib/utils' 3 | import * as WS from '../__lib/workspace' 4 | import { Octokit } from '@octokit/rest' 5 | import { format } from 'util' 6 | 7 | /** 8 | * Reset the environment before each test, allowing each test to modify it to 9 | * its needs. 10 | */ 11 | export const resetEnvironmentBeforeEachTest = () => { 12 | const originalEnvironment = Object.assign({}, process.env) 13 | beforeEach(() => { 14 | process.env = Object.assign({}, originalEnvironment) 15 | }) 16 | } 17 | 18 | /** 19 | * Helper for creating a specialized workspace 20 | */ 21 | export const createContext = (name: string) => { 22 | const ws = addOctokitToWorkspace( 23 | addDripipToWorkspace( 24 | WS.createWorkspace({ 25 | name: name, 26 | repo: `git@github.com:prisma-labs/dripip-system-tests.git`, 27 | cache: { 28 | version: `8`, 29 | }, 30 | }) 31 | ) 32 | ) 33 | 34 | resetEnvironmentBeforeEachTest() 35 | 36 | beforeEach(async () => { 37 | await Promise.all([ 38 | ws.fs.writeAsync(`package.json`, { 39 | name: `test-app`, 40 | license: `MIT`, 41 | }), 42 | ]) 43 | await ws.git.add(`package.json`) 44 | await ws.git.commit(`chore: add package.json`) 45 | }) 46 | 47 | return ws 48 | } 49 | 50 | // any https://github.com/octokit/rest.js/issues/1624 51 | export function addOctokitToWorkspace(ws: T): T & { octokit: any } { 52 | beforeAll(() => { 53 | // @ts-ignore 54 | ws.octokit = new Octokit({ 55 | auth: process.env.GITHUB_TOKEN, 56 | }) 57 | }) 58 | 59 | // @ts-ignore 60 | return ws 61 | } 62 | 63 | /** 64 | * Add the dripip cli to the workspace, ready to use. 65 | */ 66 | export function addDripipToWorkspace( 67 | ws: T 68 | ): T & { 69 | dripip: ReturnType 70 | dripipRunString: string 71 | } { 72 | beforeAll(() => { 73 | // @ts-ignore 74 | ws.dripip = createDripipRunner(ws.dir.path, ws.dir.pathRelativeToSource) 75 | // @ts-ignore 76 | ws.dripipRunString = createDripipRunString(ws.dir.pathRelativeToSource) 77 | }) 78 | 79 | // @ts-ignore 80 | return ws 81 | } 82 | 83 | /** 84 | * Certain parts of dripip output are highly dynamic, making it difficult to 85 | * snapshot. This function strips out those dynamic parts. 86 | */ 87 | function sanitizeResultForSnap(result: RunDripipResult): void { 88 | const shortSHAPattern = /\(.{7}\)/g 89 | result.stderr = result.stderr.replace(shortSHAPattern, `(__SHORT_SHA__)`) 90 | result.stdout = result.stdout!.replace(shortSHAPattern, `(__SHORT_SHA__)`) 91 | } 92 | 93 | export type RunDripipResult = Omit & { 94 | stderr: string 95 | } 96 | 97 | type DripipRunnerOptions = proc.RunOptions & { 98 | /** 99 | * Return the raw proc result 100 | * 101 | * @defaultValue false 102 | */ 103 | raw?: boolean 104 | /** 105 | * Work with stderr instead of stdout. 106 | */ 107 | error?: boolean 108 | replacements?: [RegExp, string][] 109 | } 110 | 111 | function createDripipRunString(pathToProject: string) { 112 | return `${pathToProject}/node_modules/.bin/ts-node --project ${pathToProject}/tsconfig.json ${pathToProject}/src/cli/main` 113 | } 114 | 115 | function createDripipRunner(cwd: string, pathToProject: string) { 116 | // prettier-ignore 117 | return (command: string, optsLocal?: DripipRunnerOptions): Promise | RunDripipResult> => { 118 | const opts:any = { ...optsLocal, cwd } 119 | 120 | //@ts-ignore 121 | // prettier-ignore 122 | const runString = `${createDripipRunString(pathToProject)} ${command} --json` 123 | return proc.run(runString, opts).then(result => { 124 | if (opts.raw === true) { 125 | // TODO not used/helpful...? 126 | sanitizeResultForSnap(result as RunDripipResult) 127 | return result as RunDripipResult // force TS to ignore the stderr: null possibility 128 | } 129 | 130 | const content = opts.error === true ? result.stderr ?? `` : result.stdout ?? `` 131 | let contentSanitized = content.replace(/"sha": *"[^"]+"/g, `"sha": "__dynamic_content__"`) 132 | 133 | opts.replacements?.forEach(([pattern, replacement]:any) => { 134 | contentSanitized = content.replace(pattern, replacement) 135 | }) 136 | 137 | try { 138 | // TODO typed response... 139 | return JSON.parse(contentSanitized) as Record 140 | } catch (maybeError) { 141 | const e = errorFromMaybeError(maybeError) 142 | throw new Error( 143 | `Something went wrong while trying to JSON parse the dripip cli stdout:\n\n${ 144 | e.stack 145 | }\n\nThe underlying cli result was:\n\n${format(result)}` 146 | ) 147 | } 148 | }); 149 | }; 150 | } 151 | 152 | export { createDripipRunner } 153 | -------------------------------------------------------------------------------- /tests/__lib/workspace.ts: -------------------------------------------------------------------------------- 1 | import { gitInitRepo, Simple } from '../../src/lib/git' 2 | import { createRunner } from '../../src/lib/proc' 3 | import * as jetpack from 'fs-jetpack' 4 | import * as Path from 'path' 5 | import createGit from 'simple-git/promise' 6 | 7 | type Workspace = { 8 | dir: { path: string; pathRelativeToSource: string; cacheHit: boolean } 9 | run: ReturnType 10 | fs: ReturnType 11 | git: Simple 12 | } 13 | 14 | type Options = { 15 | name: string 16 | repo?: string 17 | git?: boolean 18 | cache?: { 19 | on?: boolean 20 | version?: string 21 | includeLock?: boolean 22 | } 23 | } 24 | 25 | /** 26 | * Workspace creator coupled to jest. 27 | */ 28 | export function createWorkspace(opts: Options): Workspace { 29 | const ws = {} as Workspace 30 | // TODO track the git commit started on, then reset hard to it after each test 31 | 32 | beforeAll(async () => { 33 | Object.assign(ws, await doCreateWorkspace(opts)) 34 | // In case of a cache hit where we manually debugged the directory or 35 | // somehow else it changed. 36 | }) 37 | 38 | beforeEach(async () => { 39 | await ws.fs.removeAsync(ws.dir.path) 40 | await ws.fs.dirAsync(ws.dir.path) 41 | if (opts.git !== false) { 42 | if (opts.repo) { 43 | await ws.git.clone(opts.repo, ws.dir.path) 44 | } else { 45 | await gitInitRepo(ws.git) 46 | } 47 | } 48 | }) 49 | 50 | return ws 51 | } 52 | 53 | // TODO if errors occur during workspace creation then the cache will be hit 54 | // next time but actual contents not suitable for use. Make the system more robust! 55 | 56 | /** 57 | * Create a generic workspace to perform work in. 58 | */ 59 | async function doCreateWorkspace(optsGiven: Options): Promise { 60 | // 61 | // Setup Dir 62 | // 63 | const opts = { 64 | git: true, 65 | ...optsGiven, 66 | } 67 | 68 | let cacheKey: string 69 | if (opts.cache?.on) { 70 | const yarnLockHash = 71 | opts.cache?.includeLock === true 72 | ? jetpack.inspect(`yarn.lock`, { 73 | checksum: `md5`, 74 | })!.md5 75 | : `off` 76 | const ver = `8` 77 | const testVer = opts.cache?.version ?? `off` 78 | const currentGitBranch = (await createGit().raw([`rev-parse`, `--abbrev-ref`, `HEAD`])).trim() 79 | cacheKey = `v${ver}-yarnlock-${yarnLockHash}-gitbranch-${currentGitBranch}-testv${testVer}` 80 | } else { 81 | cacheKey = Math.random().toString().slice(2) 82 | } 83 | 84 | const projectName = require(`../../package.json`).name 85 | const dir = {} as Workspace[`dir`] 86 | dir.path = `/tmp/${projectName}-integration-test-project-bases/${opts.name}-${cacheKey}` 87 | 88 | dir.pathRelativeToSource = `../` + Path.relative(dir.path, Path.join(__dirname, `../..`)) 89 | 90 | if ((await jetpack.existsAsync(dir.path)) !== false) { 91 | dir.cacheHit = true 92 | } else { 93 | dir.cacheHit = false 94 | await jetpack.dirAsync(dir.path) 95 | } 96 | 97 | console.log(`cache %s for %s`, dir.cacheHit ? `hit` : `miss`, dir.path) 98 | const ws: any = {} 99 | 100 | // 101 | // Setup Tools 102 | // 103 | ws.dir = dir 104 | ws.fs = jetpack.dir(dir.path) 105 | ws.run = createRunner(dir.path) 106 | if (opts.git) { 107 | ws.git = createGit(dir.path) 108 | // 109 | // Setup Project (if needed, cacheable) 110 | // 111 | if (!dir.cacheHit) { 112 | if (opts.repo) { 113 | await ws.git.clone(opts.repo, dir.path) 114 | } else { 115 | await gitInitRepo(ws.git) 116 | } 117 | } 118 | } 119 | 120 | // 121 | // Return a workspace 122 | // 123 | return ws 124 | } 125 | -------------------------------------------------------------------------------- /tests/__providers__/fixture.ts: -------------------------------------------------------------------------------- 1 | import { provider } from 'konn' 2 | import { Providers } from 'konn/providers' 3 | import * as Path from 'path' 4 | 5 | type Needs = Providers.Dir.Contributes 6 | 7 | export const fixture = (params: { 8 | use: Use 9 | /** 10 | * A relative path. The path will be relative to the CWD of the Konn Dir Provider. 11 | * 12 | * The path will be used as the directory to copy the fixture into. 13 | * 14 | * The directory will be created if it does not exist. 15 | * 16 | * The directory will be deleted if it already exists. 17 | */ 18 | into: string 19 | }) => 20 | provider() 21 | .name(`fixture`) 22 | .before((ctx) => { 23 | const fixturePath = Path.join(__dirname, `../__fixtures`, params.use) 24 | if (!ctx.fs.exists(fixturePath)) { 25 | throw new Error(`Fixture "${params.use}" is not located at ${fixturePath}.`) 26 | } 27 | const destinationDir = ctx.fs.path(params.into) 28 | ctx.fs.remove(ctx.fs.path(params.into)) 29 | ctx.fs.copy(fixturePath, destinationDir, { overwrite: true }) 30 | }) 31 | .done() 32 | -------------------------------------------------------------------------------- /tests/__providers__/git.ts: -------------------------------------------------------------------------------- 1 | import * as nodeFs from 'fs' 2 | import isomorphicGit from 'isomorphic-git' 3 | import { provider } from 'konn' 4 | import { Providers } from 'konn/providers' 5 | 6 | type Needs = Providers.Dir.Contributes 7 | 8 | export const git = () => 9 | provider() 10 | .name(`git`) 11 | .before((ctx) => { 12 | const fs = nodeFs 13 | const dir = ctx.fs.cwd() 14 | const git = isomorphicGit 15 | 16 | return { 17 | git: { 18 | isomorphic: git, 19 | log: () => { 20 | return git.log({ fs, dir }) 21 | }, 22 | checkout: (params: { ref: string }) => { 23 | return git.checkout({ fs, dir, ...params }) 24 | }, 25 | commit: (message: string) => { 26 | return git.commit({ fs, dir, message, author: { name: `labs` } }) 27 | }, 28 | tag: (ref: string) => { 29 | return git.annotatedTag({ 30 | fs, 31 | dir, 32 | ref, 33 | message: ref, 34 | tagger: { 35 | name: `labs`, 36 | }, 37 | }) 38 | }, 39 | branch: (params: { ref: string }) => { 40 | return git.branch({ fs, dir, checkout: true, ...params }) 41 | }, 42 | // https://github.com/isomorphic-git/isomorphic-git/issues/129#issuecomment-390884874 43 | hardReset: async (params: { ref: string; branch: string }) => { 44 | const { ref, branch } = params 45 | const re = /head~[0-9]+/ 46 | const m = ref.match(re) 47 | if (!m) throw new Error(`Wrong ref ${ref}`) 48 | // guaranteed by regex match 49 | // eslint-disable-next-line 50 | const count = +m[1]! 51 | const commits = await git.log({ fs, dir, depth: count + 1 }) 52 | const lastCommit = commits.pop() 53 | if (!lastCommit) throw new Error(`No commits were found.`) 54 | ctx.fs.write(dir + `/.git/refs/heads/${branch}`, lastCommit.oid) 55 | ctx.fs.remove(dir + `/.git/index`) 56 | }, 57 | }, 58 | } 59 | }) 60 | .done() 61 | -------------------------------------------------------------------------------- /tests/_setup.ts: -------------------------------------------------------------------------------- 1 | // make system tests deterministic. Without this they would react to users' 2 | // machines' ~/.npmrc file contents. 3 | process.env.NPM_TOKEN = `foobar` 4 | -------------------------------------------------------------------------------- /tests/e2e/preview.spec.todo.ts: -------------------------------------------------------------------------------- 1 | // import { createPreRelease, renderStyledVersion } from '../../src/lib/semver' 2 | // import { createDripipRunner } from '../__lib/helpers' 3 | 4 | // const ctx = TestContext.compose(TestContext.all, (ctx) => { 5 | // return { 6 | // dripip: createDripipRunner(ctx.dir, ctx.pathRelativeToSource), 7 | // } 8 | // }) 9 | 10 | // beforeEach(async () => { 11 | // ctx.fs.copy(ctx.fixture(`git`), ctx.fs.path(`.git`)) 12 | // }) 13 | 14 | // // todo --identifier flag 15 | // it.skip(`can publish a preview release`, async () => { 16 | // const id = Date.now() 17 | // const branchName = `e2e-preview-${id}` 18 | // const preReleaseIdentifier = `ep${id}` 19 | // await ctx.branch(branchName) 20 | // await ctx.commit(`feat: past`) 21 | // await ctx.tag(renderStyledVersion(createPreRelease(0, 0, 0, preReleaseIdentifier, 1))) 22 | // await ctx.commit(`feat: foo`) 23 | // await ctx.commit(`fix: bar`) 24 | // await ctx.commit(`chore: qux`) 25 | // const result = ctx.dripip(`preview --json --trunk ${id} --identifier ${preReleaseIdentifier}`) 26 | // }) 27 | -------------------------------------------------------------------------------- /tests/git.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Git from '../src/lib/git' 2 | import { fixture } from './__providers__/fixture' 3 | import { git } from './__providers__/git' 4 | import { konn, providers } from 'konn' 5 | import createGit from 'simple-git/promise' 6 | 7 | const ctx = konn() 8 | .useBeforeAll(providers.dir()) 9 | .useBeforeAll(git()) 10 | .useBeforeEach(fixture({ use: `git-init`, into: `.git` })) 11 | .done() 12 | 13 | describe(`streamLog`, () => { 14 | it(`streams commits from newest to oldest`, async () => { 15 | await ctx.git.commit(`initial commit`) 16 | const git = createGit(ctx.fs.cwd()) 17 | await Git.gitCreateEmptyCommit(git, `work 1`) 18 | await ctx.git.tag(`tag-1`) 19 | await Git.gitCreateEmptyCommit(git, `work 2`) 20 | await ctx.git.tag(`tag-2`) 21 | await Git.gitCreateEmptyCommit(git, `work 3`) 22 | await ctx.git.tag(`tag-3a`) 23 | await ctx.git.tag(`tag-3b`) 24 | // console.log(await ctx.git.log()) 25 | const entries = [] 26 | for await (const entry of Git.streamLog({ cwd: ctx.fs.cwd() })) { 27 | entries.push(entry) 28 | } 29 | entries.forEach((e) => (e.sha = `__sha__`)) 30 | expect(entries).toMatchInlineSnapshot(` 31 | Array [ 32 | Object { 33 | "message": "work 3", 34 | "sha": "__sha__", 35 | "tags": Array [ 36 | "tag-3b", 37 | "tag-3a", 38 | ], 39 | }, 40 | Object { 41 | "message": "work 2", 42 | "sha": "__sha__", 43 | "tags": Array [ 44 | "tag-2", 45 | ], 46 | }, 47 | Object { 48 | "message": "work 1", 49 | "sha": "__sha__", 50 | "tags": Array [ 51 | "tag-1", 52 | ], 53 | }, 54 | Object { 55 | "message": "initial commit", 56 | "sha": "__sha__", 57 | "tags": Array [], 58 | }, 59 | ] 60 | `) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist-cjs", 5 | "module": "commonjs", 6 | "rootDir": "src", 7 | "sourceMap": true, 8 | "declaration": true, 9 | "declarationMap": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["src/**/*.spec.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist-esm", 5 | "module": "ES2015", 6 | "moduleResolution": "node", 7 | "rootDir": "src", 8 | "sourceMap": true, 9 | "declaration": true, 10 | "declarationMap": true 11 | }, 12 | "include": ["src"], 13 | "exclude": ["src/**/*.spec.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node14/tsconfig.json", 3 | "ts-node": { 4 | "swc": true, 5 | "require": ["tsconfig-paths/register"], 6 | "compilerOptions": { 7 | // Sometimes projects (e.g. Nextjs) will want code to emit ESM but ts-node will not work with that. 8 | "module": "CommonJS" 9 | } 10 | }, 11 | "compilerOptions": { 12 | // Make the compiler stricter, catch more errors 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUncheckedIndexedAccess": true, 16 | "noImplicitOverride": true, 17 | // We handle these with ESLint: 18 | // "noUnusedLocals": false, 19 | // "noUnusedParameters": false, 20 | 21 | // Output 22 | "importHelpers": true, 23 | 24 | // DX 25 | "incremental": true, 26 | "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo", 27 | "noErrorTruncation": true, 28 | "baseUrl": ".", 29 | "paths": { 30 | "~/*": ["./src/*"] 31 | }, 32 | 33 | // Transformer Plugins made possible by https://github.com/nonara/ts-patch 34 | "plugins": [ 35 | // https://github.com/LeDDGroup/typescript-transform-paths 36 | { "transform": "typescript-transform-paths" }, 37 | { "transform": "typescript-transform-paths", "afterDeclarations": true } 38 | ] 39 | }, 40 | "include": ["src", "tests", "scripts", "jest.*"], 41 | // Prevent unwanted things like auto-import from built modules 42 | "exclude": ["dist-*"], 43 | "plugins": [ 44 | { 45 | "name": "typescript-snapshots-plugin" 46 | } 47 | ] 48 | } 49 | --------------------------------------------------------------------------------