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
69 changes: 69 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Changelog

# A pull request that changes published code has to record it in CHANGELOG.md, because the
# [Unreleased] section is what decides the next version (scripts/derive-increment.mjs). Without
# this check the release is the first place anyone notices a missing or unclassifiable entry,
# and by then the version has already been derived. Label a pull request `skip-changelog` to
# opt out.
#
# This file is shared verbatim with jetstreamapp/sf-formula-parser and
# jetstreamapp/soql-parser-js - keep the copies in sync when changing it.

on:
pull_request:
# labeled/unlabeled so adding `skip-changelog` re-runs the check rather than leaving a
# stale failure behind.
types: [opened, synchronize, reopened, labeled, unlabeled]

permissions:
contents: read

jobs:
changelog:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The whole history, so the pull request's base commit is available to diff against.
fetch-depth: 0

- name: Require a changelog entry for published code
env:
BASE: ${{ github.event.pull_request.base.sha }}
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
run: |
# The label is checked here rather than in a job-level `if` so the check always
# reports a result. A skipped job is awkward to require in branch protection.
if printf '%s' "$LABELS" | grep -q '"skip-changelog"'; then
echo "skip-changelog label present - not requiring a changelog entry."
exit 0
fi

# Three dots: everything on this branch since it diverged from the base.
changed=$(git diff --name-only "$BASE"...HEAD)

# Only published code counts. Tooling and docs changes do not force an entry;
# cli/ and bin/ do not exist in every repo sharing this file, which is harmless.
if ! printf '%s\n' "$changed" | grep -qE '^(src|cli|bin)/'; then
echo "No published code changed - no changelog entry required."
exit 0
fi

# -x so docs/CHANGELOG.md does not satisfy the root one.
if ! printf '%s\n' "$changed" | grep -qx 'CHANGELOG.md'; then
echo "This pull request changes published code but does not touch CHANGELOG.md." >&2
echo "" >&2
echo " Add an entry under '## [Unreleased]' using one of the seven headings:" >&2
echo " Breaking Changes, Added, Deprecated, Changed, Removed, Fixed, Security." >&2
echo "" >&2
echo " That section decides the next version, so an unrecorded change ships" >&2
echo " unversioned. If this genuinely needs no entry, add the 'skip-changelog' label." >&2
exit 1
fi

# Touching the file is not enough - prove the section classifies to a bump, which is
# exactly what the release will do. Uses only Node builtins, so no install needed.
echo "CHANGELOG.md was updated. Checking that [Unreleased] derives a version:"
node scripts/derive-increment.mjs --explain
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: npm

- run: npm ci
- run: npm run typecheck
- run: npm test
# build before test, matching sf-formula-parser and soql-parser-js: tests run against a
# build that is known to have succeeded.
- run: npm run build
- run: npm test
77 changes: 59 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,53 +1,94 @@
name: Release

# Actions are pinned to a commit SHA with the version in a trailing comment: a tag is mutable
# and can be repointed at new code, a SHA cannot.
#
# This file is shared verbatim with jetstreamapp/sf-formula-parser and
# jetstreamapp/soql-parser-js - keep the copies in sync when changing it.

on:
workflow_dispatch:
inputs:
version:
description: "Version bump (major, minor, patch, or explicit like 1.2.3)"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
# Normally dispatched by `npm run release`, which derives this from the [Unreleased]
# section of CHANGELOG.md. `auto` derives it here instead, and a bump keyword or an
# explicit version both work when running the workflow by hand from the GitHub UI.
description: '`auto`, a bump keyword (major, minor, patch) or an explicit version (1.2.3)'
required: false
default: auto
type: string

permissions:
contents: write
id-token: write
pages: write

jobs:
release:
runs-on: ubuntu-latest

steps:
- uses: actions/create-github-app-token@v3
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: actions/checkout@v7
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# Persisted for the release commit, tag and push that release-it makes later.
token: ${{ steps.app-token.outputs.token }}

- uses: actions/setup-node@v7
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: npm
registry-url: "https://registry.npmjs.org"
registry-url: 'https://registry.npmjs.org'

- name: Resolve version
id: resolve
run: |
if [ -z "$INPUT" ] || [ "$INPUT" = "auto" ]; then
VALUE=$(node scripts/derive-increment.mjs)
echo "Derived '$VALUE' from the [Unreleased] section of CHANGELOG.md:"
node scripts/derive-increment.mjs --explain
else
VALUE="$INPUT"
echo "Using the version supplied to the workflow: $VALUE"
fi
echo "value=$VALUE" >> "$GITHUB_OUTPUT"
env:
INPUT: ${{ inputs.version }}

- run: npm ci
- name: Install dependencies
run: npm ci

- name: Get GitHub App user ID
id: app-user
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
USER_ID=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
echo "user-id=${USER_ID}" >> "$GITHUB_OUTPUT"

- name: Configure git
env:
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
USER_ID: ${{ steps.app-user.outputs.user-id }}
run: |
git config user.name "jetstream-bot[bot]"
git config user.email "jetstream-bot[bot]@users.noreply.github.com"
# `<id>+<slug>[bot]@users.noreply.github.com` is the address GitHub matches back to the
# App account, so release commits are attributed to the bot with its avatar rather than
# to an unrecognized author. Both halves come from the token, so this identity follows
# whichever App the credential belongs to instead of being hardcoded per repository.
git config user.name "${APP_SLUG}[bot]"
git config user.email "${USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"

- name: Release
run: npx release-it ${{ inputs.version }} --ci
run: npm run release:ci -- "$VERSION" --ci
env:
VERSION: ${{ steps.resolve.outputs.value }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
3 changes: 2 additions & 1 deletion .release-it.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"$schema": "./node_modules/release-it/schema/release-it.json",
"git": {
"commitMessage": "chore: release v${version}"
"commitMessage": "chore: release v${version}",
"commitArgs": ["--no-verify"]
},
"github": {
"release": true
Expand Down
51 changes: 51 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# AGENTS.md

## Misc

Use conventional commit style messages.

## Releasing

Releases are cut from `main` by CI, kicked off from your terminal:

```sh
npm run release # derive the version from CHANGELOG.md
npm run release -- --dry-run # show the plan, dispatch nothing
npm run release -- minor # force a bump level (major | minor | patch)
npm run release -- 3.0.0 # force an explicit version
```

The bump is derived from the `[Unreleased]` section of `CHANGELOG.md` by
`scripts/derive-increment.mjs`, reading **section headings only** — never the entry text, so an
entry that merely mentions a breaking change cannot turn a patch into a major:

| `[Unreleased]` contains | Bump |
| --------------------------------------------------------- | ----- |
| `### Breaking Changes` | major |
| `### Added` or `### Deprecated` | minor |
| `### Changed`, `### Removed`, `### Fixed`, `### Security` | patch |

**So keep `[Unreleased]` accurate — it decides the version.** Those seven headings are the whole
vocabulary; anything else is an error rather than a guess, because guessing risks publishing a
breaking change as a patch. Internal or tooling-only entries go under `### Changed`. An empty
`[Unreleased]` aborts the release. `npm run release:increment` shows the reasoning without
releasing.

The `Changelog` workflow enforces this on every pull request that touches published code: it fails
if `CHANGELOG.md` was not updated, and then fails again if `[Unreleased]` does not classify to a
bump — so a touched-but-empty section is caught too. Label a pull request `skip-changelog` to opt
out when a change genuinely needs no entry.

The script refuses to run unless you are on `main`, the working tree is clean, and your branch
matches `origin/main`. It then dispatches the `Release` workflow with the computed version and
tails the run. The bump, build and publish all happen on CI, so npm provenance (OIDC) is preserved
— running the release locally would lose it.

Release commits pass `--no-verify`: they are machine generated from already checked files, and a
hook failing mid-release would abort after the npm publish.

`scripts/derive-increment.mjs`, `scripts/release.mjs` and the `.github/workflows/release.yml` and
`changelog.yml` workflows are shared verbatim with
[sf-formula-parser](https://github.com/jetstreamapp/sf-formula-parser) and
[soql-parser-js](https://github.com/jetstreamapp/soql-parser-js) — keep the copies in sync when
changing them.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"release": "release-it"
"release": "node scripts/release.mjs",
"release:ci": "release-it",
"release:increment": "node scripts/derive-increment.mjs --explain"
},
"author": "Austin Turner <austin@getjetstream.app>",
"license": "MIT",
Expand Down
74 changes: 74 additions & 0 deletions scripts/__tests__/derive-increment.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { deriveIncrement, parseUnreleasedSections } from '../derive-increment.mjs';

/** Builds a CHANGELOG.md whose [Unreleased] section holds `body` */
function changelog(body) {
return `# Changelog\n\nAll notable changes.\n\n## [Unreleased]\n\n${body}\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- initial release\n`;
}

describe('parseUnreleasedSections', () => {
it('collects each heading with the number of non-blank lines under it', () => {
const sections = parseUnreleasedSections(changelog('### Added\n\n- one\n- two\n\n### Fixed\n\n- three'));

expect(sections).toEqual([
{ heading: 'Added', entries: 2 },
{ heading: 'Fixed', entries: 1 },
]);
});

it('drops headings that have nothing written under them', () => {
expect(parseUnreleasedSections(changelog('### Added\n\n### Fixed\n\n- real entry'))).toEqual([{ heading: 'Fixed', entries: 1 }]);
});

it('throws when content sits before the first heading', () => {
expect(() => parseUnreleasedSections(changelog('Breaking: dropped an overload\n\n### Fixed\n\n- typo'))).toThrow(
/not under a `### <Section>` heading/,
);
});

it('points at the offending CHANGELOG.md line so it can be found', () => {
// `## [Unreleased]` is on line 5 of the fixture, so its body starts on 6 and the prose is on 7
expect(() => parseUnreleasedSections(changelog('Breaking: dropped an overload\n\n### Fixed\n\n- typo'))).toThrow(
/CHANGELOG\.md:7: "Breaking: dropped an overload"/,
);
});

it('does not throw on an empty [Unreleased]', () => {
expect(parseUnreleasedSections(changelog(''))).toEqual([]);
});
});

describe('deriveIncrement', () => {
it.each([
['### Breaking Changes\n\n- removed an API', 'major'],
['### Added\n\n- a feature', 'minor'],
['### Deprecated\n\n- an old API', 'minor'],
['### Fixed\n\n- a bug', 'patch'],
['### Changed\n\n- some internals', 'patch'],
])('derives %s -> %s', (body, expected) => {
expect(deriveIncrement(changelog(body)).increment).toBe(expected);
});

it('takes the highest increment when several sections are present', () => {
expect(deriveIncrement(changelog('### Fixed\n\n- a bug\n\n### Breaking Changes\n\n- removed an API')).increment).toBe('major');
});

it('rejects an unrecognized heading rather than guessing', () => {
expect(() => deriveIncrement(changelog('### Housekeeping\n\n- tidied up'))).toThrow(/Unrecognized changelog section/);
});

it('reports an empty [Unreleased] as nothing to release', () => {
expect(() => deriveIncrement(changelog(''))).toThrow(/is empty - there is nothing to release/);
});

it('reports headings that are all empty', () => {
expect(() => deriveIncrement(changelog('### Added\n\n### Fixed'))).toThrow(/Every `### <Section>` heading in \[Unreleased\] is empty/);
});

it('surfaces unsectioned content instead of silently deriving a lower bump', () => {
// The bug this guards: without the check this returns `patch`, dropping the breaking change
expect(() => deriveIncrement(changelog('Breaking: dropped an overload\n\n### Fixed\n\n- typo'))).toThrow(
/not under a `### <Section>` heading/,
);
});
});
Loading
Loading