From f445fb4b56ecaf7d1e5d541b30af401789a8a451 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 03:23:20 +0000 Subject: [PATCH 1/4] ci: publish to npm with trusted publishing (OIDC) on GitHub Release Co-authored-by: Yukai Huang --- .github/workflows/publish.yml | 107 ++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 + RELEASING.md | 68 +++++++++++++++++++++ package.json | 5 +- 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml create mode 100644 RELEASING.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..ebdb8343a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,107 @@ +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. + +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..d0f9bc30f --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,68 @@ +# Releasing `@hackmd/markdown-it` + +Releases are published to npm by the +[`Publish to npm`](.github/workflows/publish.yml) workflow using +[npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC). +No npm token is involved, and every version is published with +[provenance](https://docs.npmjs.com/generating-provenance-statements). +Don't run `npm publish` locally. + +## Steps + +1. On `master`, update `CHANGELOG.md` (replace "Unreleased" with the date). +2. Bump the version without creating a tag: + + ```sh + npm version --no-git-tag-version + ``` + +3. Rebuild `dist/` and commit it together with the version bump. `dist/` is + kept out of pull requests but committed for each release: + + ```sh + npm run build + git add -f dist/ package.json package-lock.json CHANGELOG.md + git commit -m "chore: build " + git push origin master + ``` + +4. Wait for CI to pass on that commit. +5. Create a GitHub Release with a new tag `v` (for example `v14.2.0`) + targeting that commit, and publish it. Mark it as a pre-release for + versions like `14.2.0-beta.0`. +6. Publishing the release starts the workflow. Approve the deployment to the + `npm` environment when it asks for approval. +7. Check that the new version on npmjs.com shows a provenance badge. + +## What the workflow checks + +- The release tag, with or without a leading `v`, must equal the `version` + in `package.json`. +- That version must not already be on npm. +- `npm ci` and `npm test` must pass. +- `dist/` is rebuilt before publishing, so the tarball never ships a stale + build. The workflow logs a warning if the committed `dist/` was out of date. +- Versions with a pre-release suffix (`-beta.0` etc.) are published under the + `next` dist-tag; all others go to `latest`. + +## Manual runs + +The workflow can also be started from the Actions tab (**Run workflow**), +from `master` or from a release tag. Tick **dry-run** to run every step +with `npm publish --dry-run`. Manual runs use the same checks and approval, +so a dry run also fails while `package.json` still holds a published version. + +## Trusted publisher settings 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` | + +The `npm` environment must also exist in the GitHub repository settings +(**Settings > Environments**), ideally with required reviewers. 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", From 77a6dc4c473c300bdc2b612ff6ece939ae54f9ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 03:36:36 +0000 Subject: [PATCH 2/4] ci: add bump-version workflow that tags, releases and dispatches publish Co-authored-by: Yukai Huang --- .github/workflows/bump-version.yml | 150 +++++++++++++++++++++++++++++ .github/workflows/publish.yml | 4 +- RELEASING.md | 142 +++++++++++++++++++-------- 3 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/bump-version.yml diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 000000000..5510d66aa --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,150 @@ +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 + + if grep -q "^## \[$version\] - Unreleased" CHANGELOG.md; then + sed -i "s/^## \[$version\] - Unreleased/## [$version] - $(date -u +%Y-%m-%d)/" CHANGELOG.md + elif ! grep -q "^## \[$version\]" CHANGELOG.md; then + echo "::warning::CHANGELOG.md has no section for $version." + 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 CHANGELOG.md + 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: | + awk -v v="$VERSION" ' + index($0, "## [" v "]") == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > release-notes.md + + args=(--verify-tag --title "$TAG") + if [ -s release-notes.md ]; then + args+=(--notes-file release-notes.md) + else + args+=(--generate-notes) + fi + 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 index ebdb8343a..05e4daedb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,9 @@ 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. +# 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: diff --git a/RELEASING.md b/RELEASING.md index d0f9bc30f..d5d799e19 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,68 +1,126 @@ # Releasing `@hackmd/markdown-it` -Releases are published to npm by the -[`Publish to npm`](.github/workflows/publish.yml) workflow using +Releases are published to npm by GitHub Actions using [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC). No npm token is involved, and every version is published with [provenance](https://docs.npmjs.com/generating-provenance-statements). Don't run `npm publish` locally. -## Steps +Two workflows are involved: + +- [`bump-version.yml`](.github/workflows/bump-version.yml) bumps the version, + rebuilds `dist/`, commits and tags on `master`, creates the GitHub Release + and starts the publish. +- [`publish.yml`](.github/workflows/publish.yml) tests, builds and publishes + to npm. It's the workflow registered as the trusted publisher. -1. On `master`, update `CHANGELOG.md` (replace "Unreleased" with the date). -2. Bump the version without creating a tag: +## Steps - ```sh - npm version --no-git-tag-version - ``` +1. Make sure `master` is green and `CHANGELOG.md` has a section for the new + version, e.g. `## [14.2.0] - Unreleased`. +2. In the Actions tab, run **Bump version and release** on `master`: + - **release-type**: `patch`, `minor`, `major` or `prerelease` + (`prerelease` uses **preid**, default `beta`: `14.1.2` → `14.1.3-beta.0`). + - **version**: optional explicit version (`14.2.0`, `14.2.0-rc.0`), which + overrides **release-type**. + - **dry-run**: tick to stop before pushing anything. +3. The run: + 1. bumps `package.json` and `package-lock.json`, and replaces + "Unreleased" with today's date in the version's `CHANGELOG.md` + heading; + 2. refuses if tag `v` exists or the version is already on npm; + 3. runs `npm test` and `npm run build`; + 4. commits `chore: release [skip ci]` with the rebuilt `dist/`, + and tags it `v`; + 5. pushes the commit and tag to `master` in one atomic push; + 6. creates the GitHub Release `v` with that version's changelog + section as notes (marked pre-release for `-beta.0` etc.); + 7. dispatches `publish.yml` on the tag. +4. Approve the `publish.yml` run's deployment to the `npm` environment. +5. Check that the new version on npmjs.com shows a provenance badge. -3. Rebuild `dist/` and commit it together with the version bump. `dist/` is - kept out of pull requests but committed for each release: +This commit replaces the manual "chore: build" commit: `dist/` is still kept +out of pull requests, and the release commit is where it gets refreshed. - ```sh - npm run build - git add -f dist/ package.json package-lock.json CHANGELOG.md - git commit -m "chore: build " - git push origin master - ``` +## Why it's wired this way -4. Wait for CI to pass on that commit. -5. Create a GitHub Release with a new tag `v` (for example `v14.2.0`) - targeting that commit, and publish it. Mark it as a pre-release for - versions like `14.2.0-beta.0`. -6. Publishing the release starts the workflow. Approve the deployment to the - `npm` environment when it asks for approval. -7. Check that the new version on npmjs.com shows a provenance badge. +- **Release events from the bot don't fire.** Pushes, tags and releases + created with `GITHUB_TOKEN` start no workflows. `workflow_dispatch` is the + exception, so `bump-version.yml` dispatches `publish.yml` explicitly with + `gh workflow run publish.yml --ref v` (it has `actions: write` for + that). +- **`[skip ci]` only affects CI.** Skip instructions apply only to `push` and + `pull_request` events. `publish.yml` runs on `release` and + `workflow_dispatch`, never on a push, so the skip can't reach it. A bot push + wouldn't start CI anyway; the marker matters if the bump commit is ever + pushed with another token. Since CI doesn't test the release commit, + `bump-version.yml` runs `npm test` itself, and `publish.yml` runs it again. +- **No `workflow_call`.** npm's trusted publisher check uses the top-level + workflow's filename. If `bump-version.yml` called `publish.yml` as a + reusable workflow, npm would see `bump-version.yml`. A dispatched run of + `publish.yml` is its own top-level run, so the registered filename stays + `publish.yml`. -## What the workflow checks +## What `publish.yml` checks -- The release tag, with or without a leading `v`, must equal the `version` - in `package.json`. +- The tag, with or without a leading `v`, must equal `package.json`'s + `version`. A manual run must come from a tag or `master`. - That version must not already be on npm. - `npm ci` and `npm test` must pass. - `dist/` is rebuilt before publishing, so the tarball never ships a stale - build. The workflow logs a warning if the committed `dist/` was out of date. -- Versions with a pre-release suffix (`-beta.0` etc.) are published under the - `next` dist-tag; all others go to `latest`. + build. It logs a warning if the committed `dist/` was out of date. +- Versions with a pre-release suffix are published under the `next` + dist-tag; all others go to `latest`. -## Manual runs +## Other ways to publish -The workflow can also be started from the Actions tab (**Run workflow**), -from `master` or from a release tag. Tick **dry-run** to run every step -with `npm publish --dry-run`. Manual runs use the same checks and approval, -so a dry run also fails while `package.json` still holds a published version. +- **Releasing by hand:** bump, rebuild `dist/` and commit as the workflow + does, then create and publish a GitHub Release with tag `v` in the + UI. A release published by a person fires `publish.yml` directly. Releases + created by `bump-version.yml` don't, which is why it dispatches instead. +- **Re-running a publish:** if a dispatched publish failed (for example the + approval was rejected), run **Publish to npm** from the Actions tab and + pick the tag `v` as the ref. Tick **dry-run** to run every step + with `npm publish --dry-run`. A dry run also fails while `package.json` + holds a version that's already on npm. + +## Repository settings + +- **Environment:** create `npm` under **Settings > Environments**, ideally + with required reviewers. +- **Pushes to `master`:** `bump-version.yml` pushes to `master` as + `github-actions[bot]`. Right now `master` has no classic branch protection + and only an organization ruleset for Copilot code review, which doesn't + block pushes. If `master` later requires pull requests or status checks, + the bot push will be rejected. Classic branch protection has no way to let + `GITHUB_TOKEN` bypass those rules. Instead, create a GitHub App, add it to + the ruleset's bypass list, and use its token + ([`actions/create-github-app-token`](https://github.com/actions/create-github-app-token)) + for the checkout and push. Note that pushes made with an App token do + start workflows, so `[skip ci]` then keeps CI off the bump commit. The same + applies to tag rulesets matching `v*`. +- **Actions permissions:** the workflows request their own `permissions`. + If the organization restricts which actions may run, `actions/checkout` and + `actions/setup-node` must be allowed. ## Trusted publisher settings 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` | +| Field | Value | +| ------------------------ | ----------------------------- | +| Organization or user | `hackmdio` | +| Repository | `markdown-it` | +| Workflow filename | `publish.yml` | +| Environment name | `npm` | +| Allowed actions | tick **npm publish** | + +New configurations only allow `npm stage publish` unless **npm publish** is +ticked, and `publish.yml` uses `npm publish`. A new configuration expires if +it doesn't complete a publish within 2 days, so set it up shortly before the +first release. -The `npm` environment must also exist in the GitHub repository settings -(**Settings > Environments**), ideally with required reviewers. +After the first successful publish, set **Settings > Publishing access** to +"Require two-factor authentication and disallow tokens", and revoke any +automation tokens that are no longer needed. From d5878aa9c2aeae8dfe3cb4685d3df4cc3738832d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 03:40:40 +0000 Subject: [PATCH 3/4] ci: drop CHANGELOG from release flow, use generated release notes, trim RELEASING.md Co-authored-by: Yukai Huang --- .github/workflows/bump-version.yml | 21 +---- RELEASING.md | 140 +++++------------------------ 2 files changed, 26 insertions(+), 135 deletions(-) diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 5510d66aa..db6c14343 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -82,12 +82,6 @@ jobs: exit 1 fi - if grep -q "^## \[$version\] - Unreleased" CHANGELOG.md; then - sed -i "s/^## \[$version\] - Unreleased/## [$version] - $(date -u +%Y-%m-%d)/" CHANGELOG.md - elif ! grep -q "^## \[$version\]" CHANGELOG.md; then - echo "::warning::CHANGELOG.md has no section for $version." - fi - echo "Bumped to $version" echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" @@ -106,7 +100,7 @@ jobs: 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 CHANGELOG.md + 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 @@ -124,18 +118,7 @@ jobs: VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.bump.outputs.tag }} run: | - awk -v v="$VERSION" ' - index($0, "## [" v "]") == 1 { found = 1; next } - found && /^## \[/ { exit } - found { print } - ' CHANGELOG.md > release-notes.md - - args=(--verify-tag --title "$TAG") - if [ -s release-notes.md ]; then - args+=(--notes-file release-notes.md) - else - args+=(--generate-notes) - fi + args=(--verify-tag --title "$TAG" --generate-notes) case "$VERSION" in *-*) args+=(--prerelease) ;; esac gh release create "$TAG" "${args[@]}" diff --git a/RELEASING.md b/RELEASING.md index d5d799e19..5cf728fb2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,126 +1,34 @@ # Releasing `@hackmd/markdown-it` -Releases are published to npm by GitHub Actions using -[npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC). -No npm token is involved, and every version is published with -[provenance](https://docs.npmjs.com/generating-provenance-statements). -Don't run `npm publish` locally. +Releases are published from GitHub Actions with npm trusted publishing +(OIDC), so no npm token is needed. Don't run `npm publish` locally. -Two workflows are involved: +## Run the bump -- [`bump-version.yml`](.github/workflows/bump-version.yml) bumps the version, - rebuilds `dist/`, commits and tags on `master`, creates the GitHub Release - and starts the publish. -- [`publish.yml`](.github/workflows/publish.yml) tests, builds and publishes - to npm. It's the workflow registered as the trusted publisher. +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. -## Steps +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. -1. Make sure `master` is green and `CHANGELOG.md` has a section for the new - version, e.g. `## [14.2.0] - Unreleased`. -2. In the Actions tab, run **Bump version and release** on `master`: - - **release-type**: `patch`, `minor`, `major` or `prerelease` - (`prerelease` uses **preid**, default `beta`: `14.1.2` → `14.1.3-beta.0`). - - **version**: optional explicit version (`14.2.0`, `14.2.0-rc.0`), which - overrides **release-type**. - - **dry-run**: tick to stop before pushing anything. -3. The run: - 1. bumps `package.json` and `package-lock.json`, and replaces - "Unreleased" with today's date in the version's `CHANGELOG.md` - heading; - 2. refuses if tag `v` exists or the version is already on npm; - 3. runs `npm test` and `npm run build`; - 4. commits `chore: release [skip ci]` with the rebuilt `dist/`, - and tags it `v`; - 5. pushes the commit and tag to `master` in one atomic push; - 6. creates the GitHub Release `v` with that version's changelog - section as notes (marked pre-release for `-beta.0` etc.); - 7. dispatches `publish.yml` on the tag. -4. Approve the `publish.yml` run's deployment to the `npm` environment. -5. Check that the new version on npmjs.com shows a provenance badge. +## npm Trusted Publisher -This commit replaces the manual "chore: build" commit: `dist/` is still kept -out of pull requests, and the release commit is where it gets refreshed. +On npmjs.com, under the package's **Settings > Trusted Publisher**, select +GitHub Actions and enter: -## Why it's wired this way +| Field | Value | +| -------------------- | -------------------- | +| Organization or user | `hackmdio` | +| Repository | `markdown-it` | +| Workflow filename | `publish.yml` | +| Environment name | `npm` | +| Allowed actions | tick **npm publish** | -- **Release events from the bot don't fire.** Pushes, tags and releases - created with `GITHUB_TOKEN` start no workflows. `workflow_dispatch` is the - exception, so `bump-version.yml` dispatches `publish.yml` explicitly with - `gh workflow run publish.yml --ref v` (it has `actions: write` for - that). -- **`[skip ci]` only affects CI.** Skip instructions apply only to `push` and - `pull_request` events. `publish.yml` runs on `release` and - `workflow_dispatch`, never on a push, so the skip can't reach it. A bot push - wouldn't start CI anyway; the marker matters if the bump commit is ever - pushed with another token. Since CI doesn't test the release commit, - `bump-version.yml` runs `npm test` itself, and `publish.yml` runs it again. -- **No `workflow_call`.** npm's trusted publisher check uses the top-level - workflow's filename. If `bump-version.yml` called `publish.yml` as a - reusable workflow, npm would see `bump-version.yml`. A dispatched run of - `publish.yml` is its own top-level run, so the registered filename stays - `publish.yml`. +## `npm` environment -## What `publish.yml` checks - -- The tag, with or without a leading `v`, must equal `package.json`'s - `version`. A manual run must come from a tag or `master`. -- That version must not already be on npm. -- `npm ci` and `npm test` must pass. -- `dist/` is rebuilt before publishing, so the tarball never ships a stale - build. It logs a warning if the committed `dist/` was out of date. -- Versions with a pre-release suffix are published under the `next` - dist-tag; all others go to `latest`. - -## Other ways to publish - -- **Releasing by hand:** bump, rebuild `dist/` and commit as the workflow - does, then create and publish a GitHub Release with tag `v` in the - UI. A release published by a person fires `publish.yml` directly. Releases - created by `bump-version.yml` don't, which is why it dispatches instead. -- **Re-running a publish:** if a dispatched publish failed (for example the - approval was rejected), run **Publish to npm** from the Actions tab and - pick the tag `v` as the ref. Tick **dry-run** to run every step - with `npm publish --dry-run`. A dry run also fails while `package.json` - holds a version that's already on npm. - -## Repository settings - -- **Environment:** create `npm` under **Settings > Environments**, ideally - with required reviewers. -- **Pushes to `master`:** `bump-version.yml` pushes to `master` as - `github-actions[bot]`. Right now `master` has no classic branch protection - and only an organization ruleset for Copilot code review, which doesn't - block pushes. If `master` later requires pull requests or status checks, - the bot push will be rejected. Classic branch protection has no way to let - `GITHUB_TOKEN` bypass those rules. Instead, create a GitHub App, add it to - the ruleset's bypass list, and use its token - ([`actions/create-github-app-token`](https://github.com/actions/create-github-app-token)) - for the checkout and push. Note that pushes made with an App token do - start workflows, so `[skip ci]` then keeps CI off the bump commit. The same - applies to tag rulesets matching `v*`. -- **Actions permissions:** the workflows request their own `permissions`. - If the organization restricts which actions may run, `actions/checkout` and - `actions/setup-node` must be allowed. - -## Trusted publisher settings 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** | - -New configurations only allow `npm stage publish` unless **npm publish** is -ticked, and `publish.yml` uses `npm publish`. A new configuration expires if -it doesn't complete a publish within 2 days, so set it up shortly before the -first release. - -After the first successful publish, set **Settings > Publishing access** to -"Require two-factor authentication and disallow tokens", and revoke any -automation tokens that are no longer needed. +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. From 71e567cc7c0134e7702702cef5071d38fde78d2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 04:43:32 +0000 Subject: [PATCH 4/4] test: check scaling instead of wall time for long single-line pathological tests "many code spans on one line" and "many images on one line" (added in 240d144) rendered 300000 repeats under a 3s limit that includes worker startup. Healthy code took 1.8-2.8s on CI, so slow runners timed out, while the quadratic code they guard against rendered code spans in 2.4s on a fast machine and passed. Compare the time for n and 4n repeats in the same worker instead: about 4x for the current code, 7-9x for the code before 240d144, limit 6x. Co-authored-by: Yukai Huang --- test/pathological.mjs | 30 +++++++++++++++++++++++++----- test/pathological_worker.js | 12 ++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) 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 +}