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
5 changes: 5 additions & 0 deletions .bumpy/release-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
mdcode-ts: patch
---

Fixed the README's library examples: the `parse()` and `update()` examples now run as written, and code blocks no longer carry `file=` paths to files that were never committed, so `mdcode update` works on the README itself. Releases now publish only after type check, lint, build, tests, a docs-sync check and the README's `runnable=true` examples all pass.
61 changes: 59 additions & 2 deletions .github/workflows/bumpy-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,65 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}

version-pr:
# Every check the release depends on, as one named step each. Runs with a
# read-only token and no id-token, because the runnable-examples gate
# executes code from the Markdown.
gates:
needs: plan
if: needs.plan.outputs.mode == 'version-pr' || needs.plan.outputs.mode == 'publish'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-base

- id: type-check
name: 'Gate: type check'
run: pnpm lint:ts

- id: lint
name: 'Gate: lint'
run: pnpm lint:esl && pnpm lint:s

- id: build
name: 'Gate: build'
run: pnpm build

- id: tests
name: 'Gate: tests'
run: pnpm test

- id: docs-sync
name: 'Gate: docs in sync'
run: pnpm docs:check

- id: runnable-examples
name: 'Gate: runnable examples'
run: pnpm docs:examples

- name: Report the failed gate
if: failure()
env:
STEPS: ${{ toJSON(steps) }}
MODE: ${{ needs.plan.outputs.mode }}
run: |
failed=$(jq -r '[to_entries[] | select(.value.outcome == "failure") | .key] | join(", ")' <<< "$STEPS")
# Setup Base installs and builds before the first gate, so a failure there has no gate id.
failed="${failed:-setup (install or first build)}"
if [ "$MODE" = version-pr ]; then effect="No version PR was opened or updated."; else effect="Nothing was published."; fi
echo "::error title=Release blocked::Release gate failed: $failed. $effect See RELEASING.md."
{
echo "## Release blocked"
echo
echo "Failed gate: \`$failed\` (mode: \`$MODE\`). $effect"
echo
echo "Open the failed step above for its output, fix it on a pull request to \`main\`, and merge. See RELEASING.md."
} >> "$GITHUB_STEP_SUMMARY"

version-pr:
needs: [plan, gates]
if: needs.plan.outputs.mode == 'version-pr'
runs-on: ubuntu-latest
permissions:
Expand All @@ -46,7 +103,7 @@ jobs:
BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }}

publish:
needs: plan
needs: [plan, gates]
if: needs.plan.outputs.mode == 'publish'
runs-on: ubuntu-latest
environment: publish
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ jobs:
- uses: ./.github/actions/setup-base

- run: pnpm test

- name: Check docs are in sync
run: pnpm docs:check

