Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/bump-version.yml
Original file line number Diff line number Diff line change
@@ -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"
109 changes: 109 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
34 changes: 34 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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 <version> [skip ci]`,
pushes the commit and tag `v<version>` 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.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 25 additions & 5 deletions test/pathological.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
{
Expand All @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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)
})
})
})
12 changes: 12 additions & 0 deletions test/pathological_worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading