diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 000000000..db6c14343 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,133 @@ +name: Bump version and release + +# Bumps the version, rebuilds dist/, commits and tags on master, creates the +# GitHub Release, then dispatches publish.yml on the new tag. +# +# Everything here uses GITHUB_TOKEN, and events it creates (push, tag, +# release) start no workflows. workflow_dispatch is the exception, so +# publish.yml is dispatched explicitly. It then runs as its own top-level +# workflow, which is the filename npm trusted publishing checks. + +on: + workflow_dispatch: + inputs: + release-type: + description: Version increment (ignored when `version` is set) + type: choice + options: [patch, minor, major, prerelease] + default: patch + version: + description: Explicit version, e.g. 14.2.0 or 14.2.0-beta.0 (optional) + type: string + default: '' + preid: + description: Pre-release identifier for `prerelease` + type: string + default: beta + dry-run: + description: Bump, test and build, but don't push, release or publish + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: bump-version + cancel-in-progress: false + +jobs: + bump: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + + - run: npm ci + + - name: Bump version + id: bump + env: + RELEASE_TYPE: ${{ inputs.release-type }} + VERSION: ${{ inputs.version }} + PREID: ${{ inputs.preid }} + run: | + if [ -n "$VERSION" ]; then + npm version "${VERSION#v}" --no-git-tag-version + elif [ "$RELEASE_TYPE" = prerelease ]; then + npm version prerelease --preid "$PREID" --no-git-tag-version + else + npm version "$RELEASE_TYPE" --no-git-tag-version + fi + + name="$(node -p 'require("./package.json").name')" + version="$(node -p 'require("./package.json").version')" + tag="v$version" + + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + echo "::error::Tag $tag already exists." + exit 1 + fi + if [ "$(npm view "$name@$version" version 2>/dev/null)" = "$version" ]; then + echo "::error::$name@$version is already published on npm." + exit 1 + fi + + echo "Bumped to $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + # CI is skipped for the bump commit, so test here. + - name: Test + run: npm test + + - name: Build dist/ + run: npm run build + + - name: Commit and tag + env: + VERSION: ${{ steps.bump.outputs.version }} + TAG: ${{ steps.bump.outputs.tag }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -f dist/ package.json package-lock.json + git commit -m "chore: release $VERSION [skip ci]" + git tag -a "$TAG" -m "$TAG" + git show --stat HEAD + + - name: Push commit and tag + if: ${{ !inputs.dry-run }} + env: + TAG: ${{ steps.bump.outputs.tag }} + run: git push --atomic origin HEAD:refs/heads/master "refs/tags/$TAG" + + - name: Create GitHub Release + if: ${{ !inputs.dry-run }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.bump.outputs.version }} + TAG: ${{ steps.bump.outputs.tag }} + run: | + args=(--verify-tag --title "$TAG" --generate-notes) + case "$VERSION" in *-*) args+=(--prerelease) ;; esac + + gh release create "$TAG" "${args[@]}" + + - name: Dispatch publish.yml + if: ${{ !inputs.dry-run }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.bump.outputs.tag }} + run: | + gh workflow run publish.yml --ref "$TAG" + echo "Dispatched publish.yml on $TAG: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/publish.yml" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..05e4daedb --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,109 @@ +name: Publish to npm + +# Uses npm trusted publishing (OIDC): no npm token is stored in this repo. +# The trusted publisher on npmjs.com must name this file (publish.yml) and +# the `npm` environment. npm checks the top-level workflow, so keep this a +# standalone workflow (bump-version.yml dispatches it) rather than turning +# it into a reusable workflow_call target. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + dry-run: + description: Run every step but call `npm publish --dry-run` + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Use Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Ensure npm >= 11.5.1 (trusted publishing) + run: | + current="$(npm --version)" + if [ "$(printf '%s\n' 11.5.1 "$current" | sort -V | head -n1)" != 11.5.1 ]; then + npm install -g npm@11 + fi + npm --version + + - name: Check version and tag + id: version + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + name="$(node -p 'require("./package.json").name')" + version="$(node -p 'require("./package.json").version')" + + if [ "$EVENT_NAME" = release ]; then + tag="$RELEASE_TAG" + elif [ "$REF_TYPE" = tag ]; then + tag="$REF_NAME" + elif [ "$REF_NAME" = "$DEFAULT_BRANCH" ]; then + tag="" + else + echo "::error::Run this workflow from a release tag or from $DEFAULT_BRANCH, not '$REF_NAME'." + exit 1 + fi + + if [ -n "$tag" ] && [ "${tag#v}" != "$version" ]; then + echo "::error::Tag '$tag' does not match package.json version '$version'." + exit 1 + fi + + if [ "$(npm view "$name@$version" version 2>/dev/null)" = "$version" ]; then + echo "::error::$name@$version is already published on npm. Bump the version first." + exit 1 + fi + + case "$version" in + *-*) dist_tag=next ;; + *) dist_tag=latest ;; + esac + + echo "Publishing $name@$version with dist-tag '$dist_tag'" + echo "dist-tag=$dist_tag" >> "$GITHUB_OUTPUT" + + - run: npm ci + + - name: Test + run: npm test + + - name: Build dist/ + run: | + npm run build + if [ -n "$(git status --porcelain -- dist/)" ]; then + echo "::warning::The committed dist/ was stale; publishing the freshly built files." + fi + + - name: Publish + env: + DIST_TAG: ${{ steps.version.outputs.dist-tag }} + DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }} + run: npm publish --access public --provenance --tag "$DIST_TAG" $DRY_RUN diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f75aed87..590e6d0e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,8 @@ 2. Run `./benchmark/benchmark.mjs`, make sure that performance not degraded. 3. DON'T include auto-generated browser files to commit. +Maintainers: see [RELEASING.md](RELEASING.md) for publishing to npm. + ### Other things: 1. Prefer [gitter](https://gitter.im/markdown-it/markdown-it) for short "questions". diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..5cf728fb2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,34 @@ +# Releasing `@hackmd/markdown-it` + +Releases are published from GitHub Actions with npm trusted publishing +(OIDC), so no npm token is needed. Don't run `npm publish` locally. + +## Run the bump + +In the Actions tab, run **Bump version and release** on `master`. Choose a +**release-type** (`patch`, `minor`, `major` or `prerelease` with **preid**), +or give an explicit **version**. Tick **dry-run** to stop before pushing. + +The run tests, rebuilds `dist/`, commits `chore: release [skip ci]`, +pushes the commit and tag `v` to `master`, creates the GitHub Release +with auto-generated notes, and dispatches **Publish to npm** (`publish.yml`) +on the tag. Approve its deployment to the `npm` environment to publish. + +## npm Trusted Publisher + +On npmjs.com, under the package's **Settings > Trusted Publisher**, select +GitHub Actions and enter: + +| Field | Value | +| -------------------- | -------------------- | +| Organization or user | `hackmdio` | +| Repository | `markdown-it` | +| Workflow filename | `publish.yml` | +| Environment name | `npm` | +| Allowed actions | tick **npm publish** | + +## `npm` environment + +Create an environment named `npm` under the repository's +**Settings > Environments**, ideally with required reviewers. The publish job +runs in it, and its name must match the one entered on npm. diff --git a/package.json b/package.json index dc67cb3ed..f81bfbf48 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "markdown-it", "markdown-it-plugin" ], - "repository": "hackmdio/markdown-it", + "repository": { + "type": "git", + "url": "git+https://github.com/hackmdio/markdown-it.git" + }, "license": "MIT", "main": "dist/index.cjs.js", "module": "index.mjs", diff --git a/test/pathological.mjs b/test/pathological.mjs index 528691322..2c883ec97 100644 --- a/test/pathological.mjs +++ b/test/pathological.mjs @@ -4,7 +4,7 @@ import crypto from 'node:crypto' import { Worker as JestWorker } from 'jest-worker' import { readFileSync } from 'fs' -async function test_pattern (str) { +async function in_worker (fn, timeout) { const worker = new JestWorker( new URL('./pathological_worker.js', import.meta.url), { @@ -18,9 +18,9 @@ async function test_pattern (str) { try { result = await Promise.race([ - worker.render(str), + fn(worker), new Promise((resolve, reject) => { - setTimeout(() => reject(new Error('Terminated (timeout exceeded)')), 3000).unref() + setTimeout(() => reject(new Error('Terminated (timeout exceeded)')), timeout).unref() }) ]) } finally { @@ -31,6 +31,26 @@ async function test_pattern (str) { return result } +function test_pattern (str) { + return in_worker(worker => worker.render(str), 3000) +} + +// For inputs that are slow to render even in linear time, a fixed time limit +// can't tell linear from quadratic on runners of different speeds, so compare +// the time for `unit` repeated `n` and `4 * n` times instead: about 4x when +// linear, up to 16x when quadratic. +function test_linear (unit, n) { + return in_worker(async worker => { + await worker.time(unit.repeat(n / 10), 1) + const small = await worker.time(unit.repeat(n), 2) + const large = await worker.time(unit.repeat(4 * n), 2) + assert.ok( + large < small * 6, + `4x input took ${(large / small).toFixed(1)}x as long (${Math.round(small)}ms, ${Math.round(large)}ms), expected linear` + ) + }, 30000) +} + describe('Pathological sequences speed', () => { it('Integrity check', async () => { assert.strictEqual( @@ -169,11 +189,11 @@ describe('Pathological sequences speed', () => { }) it('many code spans on one line', async () => { - await test_pattern('` x` '.repeat(300000)) + await test_linear('` x` ', 50000) }) it('many images on one line', async () => { - await test_pattern('![x](u) '.repeat(300000)) + await test_linear('![x](u) ', 50000) }) }) }) diff --git a/test/pathological_worker.js b/test/pathological_worker.js index 76259c8cc..1d17b6635 100644 --- a/test/pathological_worker.js +++ b/test/pathological_worker.js @@ -3,3 +3,15 @@ exports.render = async (str) => { return (await import('../index.mjs')).default().render(str) } + +// Milliseconds to render `str`, best of `runs`. +exports.time = async (str, runs) => { + const md = (await import('../index.mjs')).default() + let best = Infinity + for (let i = 0; i < runs; i++) { + const start = performance.now() + md.render(str) + best = Math.min(best, performance.now() - start) + } + return best +}