- name: Run runnable examples
run: pnpm docs:examples
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ TypeScript port of [szkiba/mdcode](https://github.com/szkiba/mdcode): keeps Mark

## Checks

`pnpm check` runs everything CI runs: type check, ESLint, the style config (`lint:s`), build and both test packages. The husky pre-push hook runs it. `pnpm lint:fix` fixes most `lint:s` errors.
`pnpm check` runs everything CI runs: type check, ESLint, the style config (`lint:s`), build, both test packages, `docs:check` (Markdown blocks match their `file=`) and `docs:examples` (`runnable=true` blocks run). The husky pre-push hook runs it. `pnpm lint:fix` fixes most `lint:s` errors.

## Conventions

Expand All @@ -27,6 +27,7 @@ TypeScript port of [szkiba/mdcode](https://github.com/szkiba/mdcode): keeps Mark

- Adding or changing a command, flag or error code: `docs/agents/adding-a-command.md`
- Test layout: `TESTING.md`
- Releasing and the release gates: `RELEASING.md`
- Issues (GitHub, `gh` CLI): `docs/agents/issue-tracker.md`
- Triage labels: `docs/agents/triage-labels.md`
- Domain language: `CONTEXT.md` and `docs/adr/`, see `docs/agents/domain.md`
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This installs the `mdcode` command. Node.js 22 or later is required. To try it w

Put the example in a source file and mark the part you want to show with a region:

```ts
```ts runnable=true
// src/greet.ts
// #region greet
export function greet(name: string): string {
Expand Down Expand Up @@ -97,7 +97,7 @@ Every command filters blocks by language, file, name or other metadata. All but

## Development

This is a pnpm workspace. `packages/mdcode` is the published package and `packages/usage` holds end-to-end tests against the built CLI. See [TESTING.md](TESTING.md) for the test layout.
This is a pnpm workspace. `packages/mdcode` is the published package and `packages/usage` holds end-to-end tests against the built CLI. See [TESTING.md](TESTING.md) for the test layout and [RELEASING.md](RELEASING.md) for how releases are gated and published.

```bash
pnpm install
Expand Down
49 changes: 49 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Releasing

`mdcode-ts` is released by [bumpy](https://bumpy.varlock.dev) from `.github/workflows/bumpy-release.yml`. That workflow is the only way a version reaches npm. It runs on every push to `main` and on nothing else, so pull requests and other branches can never publish.

## How a release happens

1. Each pull request that changes the package adds a bump file in `.bumpy/` (`pnpm bump`). The Bumpy Check workflow fails a PR without one, unless it has the `no-bump` label.
2. When that PR merges, the release workflow runs `bumpy ci plan`. With bump files pending, the mode is `version-pr`: the release gates run, then bumpy opens or updates the **Version Packages** PR, which bumps `packages/mdcode/package.json` and writes the changelog.
3. Merging the Version Packages PR pushes to `main` again. With no bump files left and a version that npm does not have yet, the mode is `publish`: the release gates run again, then the `publish` job publishes to npm with provenance and creates the git tag and GitHub release.

A push with no bump files and nothing unpublished does nothing.

## Release gates

The `gates` job runs before both the `version-pr` and `publish` jobs, and both of them need it to pass. If a gate fails in `version-pr` mode, the Version Packages PR is not opened or updated; in `publish` mode, nothing is published to npm.

| Step | Command | Fails when |
|------|---------|------------|
| Gate: type check | `pnpm lint:ts` | `tsc` reports an error |
| Gate: lint | `pnpm lint:esl && pnpm lint:s` | ESLint reports an error or warning |
| Gate: build | `pnpm build` | `zshy` cannot build `dist/` |
| Gate: tests | `pnpm test` | a unit or usage test fails |
| Gate: docs in sync | `pnpm docs:check` | a code block has drifted from its `file=`, or a `file=` cannot be read |
| Gate: runnable examples | `pnpm docs:examples` | a `runnable=true` block in `README.md` or `packages/mdcode/README.md` fails under Node |

`docs:check` runs [`examples/ci/check-docs-sync.mjs`](examples/ci/check-docs-sync.mjs) over every Markdown file the package ships or links to. `docs:examples` runs [`examples/ci/validate-snippets.mjs`](examples/ci/validate-snippets.mjs), which extracts the `runnable=true` blocks into `packages/mdcode/.mdcode-tmp/`. There, `import … from 'mdcode-ts'` resolves to the freshly built `dist/`. The gates job has a read-only token and no `id-token`, because it executes code from the Markdown.

`pnpm check`, the pre-push hook and the CI workflow run the same checks, so drift normally fails the PR that caused it rather than the release.

## One-time setup

These are account settings. The workflow cannot create them.

- **npm trusted publishing.** On npmjs.com, open the `mdcode-ts` package settings and add a trusted publisher: GitHub Actions, repository `adrianbrowning/mdcode-ts`, workflow `bumpy-release.yml`, environment `publish`. The `publish` job then authenticates through OIDC (`id-token: write`), so no npm token is stored. Trusted publishing needs a recent npm, so the job installs the latest npm first.
- **`publish` environment.** The `publish` job deploys to the `publish` environment, which has a deployment branch policy. Keep that policy limited to `main`. To approve each publication by hand, add yourself as a required reviewer there.
- **`BUMPY_GH_TOKEN` secret.** A token that can push branches and open pull requests in this repository. Bumpy uses it to push the Version Packages branch, so that PR's checks run (GitHub does not start workflows for pushes made with the default `GITHUB_TOKEN`). Without it, the workflow falls back to `github.token`.

The package name is `mdcode-ts` until #3 settles the canonical name. If it changes, update the trusted publisher on npm to the new package.

## When a gate fails

1. Open the failed run under **Actions → Bumpy Release**. The job summary and the error annotation name the failed gate, and that step's log has the details. `docs in sync` lists each drifted block by line; `runnable examples` names the block and the extracted file that failed.
2. Reproduce it locally with the command from the table, after `pnpm build`.
3. Fix it on a pull request to `main`. Docs drift is usually fixed with `mdcode update --apply <file>`; a failing example needs the Markdown changed until it runs.
4. Merge the fix. The push re-runs the workflow. If the fix carries an empty bump file (`pnpm bumpy add --empty`) or the `no-bump` label, the plan stays in `publish` mode and the pending version is published. If it carries a real bump file, bumpy updates the Version Packages PR instead, and merging that publishes the combined version.

For a flaky failure with no code change needed, use **Re-run failed jobs** on the same run.

If `publish` itself fails after the gates passed (for example a misconfigured trusted publisher), fix the setting and re-run the failed job. Bumpy publishes only the versions npm does not have yet, so a re-run never publishes a version twice.
4 changes: 2 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ Two packages, `packages/mdcode` (published as `mdcode-ts`) and `packages/usage`.

## Before Pushing

`pnpm check` runs what CI runs: type check, ESLint, `lint:s`, build and both test packages. The
pre-push hook runs it too.
`pnpm check` runs what CI runs: type check, ESLint, `lint:s`, build, both test packages, and the
docs checks (`docs:check` and `docs:examples`). The pre-push hook runs it too.

## Adding New Tests

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
"type": "module",
"scripts": {
"prepare": "husky",
"check": "pnpm lint:ts && pnpm lint:esl && pnpm lint:s && pnpm build && pnpm test",
"check": "pnpm lint:ts && pnpm lint:esl && pnpm lint:s && pnpm build && pnpm test && pnpm docs:check && pnpm docs:examples",
"build": "pnpm --filter mdcode-ts build",
"dev": "pnpm --filter mdcode-ts dev",
"test": "pnpm -r test",
"test:watch": "pnpm --filter mdcode-ts test:watch",
"test:usage": "pnpm --filter usage test",
"test:usage:watch": "pnpm --filter usage test:watch",
"test:all": "pnpm test && pnpm test:usage",
"docs:check": "PATH=\"$PWD/scripts/bin:$PATH\" node examples/ci/check-docs-sync.mjs README.md packages/mdcode/README.md examples/CLI_EXAMPLES.md TESTING.md packages/mdcode/tests/examples/factorial/README.md packages/mdcode/tests/examples/fibonacci/README.md",
"docs:examples": "PATH=\"$PWD/scripts/bin:$PATH\" node examples/ci/validate-snippets.mjs --tmp-dir packages/mdcode/.mdcode-tmp README.md packages/mdcode/README.md -- node --experimental-strip-types {file}",
"lint:ts": "pnpm -r lint:ts",
"lint": "pnpm -r lint",
"lint:esl": "pnpm -r lint:esl",
Expand Down
Loading
Loading