diff --git a/.claude/skills/boxel-cli-pr-title/SKILL.md b/.claude/skills/boxel-cli-pr-title/SKILL.md deleted file mode 100644 index e03d78e4852..00000000000 --- a/.claude/skills/boxel-cli-pr-title/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: boxel-cli-pr-title -allowed-tools: Read, Grep, Bash -description: Decide whether a PR title needs a conventional-commit prefix. PRs touching packages/boxel-cli/** require one (feat/fix/perf/refactor/chore/docs/test/build/ci/style) because it drives the boxel-cli npm publish version bump; PRs that don't touch boxel-cli get a plain descriptive title with no prefix. Use before opening or retitling any PR. ---- - -# Boxel-CLI PR Title - -A conventional-commit prefix on a PR title is **only** meaningful for changes to `packages/boxel-cli/**`. There it's a binding contract that drives the npm publish version bump. Anywhere else it's noise — use a plain descriptive title. - -## The rule - -- **PR touches `packages/boxel-cli/**`** → title MUST start with an allowed prefix followed by `:` (e.g. `feat: add --watch flag to sync`). -- **PR does NOT touch `packages/boxel-cli/**`** → no prefix. Write a plain descriptive title (e.g. `Add evergreen-comments skill`, not `docs: add evergreen-comments skill`). - -## Allowed prefixes and their bump level - -The prefix determines the `@cardstack/boxel-cli` version bump applied post-merge: - -| Prefix | Version bump | -|--------|--------------| -| any type with a `!` (e.g. `feat!:`, `fix!:`, `feat(cli)!:`) **or** a `BREAKING CHANGE:` footer in the PR body | **major** | -| `feat:` | minor | -| `fix:`, `perf:`, `refactor:` | patch | -| `chore:`, `docs:`, `test:`, `build:`, `ci:`, `style:` | none | - -Pick the prefix from what the change actually does: diagnostics / test-only changes are `test:`, source-behavior bug fixes are `fix:`, new commands or flags are `feat:`. A **breaking** change to the CLI or plugin surface takes a `!` after the type (or scope) — `feat!:`, `fix!:`, `feat(cli)!:` — or a `BREAKING CHANGE:` footer in the body, either of which forces a major bump regardless of the base type. An optional scope in parentheses (`feat(cli): …`) is permitted and does not affect the bump level on its own. - -## Why it's boxel-cli-only - -`packages/boxel-cli/scripts/release-prefixes.json` is the single source of truth for the allowed prefixes and their non-breaking bump levels; the breaking-change → major rule (`!` marker or `BREAKING CHANGE:` footer) lives in `packages/boxel-cli/scripts/compute-release.ts`. Two workflows drive the flow: - -- **Pre-merge:** `.github/workflows/boxel-cli-pr-title.yml` (`PR Title Check [boxel-cli]`) validates the title. It is **path-scoped** to `packages/boxel-cli/**` and does not run for other PRs. -- **Post-merge:** `boxel-cli-publish.yml` reads the merged PR's title to compute the version bump and publish the new version. - -Because the same JSON file gates both, the title is a contract, not cosmetics — for boxel-cli. A PR that doesn't touch boxel-cli never triggers either workflow, so a prefix on it carries no meaning and should be omitted. - -## Self-check before opening or retitling a PR - -1. Does the diff include any file under `packages/boxel-cli/`? - - **Yes** → ensure the title starts with the prefix matching the change's bump level. If the change is **breaking**, add a `!` (e.g. `feat!:`) or a `BREAKING CHANGE:` body footer so it cuts a major version. - - **No** → ensure the title has no conventional-commit prefix; use plain prose. diff --git a/.claude/skills/published-package-pr-title/SKILL.md b/.claude/skills/published-package-pr-title/SKILL.md new file mode 100644 index 00000000000..bd0fe1810e8 --- /dev/null +++ b/.claude/skills/published-package-pr-title/SKILL.md @@ -0,0 +1,52 @@ +--- +name: published-package-pr-title +allowed-tools: Read, Grep, Bash +description: Decide whether a PR title needs a conventional-commit prefix. PRs touching packages/boxel-cli/** or packages/bxl/** require one (feat/fix/perf/refactor/chore/docs/test/build/ci/style) because it drives that package's npm publish version bump; PRs touching neither get a plain descriptive title with no prefix. Use before opening or retitling any PR. +--- + +# Published-Package PR Title + +A conventional-commit prefix on a PR title is **only** meaningful for changes to a package this repo publishes to npm: `packages/boxel-cli/**` and `packages/bxl/**`. There it's a binding contract that drives the version bump. Anywhere else it's noise — use a plain descriptive title. + +## The rule + +- **PR touches `packages/boxel-cli/` or `packages/bxl/`** → title MUST start with an allowed prefix followed by `:` (e.g. `feat: add --watch flag to sync`). +- **PR touches neither** → no prefix. Write a plain descriptive title (e.g. `Add evergreen-comments skill`, not `docs: add evergreen-comments skill`). + +One title serves both packages. A PR touching both takes a single prefix, and each package's bump is decided from it independently. + +## Allowed prefixes and their bump level + +The prefix determines the version bump applied post-merge: + +| Prefix | Version bump | +| ------------------------------------------------------------------------------------------------------------- | ------------ | +| any type with a `!` (e.g. `feat!:`, `fix!:`, `feat(cli)!:`) **or** a `BREAKING CHANGE:` footer in the PR body | **major** | +| `feat:` | minor | +| `fix:`, `perf:`, `refactor:` | patch | +| `chore:`, `docs:`, `test:`, `build:`, `ci:`, `style:` | none | + +Pick the prefix from what the change actually does: diagnostics / test-only changes are `test:`, source-behavior bug fixes are `fix:`, new commands, flags, or library functions are `feat:`. A **breaking** change to a published surface takes a `!` after the type (or scope) — `feat!:`, `fix!:`, `feat(cli)!:` — or a `BREAKING CHANGE:` footer in the body, either of which forces a major bump regardless of the base type. An optional scope in parentheses (`feat(cli): …`) is permitted and does not affect the bump level on its own. + +## Why it's scoped to those two packages + +Each package owns its own prefix list and classifier, so they can diverge: + +| Package | Prefix list | Classifier | Pre-merge check | Post-merge publish | +| ---------------------- | -------------------------------------------------- | ----------------------------------------------- | ------------------------------------------ | ----------------------- | +| `@cardstack/boxel-cli` | `packages/boxel-cli/scripts/release-prefixes.json` | `packages/boxel-cli/scripts/compute-release.ts` | `.github/workflows/boxel-cli-pr-title.yml` | `boxel-cli-publish.yml` | +| `@cardstack/bxl` | `packages/bxl/scripts/release-prefixes.json` | `packages/bxl/scripts/compute-release.ts` | `.github/workflows/bxl-pr-title.yml` | `bxl-publish.yml` | + +Each pre-merge check is **path-scoped** to its own package and does not run for other PRs. Each post-merge workflow reads the merged PR's title to compute that package's bump, then publishes. + +Because each package's prefix list gates both ends, the title is a contract, not cosmetics. A PR that touches neither package never triggers any of these workflows, so a prefix on it carries no meaning and should be omitted. + +## What a bumpable prefix does not guarantee + +Both publish flows also ask whether the merge changed anything the tarball actually ships. A `fix:`-titled PR that only touches test suites, benchmarks, or CI config publishes nothing — deliberately, so a version number isn't burned on an artifact that didn't move. Check the package's `compute-release.ts` for the paths it counts. + +## Self-check before opening or retitling a PR + +1. Does the diff include any file under `packages/boxel-cli/` or `packages/bxl/`? + - **Yes** → ensure the title starts with the prefix matching the change's bump level. If the change is **breaking**, add a `!` (e.g. `feat!:`) or a `BREAKING CHANGE:` body footer so it cuts a major version. + - **No** → ensure the title has no conventional-commit prefix; use plain prose. diff --git a/.github/workflows/bxl-pr-title.yml b/.github/workflows/bxl-pr-title.yml new file mode 100644 index 00000000000..10a94a08677 --- /dev/null +++ b/.github/workflows/bxl-pr-title.yml @@ -0,0 +1,53 @@ +name: PR Title Check [bxl] + +# Validates that PRs touching packages/bxl/** carry a conventional-commit title +# (feat:, fix:, chore:, …). bxl-publish.yml reads the merged PR's title to decide +# the version bump, so this check is the contract that keeps that flow working: +# without a recognized prefix a merge publishes nothing, silently. +# +# Path-scoped — it does not run for PRs that leave bxl alone. Don't make it a +# required status check in branch protection: a required check on a +# path-filtered workflow leaves every unrelated PR pending forever. +# +# No `merge_group:` trigger — `paths:` is not honored on merge_group events, so +# adding it would block unrelated PRs in the merge queue on a bxl-scoped title +# check. If merge queues are adopted and this needs to gate them, put a +# changed-files step inside the job instead. + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + paths: + - "packages/bxl/**" + +permissions: + pull-requests: read + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + # Sparse-checkout the one file that defines the allowed prefixes. The + # post-merge classifier (bxl-publish.yml, via compute-release.ts) reads + # the same file, so the gate and the classifier stay in lockstep. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: packages/bxl/scripts/release-prefixes.json + sparse-checkout-cone-mode: false + + - id: types + run: | + { + echo 'list<<__EOF__' + node -e 'console.log(Object.keys(require("./packages/bxl/scripts/release-prefixes.json")).join("\n"))' + echo '__EOF__' + } >> "$GITHUB_OUTPUT" + + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: ${{ steps.types.outputs.list }} + requireScope: false + subjectPattern: ^.+$ diff --git a/.github/workflows/bxl-publish.yml b/.github/workflows/bxl-publish.yml new file mode 100644 index 00000000000..5bfcf27921b --- /dev/null +++ b/.github/workflows/bxl-publish.yml @@ -0,0 +1,444 @@ +name: bxl publish + +# Owns both publish paths for @cardstack/bxl: +# +# • `unstable` job +# - On push to main touching `packages/bxl/**` — decide the version bump +# from the merged PR's title (conventional-commit prefix) and whether +# the push moved anything the tarball ships, stamp the new version into +# package.json and src/index.ts, commit it back to main, tag, and +# publish `@cardstack/bxl@-unstable.` under dist-tag `unstable`. +# - Via "Run workflow" with the `confirm` field left blank — republish +# main as it stands, skipping the PR-driven bump/commit/tag dance. The +# next free `-unstable.` comes from npm (npm is the authority; the +# bump is not committed back to main), so a manual publish can neither +# clobber an existing version nor collide with the push path, which +# resolves its counter the same way. +# +# • `stable` job — "Run workflow" with `confirm = promote`. Strips +# `-unstable.` from the current version, closes out the CHANGELOG's +# `[Unreleased]` section under that version, and publishes the clean semver +# under dist-tag `latest`. The deliberate "cut a release" step. +# +# Both flows live in one file so they share a single npm Trusted-Publisher rule +# (registered against this filename). Splitting them into separate workflow +# files would require a second rule on npmjs.com. +# +# Loop safety (unstable job): +# - Bot commits end with [skip ci] so GitHub does not re-trigger workflows. +# - The job guards with `if: github.actor != 'github-actions[bot]'` (belt + +# suspenders). +# +# Concurrency: +# - `group: bxl-release` serializes this package's own releases so two runs +# can't pick the same prerelease counter or tag. +# - `queue: max` keeps the queued ones. A group holds a single pending run by +# default and cancels it when a newer one arrives, so back-to-back merges +# would silently discard a release that nothing ever retries. `queue: max` +# lets up to 100 wait their turn instead. It cannot be combined with +# `cancel-in-progress: true`, which is not wanted here anyway. +# - The group is deliberately *not* shared with the boxel-cli publish workflow. +# Serializing across packages would make each one's releases wait on the +# other's, and every wait is a checkout of a main that has moved on. The +# races that sharing would prevent — two workflows pushing a release commit +# at once — are handled where they happen, by replaying the commit onto a +# moved main (see the push below). + +on: + push: + branches: [main] + paths: + - "packages/bxl/**" + # The catalog resolves this package's `catalog:` dependency specifiers + # into the published manifest, so a change to one of its entries changes + # what ships. compute-release.ts decides whether the entries that moved + # are ones bxl depends on. + - "pnpm-workspace.yaml" + workflow_dispatch: + inputs: + confirm: + description: "Leave blank to publish current main as unstable. Type 'promote' to strip -unstable.N and publish as latest." + required: false + type: string + +permissions: + contents: write + id-token: write + pull-requests: read + +concurrency: + group: bxl-release + cancel-in-progress: false + queue: max + +jobs: + unstable: + name: Publish unstable + # Manual runs are restricted to "Use workflow from: main", so the YAML + # interpreting a publish is always main's (defense in depth — the checkout + # below also pins `ref: main`). + if: >- + github.actor != 'github-actions[bot]' + && ( + github.event_name == 'push' + || (inputs.confirm == '' && github.ref == 'refs/heads/main') + ) + runs-on: ubuntu-latest + outputs: + # Whether this run actually published, so the verify job skips when the + # push path no-op'd (no PR, or nothing the tarball ships) instead of + # re-testing a version from some earlier run. + published: ${{ steps.publish.outputs.published }} + # The exact version published. The verify job installs THIS version rather + # than the `unstable` dist-tag — a dist-tag already resolves to a prior + # release, so waiting on it can't detect the new one propagating and could + # smoke-test the previous artifact. + version: ${{ steps.publish.outputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + # fetch-tags so compute-release.ts can read bxl-v* tags. + fetch-tags: true + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: ./.github/actions/init + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch PR title from merge SHA + id: pr + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + PR_JSON=$(gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" --jq '.[0] // empty') + if [ -z "$PR_JSON" ]; then + echo "No PR associated with ${{ github.sha }} — likely a direct push. Skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""') + # Multiline-safe output via heredoc delimiter (GitHub Actions docs). + { + echo "title<<__PR_EOF__" + echo "$PR_TITLE" + echo "__PR_EOF__" + echo "body<<__PR_EOF__" + echo "$PR_BODY" + echo "__PR_EOF__" + } >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "PR title: $PR_TITLE" + + - name: Compute release + id: release + if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' + working-directory: packages/bxl + env: + PR_TITLE: ${{ steps.pr.outputs.title }} + PR_BODY: ${{ steps.pr.outputs.body }} + # The commits this push introduced. The checkout above is of `main`, + # whose tip may already have moved past this push — a run that waited + # its turn in the concurrency group, or one that started while another + # merge was landing, sees the newer commits. Without these, the + # changed-file check would describe someone else's commit and skip this + # release. + PUSH_BEFORE: ${{ github.event.before }} + PUSH_SHA: ${{ github.sha }} + run: | + set -euo pipefail + RESULT=$(NODE_NO_WARNINGS=1 node scripts/compute-release.ts) + echo "Compute result: $RESULT" + echo "bump=$(echo "$RESULT" | jq -r '.bump')" >> "$GITHUB_OUTPUT" + echo "nextVersion=$(echo "$RESULT" | jq -r '.nextVersion // ""')" >> "$GITHUB_OUTPUT" + echo "bootstrapStableTag=$(echo "$RESULT" | jq -r '.bootstrapStableTag // ""')" >> "$GITHUB_OUTPUT" + + - name: Read the tag this release follows + id: previous + if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' && steps.release.outputs.bump != 'none' + run: | + set -euo pipefail + # The nearest bxl-v* tag reachable from HEAD, which scopes the + # auto-generated release notes to just this release's commits. Nothing + # has been tagged yet at this point in the run, so HEAD is the right + # starting ref. Absent on the first release. + if TAG=$(git describe --tags --abbrev=0 --match 'bxl-v*' HEAD 2>/dev/null); then + echo "Previous tag: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + else + echo "No prior bxl-v* tag (first release); notes will cover the full history." + echo "tag=" >> "$GITHUB_OUTPUT" + fi + + - name: Stamp the version, commit, tag, and push + id: commit + if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' && steps.release.outputs.bump != 'none' + env: + BOOTSTRAP_TAG: ${{ steps.release.outputs.bootstrapStableTag }} + NEXT_VERSION: ${{ steps.release.outputs.nextVersion }} + run: | + set -euo pipefail + TAG="bxl-v${NEXT_VERSION}" + # An existing tag means an earlier run pushed it and then failed before + # publishing. The orphan needs deleting + # (`git push origin :refs/tags/`) and npm state checking before + # this can be re-run. + for existing in "$TAG" ${BOOTSTRAP_TAG:+"$BOOTSTRAP_TAG"}; do + if git rev-parse --verify --quiet "refs/tags/$existing" >/dev/null; then + echo "::error::Tag $existing already exists. A prior publish likely failed mid-flight. Delete the orphan tag and verify npm state before re-running." + exit 1 + fi + done + + # Where the base version still stands, before the bump — the commit the + # bootstrap tag belongs on, when one is needed. It stays an ancestor of + # main through any replay below. + BASE_SHA=$(git rev-parse HEAD) + + node packages/bxl/scripts/set-version.ts "$NEXT_VERSION" + git add packages/bxl/package.json packages/bxl/src/index.ts + git commit -m "chore(release): bxl v${NEXT_VERSION} [skip ci]" + + # Push the commit before creating any tag. An ordinary merge can land + # on main while this run works, and pushing tags alongside a rejected + # branch update would leave tags naming a commit main never received. + # Replaying is safe: the commit rewrites version strings and nothing + # else. + for attempt in 1 2 3; do + if git push origin HEAD:main; then + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::main moved under this run three times; nothing was published." + exit 1 + fi + echo "main moved; replaying the release commit onto it and retrying." + git fetch origin main + git rebase origin/main + done + + # Annotated tags: they carry a message, and `--follow-tags` skips + # lightweight ones silently. + git tag -a "$TAG" -m "$TAG" + if [ -n "$BOOTSTRAP_TAG" ]; then + # Nothing recorded the stable version this prerelease series builds + # on, and the manifest no longer holds it after the commit above. + git tag -a "$BOOTSTRAP_TAG" \ + -m "$BOOTSTRAP_TAG — the version this prerelease series builds on" \ + "$BASE_SHA" + git push origin "$TAG" "$BOOTSTRAP_TAG" + else + git push origin "$TAG" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Resolve the next unstable version + # Manual path only. The push path already has its version from + # compute-release.ts; this gives the manual path the same guarantee that + # the version is free on npm, without committing the bump. + if: github.event_name == 'workflow_dispatch' + working-directory: packages/bxl + run: | + set -euo pipefail + VERSION=$(NODE_NO_WARNINGS=1 node scripts/next-unstable-version.ts) + node scripts/set-version.ts "$VERSION" + + - name: Publish to npm under unstable + id: publish + if: github.event_name == 'workflow_dispatch' || (steps.pr.outputs.skip != 'true' && steps.release.outputs.bump != 'none') + working-directory: packages/bxl + run: | + set -euo pipefail + # Authentication is npm Trusted Publishing. The `id-token: write` + # permission above lets the publish exchange a GitHub OIDC token for a + # short-lived npm credential, against a rule registered on npmjs.com + # for this package that names this repository and this workflow file — + # which is why both publish paths live here. No npm token is involved. + # A rule can only be added to a package that exists, so the very first + # version is published by hand; every one after it comes from here. + # + # `prepack` builds dist/ — the tarball carries JavaScript, since Node + # will not strip types inside node_modules. See scripts/build.ts. + # --no-git-checks because the commit and tag above are already pushed; + # pnpm's git-state check would otherwise object. + pnpm publish --tag unstable --access public --provenance --no-git-checks + echo "published=true" >> "$GITHUB_OUTPUT" + echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release (prerelease) + if: github.event_name == 'push' && steps.commit.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + NEXT_VERSION: ${{ steps.release.outputs.nextVersion }} + PREVIOUS_TAG: ${{ steps.previous.outputs.tag }} + TAG: ${{ steps.commit.outputs.tag }} + run: | + set -euo pipefail + ARGS=(--title "@cardstack/bxl v${NEXT_VERSION}" --generate-notes --prerelease) + if [ -n "$PREVIOUS_TAG" ]; then + ARGS+=(--notes-start-tag "$PREVIOUS_TAG") + fi + gh release create "$TAG" "${ARGS[@]}" + + stable: + name: Promote latest unstable to stable + # Any non-empty `confirm` routes here so the validation step can fail loudly + # on a typo. Gating on `confirm == 'promote'` would make a typo skip both + # jobs and read as a successful no-op run. + if: github.event_name == 'workflow_dispatch' && inputs.confirm != '' + runs-on: ubuntu-latest + outputs: + # The promoted version, so the verify job installs it directly rather than + # the `latest` dist-tag (which resolves to the prior release until the new + # one propagates). Same reasoning as the unstable job's `version`. + version: ${{ steps.strip.outputs.version }} + steps: + - name: Validate confirmation + run: | + if [ "${{ inputs.confirm }}" != "promote" ]; then + echo "::error::Pass 'promote' as the confirm input to proceed." + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: ./.github/actions/init + + - name: Configure git + run: | + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor }}@users.noreply.github.com" + + - name: Strip -unstable.N from the version + id: strip + working-directory: packages/bxl + run: | + set -euo pipefail + CURRENT=$(node -p "require('./package.json').version") + echo "Current version: $CURRENT" + # A version with no prerelease suffix is already stable — refuse + # rather than republish identical bits under a new tag. + if [[ "$CURRENT" != *-unstable.* ]]; then + echo "::error::Version $CURRENT is already stable; nothing to promote." + exit 1 + fi + STABLE="${CURRENT%-unstable.*}" + echo "Promoting to: $STABLE" + node scripts/set-version.ts "$STABLE" + echo "version=$STABLE" >> "$GITHUB_OUTPUT" + + - name: Close out the CHANGELOG + id: notes + working-directory: packages/bxl + env: + CHANGELOG_NOTES_FILE: ${{ runner.temp }}/release-notes.md + VERSION: ${{ steps.strip.outputs.version }} + run: | + set -euo pipefail + # The date is passed in rather than read from the clock inside the + # script, so the same inputs always produce the same file. + node scripts/promote-changelog.ts "$VERSION" "$(date -u +%F)" + echo "body_file=$CHANGELOG_NOTES_FILE" >> "$GITHUB_OUTPUT" + + - name: Commit and tag stable + env: + VERSION: ${{ steps.strip.outputs.version }} + run: | + set -euo pipefail + TAG="bxl-v${VERSION}" + # An existing tag means a prior promotion pushed it and then failed + # before publishing. Delete the orphan and check npm state before + # re-running. + if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then + echo "::error::Tag $TAG already exists. A prior promotion likely failed mid-flight. Delete the orphan tag and verify npm state before re-running." + exit 1 + fi + git add packages/bxl/package.json packages/bxl/src/index.ts packages/bxl/CHANGELOG.md + git commit -m "Release @cardstack/bxl v${VERSION} [skip ci]" + + # Push the commit before tagging, and replay it if an ordinary merge + # landed on main first — same reasoning as the unstable job's push. + for attempt in 1 2 3; do + if git push origin HEAD:main; then + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::main moved under this run three times; nothing was published." + exit 1 + fi + echo "main moved; replaying the release commit onto it and retrying." + git fetch origin main + git rebase origin/main + done + + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + + - name: Publish to npm under latest + working-directory: packages/bxl + # Authenticated by npm Trusted Publishing, as in the unstable job. + run: pnpm publish --access public --provenance --no-git-checks + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + NOTES_BODY_FILE: ${{ steps.notes.outputs.body_file }} + VERSION: ${{ steps.strip.outputs.version }} + run: | + set -euo pipefail + gh release create "bxl-v${VERSION}" \ + --title "@cardstack/bxl v${VERSION}" \ + --notes-file "$NOTES_BODY_FILE" + + # --------------------------------------------------------------------------- + # Post-publish verification: install the version just published from the + # registry, as a consumer would with npm's hoisted node_modules, and check it + # from outside the monorepo — every published subpath loads under plain Node, + # a formula evaluates, a lazy formula chunk resolves, and a consumer's + # compiler finds declarations for the whole surface. + # + # This is the only place the published artifact itself is exercised. Every + # pre-merge suite reads `src/`, so none of them can tell whether the tarball's + # exports map, `files` list, or emitted JavaScript is right. + verify-unstable: + name: Verify published unstable install + needs: unstable + if: needs.unstable.outputs.published == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + - uses: ./.github/actions/init + - name: Verify the published artifact + working-directory: packages/bxl + run: NODE_NO_WARNINGS=1 node scripts/verify-package.ts --source published --version "${{ needs.unstable.outputs.version }}" + + verify-stable: + name: Verify published stable install + needs: stable + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + - uses: ./.github/actions/init + - name: Verify the published artifact + working-directory: packages/bxl + run: NODE_NO_WARNINGS=1 node scripts/verify-package.ts --source published --version "${{ needs.stable.outputs.version }}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ae6ff4b60d0..a42bbead1dd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -240,6 +240,15 @@ jobs: - name: BXL test suite run: pnpm test working-directory: packages/bxl + # The suites above read `src/`. This packs the npm artifact, installs it + # outside the monorepo the way a consumer does, and checks that every + # published subpath loads under plain Node and type-checks — the only + # pre-merge signal about the thing bxl-publish.yml ships. Unlike the + # suites, it reaches the npm registry, to install the package's own + # dependencies into the throwaway consumer project. + - name: BXL published package check + run: pnpm run verify:package + working-directory: packages/bxl eslint-plugin-boxel-test: name: ESLint Plugin Boxel Tests diff --git a/AGENTS.md b/AGENTS.md index 5a1c884c001..cf49d303b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,9 +172,9 @@ notice, but the commit still proceeds. CI lint remains the real gate, so still fix what the warning reports. Do **not** pass `git commit --no-verify` — that skips the autofix and is what lets trivial lint errors waste a CI run. -### boxel-cli commit prefixes +### Published-package commit prefixes -PRs touching `packages/boxel-cli/**` must use a conventional-commit prefix in the **PR title** (not the commit message — squash isn't used; the on-`main` workflow reads the PR title via `gh api`). The PR-title check (`.github/workflows/boxel-cli-pr-title.yml`) enforces this. +Two packages publish to npm — `packages/boxel-cli` and `packages/bxl` — and a PR touching either must use a conventional-commit prefix in the **PR title** (not the commit message — squash isn't used; the on-`main` workflows read the PR title via `gh api`). A path-scoped PR-title check per package (`.github/workflows/boxel-cli-pr-title.yml`, `.github/workflows/bxl-pr-title.yml`) enforces it. | Prefix | Bump level (per touched surface) | | ---------------------------------------------------------- | -------------------------------- | @@ -183,7 +183,9 @@ PRs touching `packages/boxel-cli/**` must use a conventional-commit prefix in th | `fix:` / `perf:` / `refactor:` | patch | | `chore:` / `docs:` / `test:` / `build:` / `ci:` / `style:` | none | -Scopes are allowed: `feat(profile): …`. Other monorepo packages are unaffected — this only applies when the PR's diff touches `packages/boxel-cli/**`. +Scopes are allowed: `feat(profile): …`. One title covers both packages when a PR touches both; each decides its own bump from it. Every other package in the monorepo is unaffected — a PR whose diff touches neither published package takes a plain descriptive title with no prefix. + +A bumpable prefix is necessary but not sufficient: each flow also asks whether the merge changed anything its tarball ships, so a `fix:` touching only tests or CI config publishes nothing. **Edge case:** bumping `BOXEL_SKILLS_VERSION` in `packages/boxel-cli/scripts/build-skills.ts` regenerates plugin skill content. Use `fix(skills):` (routine refresh) or `feat(skills):` (additive content), never `chore:` — a `chore:` prefix means no `plugin.json` bump, and the marketplace cache won't refresh for users. See `packages/boxel-cli/plugin/README.md` for full surface-scoping rules. diff --git a/packages/bxl/.eslintignore b/packages/bxl/.eslintignore new file mode 100644 index 00000000000..87ced627443 --- /dev/null +++ b/packages/bxl/.eslintignore @@ -0,0 +1,7 @@ +node_modules/ + +# Publish artifact — generated by `pnpm build` from src/, never in git. +dist/ + +# Tarballs left behind by `pnpm pack` / the package verification script. +*.tgz diff --git a/packages/bxl/.gitignore b/packages/bxl/.gitignore index e46680ebf77..bcfac3eb85c 100644 --- a/packages/bxl/.gitignore +++ b/packages/bxl/.gitignore @@ -1,2 +1,9 @@ # Local benchmark artifacts. .perf/ + +# Publish artifact — JavaScript + declarations emitted from src/ by +# `pnpm build`, shipped in the npm tarball, never committed. +dist/ + +# Tarballs from `pnpm pack` and `pnpm verify:package`. +*.tgz diff --git a/packages/bxl/CHANGELOG.md b/packages/bxl/CHANGELOG.md index 2f42f293263..00b85e0d999 100644 --- a/packages/bxl/CHANGELOG.md +++ b/packages/bxl/CHANGELOG.md @@ -10,6 +10,8 @@ versions may change syntax behavior until `1.0.0`. ## [Unreleased] +## [0.6.0] — 2026-08-18 + ### Added - **Cycle-guarded lazy card materialization in `expression()`.** The @@ -121,6 +123,23 @@ versions may change syntax behavior until `1.0.0`. - **The `bxl` and `bxl-sync` bins.** The CLI and the per-realm bundle-sync flow are not part of this package. +### Infrastructure + +- **Published to npm from the monorepo.** Merging a change to anything the + tarball ships publishes a `-unstable.` prerelease whose version comes from + the merged pull request's conventional-commit prefix; a stable version under + `latest` is cut deliberately from one of those prereleases. See the README's + "Releasing." + +- **The published package carries JavaScript.** `pnpm build` emits the sources + to `dist/` with declarations and source maps, and `publishConfig.exports` + points the published package there — an installed package lives inside + `node_modules`, where Node will not strip types, so raw TypeScript could not + load. In-repo consumers still resolve `src/` directly, unchanged. The npm + artifact is packed, installed outside the monorepo, and checked — under plain + Node and under a consumer's type-check — before merge and again after + publish. + ## [0.5.1] — 2026-08-02 ### Fixed diff --git a/packages/bxl/README.md b/packages/bxl/README.md index 046f337598a..d77d4cb7149 100644 --- a/packages/bxl/README.md +++ b/packages/bxl/README.md @@ -52,7 +52,17 @@ If you've been stitching together Ajv + jq + Formula.js + a custom rule engine, ## Install -Inside this monorepo, depend on the workspace package: +From npm: + +```sh +npm install @cardstack/bxl +``` + +Releases go out under two dist-tags: `unstable`, published as changes land, and +`latest`, cut deliberately from one of those prereleases. +`npm install @cardstack/bxl@unstable` follows the former. + +Inside this monorepo, depend on the workspace package instead: ```jsonc // package.json @@ -61,7 +71,13 @@ Inside this monorepo, depend on the workspace package: } ``` -Requires Node `>=24` — the package ships raw erasable TypeScript that Node runs via type stripping. The default entry keeps heavyweight Formula.js dependencies in lazy chunks; the `./linter` sub-entry gives editor tooling parser-only diagnostics without the formula helpers. +Requires Node `>=24`. The two forms differ in what they resolve to: the +published package serves compiled JavaScript with declarations, while the +workspace one serves the raw erasable TypeScript sources — Node runs those +directly via type stripping, and the host bundles them. Either way the imports +are the same. The default entry keeps heavyweight Formula.js dependencies in +lazy chunks; the `./linter` sub-entry gives editor tooling parser-only +diagnostics without the formula helpers. --- @@ -1076,12 +1092,45 @@ For the canonical reference on jq vs fx vs plain-string mode, see [`docs/syntax- pnpm test # every suite under tests/unit, tests/smoke, tests/boxel pnpm test tests/boxel # one directory pnpm lint # lint:js (ESLint + prettier) and lint:types (tsc --noEmit) +pnpm verify:package # pack the npm artifact and check it from outside the repo ``` -There is no build step. The package ships raw erasable TypeScript with `.ts` -import specifiers: Node runs the sources directly and the host bundles them. -A suite is a standalone entry point, so `node tests/unit/linter-cli.ts` runs -exactly one. +Development needs no build. The sources are erasable TypeScript with `.ts` +import specifiers: Node runs them directly and the host bundles them, so +nothing here is generated and no `dist/` can go stale. A suite is a standalone +entry point, so `node tests/unit/linter-cli.ts` runs exactly one. + +Publishing does need a build, because an installed package sits inside +`node_modules` where Node refuses to strip types. `pnpm build` emits JavaScript +and declarations into `dist/`, which `publishConfig.exports` points the +published package at — see [`scripts/build.ts`](./scripts/build.ts). The +version lives in two places, `package.json` and `VERSION` in `src/index.ts`; +`pnpm exec node scripts/set-version.ts ` sets both. + +### Releasing + +Merging to main publishes a prerelease under the `unstable` dist-tag. The +version comes from the merged PR's title: a conventional-commit prefix +(`feat:` minor, `fix:` / `perf:` / `refactor:` patch, a `!` or a +`BREAKING CHANGE:` footer major), and prefixes that describe no consumer-visible +change (`chore:`, `docs:`, `test:`, …) publish nothing. Neither does a merge +that leaves everything the tarball ships untouched, whatever its title — +`scripts/compute-release.ts` holds both rules, and `scripts/release-prefixes.json` +is the prefix list the pre-merge title check reads too. One shipped thing lives +outside this directory: the workspace catalog, which resolves the `catalog:` +dependency specifiers into the published manifest, so moving an entry this +package depends on counts as a change to what it ships. + +Publishing authenticates through npm Trusted Publishing — a rule on npmjs.com +naming this repository and `bxl-publish.yml`, exchanged for a short-lived +credential at publish time, with no token held anywhere. Such a rule can only be +added to a package that already exists, so the first version of a package is +published by hand and everything after it comes from the workflow. + +Cutting a stable release is a separate, manual act: run the `bxl publish` +workflow with `confirm = promote`. It strips the `-unstable.` suffix, closes +out the CHANGELOG's `[Unreleased]` section under the new version, and publishes +that version under `latest` — so keep `[Unreleased]` current as changes land. ### Layout diff --git a/packages/bxl/package.json b/packages/bxl/package.json index 650441a0c78..27d4664f0dc 100644 --- a/packages/bxl/package.json +++ b/packages/bxl/package.json @@ -1,6 +1,6 @@ { "name": "@cardstack/bxl", - "version": "0.5.1", + "version": "0.6.0", "description": "Boxel Expression Language: readable syntax, linter, formatter, and sandboxed evaluator that compiles to canonical jq.", "keywords": [ "bxl", @@ -39,6 +39,7 @@ "./*": "./src/*.ts" }, "files": [ + "dist", "src", "docs", "LICENSES", @@ -49,10 +50,24 @@ ], "publishConfig": { "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json", + "./boxel-runtime": "./dist/boxel-runtime.js", + "./compiler": "./dist/compiler.js", + "./examples": "./dist/examples/index.js", + "./linter": "./dist/linter.js", + "./mutation": "./dist/mutation/index.js", + "./runtime": "./dist/runtime.js", + "./runtime-bare": "./dist/runtime-bare.js", + "./syntax/textmate": "./dist/bxl/syntax/bxl.tmLanguage.json", + "./*": "./dist/*.js" + }, "provenance": true }, "scripts": { "bench:authorization": "node scripts/bench-authorization.ts", + "build": "node scripts/build.ts", "example:authorization": "node examples/authorization/run.ts", "example:mutation": "node tests/unit/bxl-mutation-fixtures-cli.ts", "example:mutation:realm": "node tests/unit/bxl-mutation-realm-fixtures-cli.ts", @@ -62,9 +77,11 @@ "lint:js": "eslint . --report-unused-disable-directives --cache", "lint:js:fix": "eslint . --report-unused-disable-directives --fix", "lint:types": "ember-tsc --noEmit", + "prepack": "node scripts/build.ts", "test": "node scripts/run-tests.mjs", "test:authorization:conformance": "node scripts/run-authorization-conformance.ts", - "test:authorization:performance": "node tests/unit/authorization-performance-cli.ts" + "test:authorization:performance": "node tests/unit/authorization-performance-cli.ts", + "verify:package": "node scripts/verify-package.ts" }, "dependencies": { "bessel": "catalog:", @@ -76,9 +93,11 @@ "@openfga/syntax-transformer": "catalog:", "@types/eslint": "catalog:", "@types/node": "catalog:", + "@types/semver": "^7.7.0", "concurrently": "catalog:", "esbuild": "catalog:", "eslint": "catalog:", + "semver": "^7.7.0", "typescript": "catalog:", "yaml": "catalog:" } diff --git a/packages/bxl/scripts/README.md b/packages/bxl/scripts/README.md index 11be51a6e3f..ad7f7453d92 100644 --- a/packages/bxl/scripts/README.md +++ b/packages/bxl/scripts/README.md @@ -1,10 +1,23 @@ # Scripts -| Script | Purpose | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `run-tests.mjs` | Runs every suite under `tests/unit`, `tests/smoke`, and `tests/boxel`; backs `pnpm test`. | -| `verify-authorization-fixtures.ts` | Checks the pinned OpenFGA fixture hashes and the assertion inventory; backs `pnpm fixtures:authorization:verify`. | -| `run-authorization-conformance.ts` | Executes the OpenFGA conformance corpus and reports pass/fail accounting; backs `pnpm test:authorization:conformance`. | -| `bench-authorization.ts` | Times authorization prepare/check/batch paths; backs `pnpm bench:authorization`. | +| Script | Purpose | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `run-tests.mjs` | Runs every suite under `tests/unit`, `tests/smoke`, and `tests/boxel`; backs `pnpm test`. | +| `verify-authorization-fixtures.ts` | Checks the pinned OpenFGA fixture hashes and the assertion inventory; backs `pnpm fixtures:authorization:verify`. | +| `run-authorization-conformance.ts` | Executes the OpenFGA conformance corpus and reports pass/fail accounting; backs `pnpm test:authorization:conformance`. | +| `bench-authorization.ts` | Times authorization prepare/check/batch paths; backs `pnpm bench:authorization`. | +| `build.ts` | Emits the published artifact (JavaScript + declarations) into `dist/`; backs `pnpm build` and runs on `prepack`. | +| `verify-package.ts` | Packs or downloads the npm artifact and checks it from a throwaway install; backs `pnpm verify:package`. | +| `compute-release.ts` | Decides which version a merge to main publishes, from the PR title and the changed files. | +| `next-unstable-version.ts` | Prints the next `-unstable.` free on npm for the current base, for the manual publish path. | +| `set-version.ts` | Sets the version in both places that carry it — `package.json` and `VERSION` in `src/index.ts`. | +| `promote-changelog.ts` | Closes out the CHANGELOG's `[Unreleased]` section under a version heading when a stable release is cut. | +| `release-prefixes.json` | The conventional-commit prefixes and the bump each implies; read by both the pre-merge title check and `compute-release`. | -Each `.ts` script runs directly under Node — there is no build step. +Every `.ts` script runs directly under Node — nothing here is compiled first. +The one build in the package produces the npm tarball's contents, never +anything development or the test suites read. + +The release scripts are driven by `.github/workflows/bxl-publish.yml`; the +README's [Releasing](../README.md#releasing) section describes the flow they +implement. diff --git a/packages/bxl/scripts/build.ts b/packages/bxl/scripts/build.ts new file mode 100644 index 00000000000..4f7e72ef979 --- /dev/null +++ b/packages/bxl/scripts/build.ts @@ -0,0 +1,447 @@ +#!/usr/bin/env node +/** + * Build the artifact published to npm as `@cardstack/bxl`. + * + * In-repo consumers read `src/` directly: the host's bundler compiles the + * TypeScript, and this package's own suites are run by Node, which strips the + * types as it loads them. Neither holds for an installed package — it lands + * inside `node_modules`, where Node refuses to strip types + * (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). So the tarball has to carry + * JavaScript. + * + * This script emits that JavaScript plus declarations into `dist/`. + * `publishConfig.exports` points the published package at `dist/`; the + * ordinary `exports` map still points at `src/`, so local development is + * unaffected by anything here. + * + * The steps, and what each one is for: + * + * 1. Compile `src/` under `tsconfig.build.json`. + * `rewriteRelativeImportExtensions` turns `./x.ts` specifiers into + * `./x.js` in the emitted JavaScript, dynamic imports included — that + * covers the lazy formula chunks. + * 2. Rewrite the same specifiers in the emitted declarations. TypeScript's + * rewrite applies to JavaScript emit only, so declarations keep the `.ts` + * specifiers they were written with — naming files that exist in `src/` + * but not beside the `.js` in `dist/`. TypeScript itself copes (it + * substitutes the sibling `.d.ts` for a `.ts` specifier), so this is + * about the declarations describing the tree that actually shipped, for + * every consumer that reads them without that substitution. + * 3. Blank the `/// ` lines. Those + * ambient-module shims exist to carry declarations for the untyped + * `bessel` / `jstat` / `validator` packages into whatever project + * type-checks the sources; the compiler copies the comment into the + * emitted JavaScript, where the relative path resolves to nothing. + * Blanked rather than deleted so source-map line numbering still lines + * up with the emitted files. + * 4. Copy the non-TypeScript assets the exports map serves — the TextMate + * grammar — which the compiler has no reason to emit. + * 5. Check the published surface: the published exports map is what deriving + * it from the development one gives, every target it names exists, `files` + * ships what those targets and the source maps need, and nothing in + * `dist/` still imports a `.ts` file. + * + * Run via `pnpm --filter @cardstack/bxl build`. `prepack` runs it too, so a + * `pnpm pack` or `pnpm publish` cannot ship a stale or missing `dist/`. + */ + +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +import ts from 'typescript'; + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); +const SRC_DIR = join(PACKAGE_ROOT, 'src'); +const DIST_DIR = join(PACKAGE_ROOT, 'dist'); +const CONFIG_PATH = join(PACKAGE_ROOT, 'tsconfig.build.json'); + +// A relative specifier naming a TypeScript file, as written in source and +// preserved verbatim by declaration emit. +const RELATIVE_TS_SPECIFIER = /^(\.\.?\/.*)\.([mc]?)ts$/; + +// A specifier naming a declaration file. Declarations are compiler inputs, not +// emitted beside the JavaScript, so there is no `.js` counterpart to redirect +// to — rewriting one would just name a different file that doesn't exist. +// Left alone so step 5 reports it instead. +const DECLARATION_SPECIFIER = /\.d\.[mc]?ts$/; + +const DIAGNOSTIC_HOST: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (f) => f, + getCurrentDirectory: () => PACKAGE_ROOT, + getNewLine: () => '\n', +}; + +interface PackageJson { + exports: Record; + files: string[]; + publishConfig?: { exports?: Record }; + version: string; +} + +function readPackageJson(): PackageJson { + return JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'), + ) as PackageJson; +} + +function walk(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...walk(full)); + } else { + found.push(full); + } + } + return found; +} + +// --- 1. compile --- + +function compile(): number { + const parsed = ts.getParsedCommandLineOfConfigFile(CONFIG_PATH, undefined, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error( + ts.formatDiagnostics([diagnostic], DIAGNOSTIC_HOST).trim(), + ); + }, + }); + if (!parsed) { + throw new Error(`Could not read ${CONFIG_PATH}`); + } + // A rejected option or an unmatched `include` is reported here rather than in + // the program's own diagnostics, where it would be silently ignored — and the + // emit shape is exactly what these options decide. + if (parsed.errors.length > 0) { + process.stderr.write( + ts.formatDiagnosticsWithColorAndContext(parsed.errors, DIAGNOSTIC_HOST), + ); + throw new Error(`${CONFIG_PATH} was not accepted as written`); + } + const program = ts.createProgram(parsed.fileNames, parsed.options); + + // `noEmitOnError` is a `tsc` CLI behavior, not a property of `emit()` — so + // check first and bail rather than leaving a half-written `dist/` behind. + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d) => d.category === ts.DiagnosticCategory.Error); + if (errors.length > 0) { + process.stderr.write( + ts.formatDiagnosticsWithColorAndContext(errors, DIAGNOSTIC_HOST), + ); + throw new Error(`${errors.length} type error(s); nothing emitted`); + } + + const emitted = program.emit(); + if (emitted.diagnostics.length > 0) { + process.stderr.write( + ts.formatDiagnosticsWithColorAndContext( + emitted.diagnostics, + DIAGNOSTIC_HOST, + ), + ); + throw new Error('emit reported diagnostics'); + } + return program.getRootFileNames().length; +} + +// --- 2. declaration specifiers --- + +function rewriteSpecifier(specifier: string): string | null { + if (DECLARATION_SPECIFIER.test(specifier)) { + return null; + } + const match = specifier.match(RELATIVE_TS_SPECIFIER); + return match ? `${match[1]}.${match[2]}js` : null; +} + +/** + * Every module specifier in a parsed file, as the syntax tree reports them. + * + * Both the rewrite below and the final check read specifiers from here rather + * than by searching the text, so a path written in a doc comment or held in a + * string constant is neither rewritten nor mistaken for an import. + */ +function moduleSpecifiers(source: ts.SourceFile): ts.StringLiteral[] { + const found: ts.StringLiteral[] = []; + const take = (node: ts.Node | undefined): void => { + if (node && ts.isStringLiteral(node)) { + found.push(node); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + take(node.moduleSpecifier); + } else if (ts.isImportTypeNode(node)) { + // `import('./x.ts').Type` — declaration emit's way of naming a type it + // did not need a top-level import for. + take( + ts.isLiteralTypeNode(node.argument) ? node.argument.literal : undefined, + ); + } else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + take(node.name); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword + ) { + // A dynamic import — how the lazy formula chunks load. + take(node.arguments[0]); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(source, visit); + return found; +} + +function parseFile(file: string, text: string): ts.SourceFile { + return ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + true, + file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS, + ); +} + +/** + * Rewrite every relative `.ts` module specifier in one declaration file to + * name the emitted `.js` instead. + */ +function rewriteDeclarationSpecifiers(file: string): number { + const text = readFileSync(file, 'utf8'); + const source = parseFile(file, text); + + const edits: { end: number; start: number; text: string }[] = []; + for (const node of moduleSpecifiers(source)) { + const rewritten = rewriteSpecifier(node.text); + if (rewritten === null) { + continue; + } + const start = node.getStart(source); + const quote = text[start]; + edits.push({ + start, + end: node.getEnd(), + text: `${quote}${rewritten}${quote}`, + }); + } + + if (edits.length === 0) { + return 0; + } + let updated = text; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + updated = + updated.slice(0, edit.start) + edit.text + updated.slice(edit.end); + } + writeFileSync(file, updated, 'utf8'); + return edits.length; +} + +// --- 3. reference comments --- + +function blankReferenceComments(file: string): number { + const text = readFileSync(file, 'utf8'); + let blanked = 0; + const updated = text.replace( + /^[ \t]*\/\/\/[ \t]* { + blanked++; + return ''; + }, + ); + if (blanked > 0) { + writeFileSync(file, updated, 'utf8'); + } + return blanked; +} + +// --- 4. assets --- + +/** + * Mirror the data files under `src/` into `dist/`. The TextMate grammar is + * served straight out of the exports map; the compiler only emits modules, so + * anything that isn't one has to be copied. + */ +function copyAssets(): string[] { + const copied: string[] = []; + for (const file of walk(SRC_DIR)) { + if (!file.endsWith('.json')) { + continue; + } + const rel = relative(SRC_DIR, file); + const dest = join(DIST_DIR, rel); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(file, dest); + copied.push(rel); + } + return copied; +} + +// --- 5. checks --- + +/** + * The exports map the published package serves, derived from the one this repo + * serves: `./src/x.ts` becomes `./dist/x.js`, data files keep their extension, + * and a target outside `src/` — the manifest itself — is left alone. + * + * Derived rather than loosely compared, because the two maps are otherwise + * hand-maintained mirrors of each other, and a subpath quietly wired to the + * wrong sibling looks exactly like a correct one until a consumer imports it. + */ +export function publishedExportsFor( + exports: Record, +): Record { + return Object.fromEntries( + Object.entries(exports).map(([subpath, target]) => [ + subpath, + target.startsWith('./src/') + ? target.replace(/^\.\/src\//, './dist/').replace(/\.ts$/, '.js') + : target, + ]), + ); +} + +/** + * The published surface: the exports map is the one derived above, every target + * it names is on disk, and `files` ships the directories those targets and the + * source maps depend on. + */ +function checkPublishedSurface(pkg: PackageJson): void { + const published = pkg.publishConfig?.exports; + if (!published) { + throw new Error( + 'package.json has no publishConfig.exports — the published package ' + + 'would serve raw .ts sources, which Node cannot load from node_modules', + ); + } + + const expected = publishedExportsFor(pkg.exports); + const wrong = Object.keys({ ...expected, ...published }) + .filter((subpath) => published[subpath] !== expected[subpath]) + .map( + (subpath) => + `${subpath}: ${published[subpath] ?? '(unpublished)'} — expected ` + + `${expected[subpath] ?? '(no such subpath in exports)'}`, + ); + if (wrong.length > 0) { + throw new Error( + `publishConfig.exports does not mirror exports:\n ${wrong.join('\n ')}`, + ); + } + + for (const [subpath, target] of Object.entries(published)) { + // Patterns can't be resolved to a single file; the specifier check below + // covers what they expand to. + if (subpath.includes('*') || target.includes('*')) { + continue; + } + if (!existsSync(join(PACKAGE_ROOT, target))) { + throw new Error( + `publishConfig.exports["${subpath}"] → ${target} missing`, + ); + } + } + + // `dist` is what the exports map serves. `src` is what the source maps and + // declaration maps resolve against, and what NOTICE.md's attributions name — + // without it a consumer's debugger and those references both dangle. + for (const directory of ['dist', 'src']) { + if (!pkg.files.includes(directory)) { + throw new Error(`package.json "files" does not include ${directory}`); + } + } +} + +/** + * Nothing in the emitted tree may name a `.ts` file: `dist/` holds JavaScript + * and declarations, so an import or a leftover reference path pointing at a + * TypeScript source names a file that isn't there. + * + * Imports are read from the syntax tree, so prose that mentions a `.ts` path — + * a doc comment explaining where something is parsed, say — is not mistaken for + * one. `.map` files are skipped entirely: their `sources` legitimately name the + * `src/` TypeScript, which ships alongside. + */ +function checkNoTypeScriptReferences(): void { + const offenders: string[] = []; + for (const file of walk(DIST_DIR)) { + if (file.endsWith('.map')) { + continue; + } + const text = readFileSync(file, 'utf8'); + const where = relative(PACKAGE_ROOT, file); + for (const node of moduleSpecifiers(parseFile(file, text))) { + if (/^\.\.?\//.test(node.text) && /\.[mc]?ts$/.test(node.text)) { + offenders.push(`${where}: imports ${node.text}`); + } + } + // Reference directives are comments, which the syntax tree doesn't carry. + for (const [match] of text.matchAll( + /\/\/\/[ \t]* 0) { + throw new Error( + `emitted files still reference TypeScript sources:\n ${offenders.join('\n ')}`, + ); + } +} + +// --- driver --- + +function byteSize(files: string[]): number { + return files.reduce((total, file) => total + statSync(file).size, 0); +} + +function main(): void { + const pkg = readPackageJson(); + + rmSync(DIST_DIR, { force: true, recursive: true }); + const moduleCount = compile(); + + let specifiers = 0; + let references = 0; + for (const file of walk(DIST_DIR)) { + if (file.endsWith('.d.ts')) { + specifiers += rewriteDeclarationSpecifiers(file); + } else if (file.endsWith('.js')) { + references += blankReferenceComments(file); + } + } + + const assets = copyAssets(); + + checkPublishedSurface(pkg); + checkNoTypeScriptReferences(); + + const emitted = walk(DIST_DIR); + console.log( + [ + `built @cardstack/bxl ${pkg.version}`, + `${moduleCount} modules`, + `${emitted.length} files`, + `${(byteSize(emitted) / 1024 / 1024).toFixed(1)}MB`, + `${specifiers} declaration specifiers rewritten`, + `${references} reference comments blanked`, + `${assets.length} assets copied`, + ].join('; '), + ); +} + +if (import.meta.main) { + main(); +} diff --git a/packages/bxl/scripts/compute-release.ts b/packages/bxl/scripts/compute-release.ts new file mode 100644 index 00000000000..2e51115bab5 --- /dev/null +++ b/packages/bxl/scripts/compute-release.ts @@ -0,0 +1,508 @@ +#!/usr/bin/env node +/** + * Decide what version, if any, a merge to main publishes as `@cardstack/bxl`. + * + * Two inputs drive it. The merged PR's title carries a conventional-commit + * prefix, which maps to a bump level through `release-prefixes.json` — the same + * file the pre-merge title check reads, so the gate and the classifier can't + * disagree about what a valid prefix is. The push's changed files say whether the + * *published* artifact moved at all: a package holds more than it ships, and a + * `fix:` that only touched a test suite has nothing to release. One of those + * files sits outside the package — the workspace catalog, which resolves this + * package's dependency specifiers into the published manifest. + * + * Publishes are prereleases — `-unstable.` under the `unstable` + * dist-tag. Cutting a stable release from one is a deliberate, separate act + * (the publish workflow's `promote` path). + * + * Emits JSON on stdout for the workflow to read. `computeRelease()` is pure; the + * wrapper at the bottom is what touches git, npm, and disk, so the suite in + * `tests/unit/release-cli.ts` can exercise the decisions directly. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import semver from 'semver'; + +import bumpByPrefix from './release-prefixes.json' with { type: 'json' }; + +export type BumpLevel = 'major' | 'minor' | 'patch' | 'none'; + +export interface ComputeReleaseInput { + // Whether the push moved a catalog entry this package depends on. The catalog + // lives outside the package but resolves into the published manifest, so it + // counts as published surface on its own terms. + catalogAffectsDependencies: boolean; + changedFiles: string[]; + currentVersion: string; + lastStableBase: string; + prBody: string; + prereleaseCounter: number; + prTitle: string; +} + +export interface ComputeReleaseOutput { + // A stable release tag the workflow must create alongside this prerelease, + // when nothing yet records the base the series builds on. Null once one does. + bootstrapStableTag: string | null; + bump: BumpLevel; + nextVersion: string | null; + prereleaseCounter: number; +} + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); +const PACKAGE_NAME = '@cardstack/bxl'; +const PACKAGE_DIR = 'packages/bxl'; +const TAG_PREFIX = 'bxl-v'; +const PRERELEASE_TAG = 'unstable'; +// The workspace catalog, which resolves this package's `catalog:` dependency +// specifiers at pack time — so it shapes the published manifest from outside the +// package directory. +const CATALOG_FILE = 'pnpm-workspace.yaml'; + +const CONVENTIONAL_PREFIX = /^([a-z]+)(?:\([^)]+\))?(!?):\s*/; + +const BUMP_BY_PREFIX = bumpByPrefix as Record; + +/** + * The paths whose contents reach the tarball, as `files` and the published + * exports map define it, plus the two files that shape the artifact itself. + * Everything else in the package — the test suites, the benchmarks, the + * authoring examples, the lint rules, the development tsconfig — is real work + * that changes nothing a consumer would install. + */ +const PUBLISHED_SURFACE: RegExp[] = [ + new RegExp(`^${PACKAGE_DIR}/src/`), + new RegExp(`^${PACKAGE_DIR}/docs/`), + new RegExp(`^${PACKAGE_DIR}/LICENSES/`), + new RegExp(`^${PACKAGE_DIR}/(README|CHANGELOG|NOTICE)\\.md$`), + new RegExp(`^${PACKAGE_DIR}/LICENSE$`), + new RegExp(`^${PACKAGE_DIR}/package\\.json$`), + new RegExp(`^${PACKAGE_DIR}/tsconfig(\\.build)?\\.json$`), + new RegExp(`^${PACKAGE_DIR}/scripts/build\\.ts$`), +]; + +const BUMP_RANK: Record = { + none: 0, + patch: 1, + minor: 2, + major: 3, +}; + +export function classifyBumpFromTitle( + prTitle: string, + prBody: string, +): BumpLevel { + const match = prTitle.match(CONVENTIONAL_PREFIX); + if (!match) { + return 'none'; + } + const [, prefix, bang] = match; + if (bang === '!' || /^BREAKING CHANGE:/m.test(prBody)) { + return 'major'; + } + return BUMP_BY_PREFIX[prefix] ?? 'none'; +} + +export function touchesPublishedSurface(changedFiles: string[]): boolean { + return changedFiles.some((file) => + PUBLISHED_SURFACE.some((pattern) => pattern.test(file)), + ); +} + +function parse(version: string): semver.SemVer { + const parsed = semver.parse(version); + if (!parsed) { + throw new Error(`Invalid semver: ${version}`); + } + return parsed; +} + +/** Apply a bump to the stable `major.minor.patch` of `version`. */ +function applyBump(version: string, bump: BumpLevel): string { + const { major, minor, patch } = parse(version); + const base = `${major}.${minor}.${patch}`; + return bump === 'none' ? base : semver.inc(base, bump)!; +} + +function maxBump(a: BumpLevel, b: BumpLevel): BumpLevel { + return BUMP_RANK[a] >= BUMP_RANK[b] ? a : b; +} + +export interface StableBase { + base: string; + // Whether a release tag records this base. When nothing does, the workflow + // creates one — see `bootstrapStableTag` on the output. + tagged: boolean; +} + +/** + * The stable release the current prerelease series builds on, given every + * `bxl-v*` tag that exists. + * + * Release tags are the record: a stable cut tags `bxl-v`. + * Before the first one there is nothing to read, so fall back to the manifest, + * which still holds a stable version until the first prerelease publishes — and + * report that no tag records it, because from the next merge onward the manifest + * holds a prerelease and this information would be gone. A prerelease manifest + * with no stable tag is that lost state, and nothing can recover the base from + * it, so it stops the release rather than guessing. + */ +export function resolveStableBase( + tags: string[], + currentVersion: string, +): StableBase { + const stable = tags + .filter((tag) => tag.startsWith(TAG_PREFIX)) + .map((tag) => tag.slice(TAG_PREFIX.length)) + .filter((version) => semver.valid(version) && !semver.prerelease(version)) + .sort(semver.compare); + if (stable.length > 0) { + return { base: stable[stable.length - 1], tagged: true }; + } + if (semver.prerelease(currentVersion)) { + throw new Error( + `No ${TAG_PREFIX}* stable tag exists and package.json is already at ` + + `prerelease ${currentVersion}, so the stable base it builds on is ` + + `unknowable. Tag the stable release this series started from.`, + ); + } + return { base: currentVersion, tagged: false }; +} + +/** + * Where this commit's bump lands, given the prereleases already stacked up + * since the last stable release. + * + * A prerelease base is the accumulation of every bump since that stable one, so + * it can't be bumped again from itself: three `fix:` merges in a row publish + * `0.5.2-unstable.0`, `.1`, `.2`, not `0.5.2`, `0.5.3`, `0.5.4`. Bump the + * *stable* base instead, by whichever is larger — how far the prereleases have + * already moved, or what this commit asks for. A `feat:` after those three + * fixes escalates 0.5.2 to 0.6.0; a fourth `fix:` leaves it at 0.5.2. + */ +function nextVersionFor( + currentVersion: string, + lastStableBase: string, + bump: BumpLevel, + prereleaseCounter: number, +): string { + const current = parse(currentVersion); + if (current.prerelease.length === 0) { + return `${applyBump(currentVersion, bump)}-${PRERELEASE_TAG}.${prereleaseCounter}`; + } + const currentBase = `${current.major}.${current.minor}.${current.patch}`; + const accumulated = semver.diff(lastStableBase, currentBase); + const implied: BumpLevel = + accumulated === 'major' || + accumulated === 'minor' || + accumulated === 'patch' + ? accumulated + : 'none'; + const base = applyBump(lastStableBase, maxBump(implied, bump)); + return `${base}-${PRERELEASE_TAG}.${prereleaseCounter}`; +} + +export function computeRelease( + input: ComputeReleaseInput, +): ComputeReleaseOutput { + const shipped = + touchesPublishedSurface(input.changedFiles) || + input.catalogAffectsDependencies; + const bump = shipped + ? classifyBumpFromTitle(input.prTitle, input.prBody) + : 'none'; + return { + bootstrapStableTag: null, + bump, + nextVersion: + bump === 'none' + ? null + : nextVersionFor( + input.currentVersion, + input.lastStableBase, + bump, + input.prereleaseCounter, + ), + prereleaseCounter: input.prereleaseCounter, + }; +} + +/** + * The version a manual "republish main as it stands" should take. + * + * The base is the manifest's version with any prerelease suffix dropped — except + * when that version is itself already released, which is main's state directly + * after a promotion. Reusing it would publish `0.6.0-unstable.4` *after* + * `0.6.0`, and a prerelease sorts below the release it names, so the `unstable` + * dist-tag would point at something semver considers older than `latest`. Move + * to the next patch instead. + */ +export function nextManualUnstableVersion( + manifestVersion: string, + published: unknown[], +): string { + const stripped = manifestVersion.replace( + new RegExp(`-${PRERELEASE_TAG}\\.\\d+$`), + '', + ); + const released = published.some( + (version) => version === stripped && !semver.prerelease(stripped), + ); + const base = released ? applyBump(stripped, 'patch') : stripped; + const counters = unstableCounters(base, published); + const counter = counters.length ? Math.max(...counters) + 1 : 0; + return `${base}-${PRERELEASE_TAG}.${counter}`; +} + +/** + * The `-unstable.` counters already published for `base`. Entries that + * aren't versions are dropped rather than thrown on, and comparing the parsed + * components keeps `0.3.20` distinct from `0.3.2`, which a prefix match would + * conflate. + */ +export function unstableCounters(base: string, versions: unknown[]): number[] { + const wanted = parse(base); + const counters: number[] = []; + for (const version of versions) { + if (typeof version !== 'string') { + continue; + } + const parsed = semver.parse(version); + if ( + !parsed || + parsed.major !== wanted.major || + parsed.minor !== wanted.minor || + parsed.patch !== wanted.patch + ) { + continue; + } + // semver reads a numeric prerelease identifier as a number, so + // `-unstable.` parses to `['unstable', ]`. + const [tag, counter] = parsed.prerelease; + if (tag === PRERELEASE_TAG && typeof counter === 'number') { + counters.push(counter); + } + } + return counters; +} + +// --- the parts that touch the world --- + +/** + * Run git from the repository root. The workflow invokes this script from the + * package directory, where git would resolve the `packages/bxl/` pathspec + * relative to the cwd — a path that doesn't exist there, matching no files and + * quietly reporting that nothing publishable changed. + */ +function repoRoot(): string { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); +} + +function git(...args: string[]): string { + return execFileSync('git', args, { + cwd: repoRoot(), + encoding: 'utf8', + }).trim(); +} + +/** + * The two commits bounding what the push introduced. + * + * Deliberately not the checked-out `HEAD`. The workflow checks out `main`, whose + * tip may have moved past the merge that triggered this run — a release + * workflow's own commit lands there, and a run that waited its turn in the + * concurrency group sees it. Diffing from `HEAD` would then describe someone + * else's commit and conclude this package was untouched, silently skipping a + * release. `PUSH_BEFORE` (the branch tip before the push) and `PUSH_SHA` (after) + * pin the range to this run's own event, whatever main has done since. + * + * `PUSH_BEFORE` is absent when a branch is created and can name a commit that a + * force-push has since orphaned, so it is used only once resolved; the pushed + * commit's first parent stands in otherwise. With neither — a local run — the + * checkout's own last commit is the best available guess. + */ +function pushedRange(): [string, string] { + const before = process.env.PUSH_BEFORE ?? ''; + const sha = process.env.PUSH_SHA ?? ''; + if (!sha) { + return ['HEAD^', 'HEAD']; + } + if (before && !/^0+$/.test(before) && resolvesToCommit(before)) { + return [before, sha]; + } + return [`${sha}^`, sha]; +} + +function resolvesToCommit(ref: string): boolean { + try { + // Silenced: an absent object is this function's answer, not a failure worth + // printing into the workflow log as though something went wrong. + execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { + cwd: repoRoot(), + stdio: ['ignore', 'ignore', 'ignore'], + }); + return true; + } catch { + return false; + } +} + +function changedFilesInPush([from, to]: [string, string]): string[] { + return git( + 'diff', + '--name-only', + from, + to, + '--', + `${PACKAGE_DIR}/`, + CATALOG_FILE, + ) + .split('\n') + .filter(Boolean); +} + +/** + * Whether the push moved a catalog entry this package depends on. + * + * Dependencies are declared as `catalog:` and resolved to the catalog's real + * ranges when the tarball is packed, so a catalog edit changes the published + * manifest without touching a file under the package. Only this package's own + * entries count — the catalog holds a few hundred, and someone else's bump + * changes nothing about what bxl ships. + */ +function catalogAffectsDependencies(range: [string, string]): boolean { + const [from, to] = range; + const diff = git('diff', from, to, '--', CATALOG_FILE); + if (!diff) { + return false; + } + const manifest = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'), + ); + return diffTouchesCatalogEntries( + diff, + Object.keys(manifest.dependencies ?? {}), + ); +} + +/** + * Does a unified diff of the catalog add or remove an entry for one of `names`? + * + * Catalog entries are one `name: range` mapping per line, so an added or removed + * line whose key is one of this package's dependencies is the signal. A name + * that also appears elsewhere in the file can only cause an extra release, never + * a missed one. + */ +export function diffTouchesCatalogEntries( + diff: string, + names: string[], +): boolean { + if (names.length === 0) { + return false; + } + const entry = new RegExp( + `^[+-]\\s*['"]?(${names.map(escapeForRegExp).join('|')})['"]?\\s*:`, + ); + return diff + .split('\n') + .filter((line) => !line.startsWith('+++') && !line.startsWith('---')) + .some((line) => entry.test(line)); +} + +function escapeForRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stableBase(currentVersion: string): StableBase { + return resolveStableBase( + git('tag', '--list', `${TAG_PREFIX}*`).split('\n').filter(Boolean), + currentVersion, + ); +} + +/** + * The versions npm has, which is the authority on which prerelease counters are + * taken: the workflow's manual publish path deliberately doesn't commit its + * bump, so git history alone would miss counters that exist. A registry error + * is left to fail the run — treating it as "nothing published" would restart + * the counter at 0 and collide with a real version. An unpublished package is + * the one exception, and it 404s distinguishably. + */ +export function publishedVersions(): unknown[] { + let raw: string; + try { + raw = execFileSync('npm', ['view', PACKAGE_NAME, 'versions', '--json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + const stderr = String((error as { stderr?: Buffer }).stderr ?? ''); + if (stderr.includes('E404')) { + return []; + } + throw error; + } + // `npm view … versions --json` yields an array, or a bare string when exactly + // one version is published. + return raw ? [].concat(JSON.parse(raw)) : []; +} + +function main(): void { + const prTitle = process.env.PR_TITLE ?? ''; + const prBody = process.env.PR_BODY ?? ''; + const noop: ComputeReleaseOutput = { + bootstrapStableTag: null, + bump: 'none', + nextVersion: null, + prereleaseCounter: 0, + }; + if (!prTitle) { + // A direct push to main, with no PR to read a prefix from. + process.stdout.write(JSON.stringify(noop) + '\n'); + return; + } + + const currentVersion = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'), + ).version; + const range = pushedRange(); + const base = stableBase(currentVersion); + + // Resolve the version with a placeholder counter, then take the first counter + // free for that base on npm. + const result = computeRelease({ + catalogAffectsDependencies: catalogAffectsDependencies(range), + changedFiles: changedFilesInPush(range), + currentVersion, + lastStableBase: base.base, + prBody, + prereleaseCounter: 0, + prTitle, + }); + if (result.nextVersion) { + const bumped = result.nextVersion.replace( + new RegExp(`-${PRERELEASE_TAG}\\.\\d+$`), + '', + ); + const counters = unstableCounters(bumped, publishedVersions()); + result.prereleaseCounter = counters.length ? Math.max(...counters) + 1 : 0; + result.nextVersion = `${bumped}-${PRERELEASE_TAG}.${result.prereleaseCounter}`; + // Nothing records the base this prerelease series builds on, and after this + // commit the manifest no longer holds it either. Ask the workflow to tag it + // so the next merge can still resolve it. + if (!base.tagged) { + result.bootstrapStableTag = `${TAG_PREFIX}${base.base}`; + } + } + + process.stdout.write(JSON.stringify(result) + '\n'); +} + +if (import.meta.main) { + main(); +} diff --git a/packages/bxl/scripts/next-unstable-version.ts b/packages/bxl/scripts/next-unstable-version.ts new file mode 100644 index 00000000000..8e6d5a8df9a --- /dev/null +++ b/packages/bxl/scripts/next-unstable-version.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Print the version a manual "republish main as it stands" should publish, based + * on the version in package.json and what npm already holds. + * + * This backs the publish workflow's manual path. That path deliberately doesn't + * commit its bump, so the repo can't track the prerelease counter — npm is the + * authority on which ones are taken, and reading them back is what keeps a + * manual publish from colliding with a version that already exists. + */ + +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import { + nextManualUnstableVersion, + publishedVersions, +} from './compute-release.ts'; + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); + +const manifestVersion = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'), +).version as string; + +process.stdout.write( + `${nextManualUnstableVersion(manifestVersion, publishedVersions())}\n`, +); diff --git a/packages/bxl/scripts/promote-changelog.ts b/packages/bxl/scripts/promote-changelog.ts new file mode 100644 index 00000000000..961aae720a5 --- /dev/null +++ b/packages/bxl/scripts/promote-changelog.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Close out the CHANGELOG's `[Unreleased]` section under a version heading. + * + * node scripts/promote-changelog.ts 0.6.0 2026-08-18 + * + * Run when a stable release is cut. What was unreleased becomes the record of + * that version, a fresh `[Unreleased]` opens above it, and the section's body + * is written to `$CHANGELOG_NOTES_FILE` (when set) for the GitHub release. + * + * The date is an argument rather than read from the clock, so the same inputs + * always produce the same file. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); + +const UNRELEASED_HEADING = '## [Unreleased]'; +const VERSION_HEADING = /^## \[/m; + +export interface PromotedChangelog { + changelog: string; + notes: string; +} + +/** + * Move everything under `[Unreleased]` beneath a heading for `version`. + * + * An empty section fails rather than producing a version heading with nothing + * under it: a stable release that records no changes is a gap in the log, and + * the fix is to write the entry, which no automation can do. + */ +export function promoteUnreleased( + changelog: string, + version: string, + date: string, +): PromotedChangelog { + const start = changelog.indexOf(UNRELEASED_HEADING); + if (start === -1) { + throw new Error(`CHANGELOG.md has no "${UNRELEASED_HEADING}" heading`); + } + const bodyStart = start + UNRELEASED_HEADING.length; + + // The next version heading bounds the section; without one, everything to the + // end of the file is unreleased. + const rest = changelog.slice(bodyStart); + const nextHeading = rest.search(VERSION_HEADING); + const bodyEnd = + nextHeading === -1 ? changelog.length : bodyStart + nextHeading; + + const notes = changelog.slice(bodyStart, bodyEnd).trim(); + if (notes === '') { + throw new Error( + `CHANGELOG.md's ${UNRELEASED_HEADING} section is empty — write the ` + + `entry for ${version} before cutting the release`, + ); + } + + const promoted = + `${UNRELEASED_HEADING}\n\n## [${version}] — ${date}\n\n${notes}\n\n` + + changelog.slice(bodyEnd); + return { changelog: changelog.slice(0, start) + promoted, notes }; +} + +if (import.meta.main) { + const [version, date] = process.argv.slice(2); + if (!version || !date) { + throw new Error( + 'usage: node scripts/promote-changelog.ts ', + ); + } + const path = join(PACKAGE_ROOT, 'CHANGELOG.md'); + const { changelog, notes } = promoteUnreleased( + readFileSync(path, 'utf8'), + version, + date, + ); + writeFileSync(path, changelog, 'utf8'); + const notesFile = process.env.CHANGELOG_NOTES_FILE; + if (notesFile) { + writeFileSync(notesFile, `${notes}\n`, 'utf8'); + } + console.log( + `CHANGELOG.md → [${version}] — ${date} (${notes.split('\n').length} lines)`, + ); +} diff --git a/packages/bxl/scripts/release-prefixes.json b/packages/bxl/scripts/release-prefixes.json new file mode 100644 index 00000000000..4ef91c44234 --- /dev/null +++ b/packages/bxl/scripts/release-prefixes.json @@ -0,0 +1,12 @@ +{ + "feat": "minor", + "fix": "patch", + "perf": "patch", + "refactor": "patch", + "chore": "none", + "docs": "none", + "test": "none", + "build": "none", + "ci": "none", + "style": "none" +} diff --git a/packages/bxl/scripts/set-version.ts b/packages/bxl/scripts/set-version.ts new file mode 100644 index 00000000000..613761dbc17 --- /dev/null +++ b/packages/bxl/scripts/set-version.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Set the package version. + * + * node scripts/set-version.ts 0.6.0-unstable.3 + * + * Two files carry it. `package.json` is what npm publishes under; `VERSION` in + * `src/index.ts` is what the library reports about itself at runtime, through + * `BXL_BUILD_INFO` — a consumer holding a BXL result reads that, not the + * manifest. They have to agree, so nothing sets one without the other, and + * `tests/unit/bxl-build-info.ts` fails the suite if they ever disagree. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); + +const VERSION_DECLARATION = /^export const VERSION = '[^']*';$/gm; + +// The versions this package publishes: `major.minor.patch`, optionally an +// `-unstable.` prerelease. +const PUBLISHABLE_VERSION = /^\d+\.\d+\.\d+(?:-unstable\.\d+)?$/; + +export function assertPublishableVersion(version: string): void { + if (!PUBLISHABLE_VERSION.test(version)) { + throw new Error( + `"${version}" is not a version this package publishes ` + + `(major.minor.patch[-unstable.n])`, + ); + } +} + +/** + * Rewrite the `VERSION` declaration in the entry module's source. + * + * Insisting on exactly one match is the point: this is a text edit standing in + * for a language-level guarantee, so a source tree where the declaration moved, + * changed shape, or acquired a second copy has to stop the release rather than + * quietly leave the runtime version behind. + */ +export function withVersionDeclaration( + source: string, + version: string, +): string { + assertPublishableVersion(version); + const matches = source.match(VERSION_DECLARATION) ?? []; + if (matches.length !== 1) { + throw new Error( + `expected exactly one VERSION declaration, found ${matches.length}`, + ); + } + return source.replace( + VERSION_DECLARATION, + `export const VERSION = '${version}';`, + ); +} + +export function setVersion(version: string): void { + assertPublishableVersion(version); + + const manifestPath = join(PACKAGE_ROOT, 'package.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.version = version; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + const entryPath = join(PACKAGE_ROOT, 'src', 'index.ts'); + writeFileSync( + entryPath, + withVersionDeclaration(readFileSync(entryPath, 'utf8'), version), + 'utf8', + ); +} + +if (import.meta.main) { + const version = process.argv[2]; + if (!version) { + throw new Error('usage: node scripts/set-version.ts '); + } + setVersion(version); + console.log(`version → ${version} (package.json, src/index.ts)`); +} diff --git a/packages/bxl/scripts/verify-package.ts b/packages/bxl/scripts/verify-package.ts new file mode 100644 index 00000000000..743e5f5a646 --- /dev/null +++ b/packages/bxl/scripts/verify-package.ts @@ -0,0 +1,362 @@ +#!/usr/bin/env node +/** + * Verify `@cardstack/bxl` the way an npm consumer gets it, rather than the way + * this repo gets it. + * + * Everything else that exercises BXL — the package's own suites, the host + * integration tests, the realm-server smoke — reads `src/`. None of that says + * anything about the published tarball: the exports map a consumer resolves is + * `publishConfig.exports`, the files a consumer has are whatever `files` + * shipped, and the directory a consumer loads from is inside `node_modules`, + * where Node refuses to strip types. A green suite is entirely compatible with + * a package that is dead on arrival. + * + * So: pack (or download) the artifact, `npm install` it into a throwaway + * project outside the monorepo, and check it from there. + * + * node scripts/verify-package.ts + * node scripts/verify-package.ts --source published --version 0.6.0-unstable.0 + * + * Sources: + * --source tarball (default) `pnpm pack` the working tree — `prepack` + * builds `dist/` first — then `npm install` the tarball. + * pnpm packs rather than npm because pnpm rewrites the + * `catalog:` dependency specifiers to real ranges, which + * is what `pnpm publish` ships; npm would leave them + * literal and the install would fail. + * --source published `npm install @cardstack/bxl@` from the + * registry, polling for propagation first. Checks the + * artifact that actually shipped. + * + * `npm install` — not pnpm — so dependencies land in the hoisted layout a + * real consumer has. + * + * Two checks run against the install: + * + * Runtime, under plain Node: every subpath in the published exports map + * resolves and loads, a formula evaluates end to end, a lazy formula chunk + * loads (its dynamic import is a separate resolution path, and the one most + * likely to break in a packed layout), the TextMate grammar is readable as + * JSON, and the build's self-reported version matches the package's. + * + * Types, under `nodenext` with `skipLibCheck` off: a consumer's compiler + * resolves declarations for every subpath through the exports map and walks + * the whole declaration graph. This is what catches a subpath that resolves + * at runtime but has no declarations, and a declaration reaching a file the + * `files` list never shipped — both of which leave a consumer's imports + * silently typed `any` or outright failing to compile. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import ts from 'typescript'; + +const PACKAGE_ROOT = resolve(import.meta.dirname, '..'); +const PACKAGE_NAME = '@cardstack/bxl'; + +// A concrete semver, as opposed to a dist-tag like `unstable` or `latest`. +const CONCRETE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +interface Args { + source: 'published' | 'tarball'; + version: string; +} + +function parseArgs(argv: string[]): Args { + let source = 'tarball'; + let version = 'latest'; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--source') { + source = argv[++i]; + } else if (argv[i] === '--version') { + version = argv[++i]; + } else { + throw new Error(`Unknown argument: ${argv[i]}`); + } + } + if (source !== 'tarball' && source !== 'published') { + throw new Error( + `--source must be 'tarball' or 'published' (got ${source})`, + ); + } + return { source, version }; +} + +function packTarball(destDir: string): string { + execFileSync('pnpm', ['pack', '--pack-destination', destDir], { + cwd: PACKAGE_ROOT, + stdio: 'inherit', + }); + const tarball = readdirSync(destDir).find((f) => f.endsWith('.tgz')); + if (!tarball) { + throw new Error(`pnpm pack produced no .tgz in ${destDir}`); + } + return join(destDir, tarball); +} + +/** + * Poll `npm view` until `version` resolves, absorbing the registry's + * post-publish propagation delay. + * + * Only a concrete version makes this meaningful: `npm view pkg@1.2.3` errors + * until that exact version exists, so the poll waits for it. A dist-tag + * already resolves to some earlier release, so the poll would return + * immediately and the checks could run against the *previous* artifact. + */ +function waitForPublishedVersion(version: string): void { + if (!CONCRETE_VERSION.test(version)) { + console.warn( + `WARNING: "${version}" is a dist-tag, not a concrete version. This ` + + `cannot confirm a freshly published version has propagated — npm may ` + + `resolve it to an older release. Pass the exact version for a ` + + `trustworthy post-publish check.`, + ); + } + const deadline = Date.now() + 180_000; + let attempt = 0; + for (;;) { + const result = spawnSync( + 'npm', + ['view', `${PACKAGE_NAME}@${version}`, 'version'], + { encoding: 'utf8' }, + ); + if (result.status === 0 && result.stdout.trim()) { + console.log( + `resolved ${PACKAGE_NAME}@${version} → ${result.stdout.trim()}`, + ); + return; + } + if (Date.now() > deadline) { + throw new Error( + `${PACKAGE_NAME}@${version} not resolvable after 180s. Last npm error:\n${result.stderr}`, + ); + } + const delay = Math.min(15_000, 2_000 * ++attempt); + console.log(`waiting for ${PACKAGE_NAME}@${version} to propagate…`); + execFileSync('sleep', [String(delay / 1000)]); + } +} + +/** + * A throwaway consumer project outside the monorepo — outside so no ambient + * workspace `node_modules`, tsconfig, or pnpm link can stand in for something + * the tarball was supposed to provide. + */ +function installConsumer(spec: string): string { + const dir = mkdtempSync(join(tmpdir(), 'bxl-consumer-')); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: 'bxl-consumer', + version: '0.0.0', + private: true, + type: 'module', + }, + null, + 2, + ) + '\n', + ); + execFileSync( + 'npm', + ['install', spec, '--no-audit', '--no-fund', '--loglevel', 'error'], + { cwd: dir, stdio: 'inherit' }, + ); + return dir; +} + +interface Subpath { + // JSON is served as data, so it needs an import attribute at every reference + // — including the generated checks. + json: boolean; + specifier: string; +} + +/** + * Every non-pattern subpath of the published exports map, so the checks below + * cover the surface the package promises rather than a hand-picked few. + */ +function publishedSubpaths(): Subpath[] { + const pkg = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'), + ); + const map: Record = pkg.publishConfig?.exports ?? {}; + return Object.entries(map) + .filter( + ([subpath]) => !subpath.includes('*') && subpath !== './package.json', + ) + .map(([subpath, target]) => ({ + json: target.endsWith('.json'), + specifier: + subpath === '.' ? PACKAGE_NAME : `${PACKAGE_NAME}/${subpath.slice(2)}`, + })) + .sort((a, b) => a.specifier.localeCompare(b.specifier)); +} + +const RUNTIME_CHECK = (subpaths: Subpath[]) => ` +import { createRequire } from 'node:module'; +import { strictEqual, ok } from 'node:assert'; + +import { BXL_BUILD_INFO, VERSION, evaluateBxl } from '${PACKAGE_NAME}'; +import { runNativeJqAsync } from '${PACKAGE_NAME}/runtime'; +import grammar from '${PACKAGE_NAME}/syntax/textmate' with { type: 'json' }; + +// Every subpath the published exports map serves, loaded through the map the +// way a consumer reaches it. A subpath whose target the tarball never shipped +// fails here with ERR_MODULE_NOT_FOUND. +for (const { specifier, json } of ${JSON.stringify(subpaths)}) { + const loaded = json + ? await import(specifier, { with: { type: 'json' } }) + : await import(specifier); + ok( + Object.keys(loaded).length > 0, + \`\${specifier} resolved but exported nothing\`, + ); +} + +// A formula end to end: readable syntax → jq → evaluated result. +const evaluated = evaluateBxl('ROUND(Subtotal * TaxRate / 100, 2)', { + subtotal: 50, + taxRate: 8.25, +}); +strictEqual(evaluated.value, 4.13, 'formula evaluated to the expected value'); + +// A lazy formula chunk. These load by dynamic import at call time, which +// resolves separately from the static import graph above — and is the part a +// packed layout is most likely to break. +const lazy = await runNativeJqAsync('NORM.DIST(42, 40, 1.5, true)', {}); +ok( + Math.abs(lazy.outputs[0] - 0.9087887802741321) < 1e-12, + \`statistical chunk returned \${lazy.outputs[0]}\`, +); + +// The TextMate grammar is a data file, not a module — it only reaches the +// tarball if the build copies it. Imported statically here, the way a consumer +// writes it, and checked for real content rather than mere resolvability. +ok(grammar.scopeName, 'TextMate grammar has a scopeName'); + +// The version the build reports about itself is the version that was +// published. These live in two files and drift silently otherwise. +const require = createRequire(import.meta.url); +const installed = require('${PACKAGE_NAME}/package.json'); +strictEqual(VERSION, installed.version, 'VERSION matches package.json'); +strictEqual(BXL_BUILD_INFO.version, installed.version, 'build info matches'); + +console.log( + \`runtime OK: ${subpaths.length} subpaths, version \${installed.version}\`, +); +`; + +const TYPE_CHECK = (subpaths: Subpath[]) => ` +${subpaths + .map(({ specifier, json }, index) => + json + ? `import m${index} from '${specifier}' with { type: 'json' };` + : `import * as m${index} from '${specifier}';`, + ) + .join('\n')} +import { evaluateBxl, type BxlEvaluation } from '${PACKAGE_NAME}'; +import { compileReadableSyntax } from '${PACKAGE_NAME}/compiler'; +import { lintBxlExpression } from '${PACKAGE_NAME}/linter'; + +// Annotated deliberately: an inferred type would let a broken declaration +// degrade to \`any\` and still compile. +const evaluation: BxlEvaluation = evaluateBxl('1 + 1', {}); +const compiled: string = compileReadableSyntax('Subtotal * 2').source; +const issues: number = lintBxlExpression('Subtotal *').issues.length; + +export const surface = [ +${subpaths.map((_, index) => ` m${index},`).join('\n')} + evaluation.value, + compiled, + issues, +]; +`; + +function runNodeCheck(consumerDir: string, subpaths: Subpath[]): void { + const file = join(consumerDir, 'runtime-check.mjs'); + writeFileSync(file, RUNTIME_CHECK(subpaths), 'utf8'); + const result = spawnSync(process.execPath, [file], { + cwd: consumerDir, + stdio: 'inherit', + }); + if (result.status !== 0) { + throw new Error('runtime check failed'); + } +} + +/** + * Type-check a consumer module against the installed declarations, with the + * settings a consumer plausibly has: `nodenext` resolution (so the exports map + * governs), `strict`, and `skipLibCheck` off so errors inside the package's own + * declarations are reported rather than swallowed. + */ +function runTypeCheck(consumerDir: string, subpaths: Subpath[]): void { + const file = join(consumerDir, 'type-check.ts'); + writeFileSync(file, TYPE_CHECK(subpaths), 'utf8'); + + const options: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022, + noEmit: true, + resolveJsonModule: true, + skipLibCheck: false, + strict: true, + types: [], + }; + const program = ts.createProgram([file], options); + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((d) => d.category === ts.DiagnosticCategory.Error); + if (diagnostics.length > 0) { + process.stderr.write( + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (f) => f, + getCurrentDirectory: () => consumerDir, + getNewLine: () => '\n', + }), + ); + throw new Error(`type check failed with ${diagnostics.length} error(s)`); + } + console.log(`types OK: ${subpaths.length} subpaths resolve declarations`); +} + +function main(): void { + const { source, version } = parseArgs(process.argv.slice(2)); + const subpaths = publishedSubpaths(); + + const workDir = mkdtempSync(join(tmpdir(), 'bxl-pack-')); + const cleanup = [workDir]; + try { + let spec: string; + if (source === 'tarball') { + spec = packTarball(workDir); + } else { + waitForPublishedVersion(version); + spec = `${PACKAGE_NAME}@${version}`; + } + + const consumerDir = installConsumer(spec); + cleanup.push(consumerDir); + + runNodeCheck(consumerDir, subpaths); + runTypeCheck(consumerDir, subpaths); + console.log(`${PACKAGE_NAME} verified from ${source}`); + } finally { + for (const dir of cleanup) { + rmSync(dir, { force: true, recursive: true }); + } + } +} + +main(); diff --git a/packages/bxl/src/index.ts b/packages/bxl/src/index.ts index 113d174ce62..26b045e632e 100644 --- a/packages/bxl/src/index.ts +++ b/packages/bxl/src/index.ts @@ -151,7 +151,7 @@ export type { PreparedBoxelRuntime, } from './boxel-runtime.ts'; -export const VERSION = '0.5.1'; +export const VERSION = '0.6.0'; /** * Runtime identity: the version plus the set of behaviors this build of the @@ -168,7 +168,7 @@ export const VERSION = '0.5.1'; * * console.log(BXL_BUILD_INFO); * // { - * // version: '0.5.1', + * // version: , * // features: ['null-tolerance', 'jq-fx-tags', 'as-materialize', * // 'pascalcase-fallback', 'jq-keywords-guard', ...], * // } diff --git a/packages/bxl/tests/unit/bxl-build-info.ts b/packages/bxl/tests/unit/bxl-build-info.ts index f45cb7a3e13..82007d9d474 100644 --- a/packages/bxl/tests/unit/bxl-build-info.ts +++ b/packages/bxl/tests/unit/bxl-build-info.ts @@ -1,11 +1,21 @@ // Smoke check that BXL_BUILD_INFO has the expected shape and that the // feature list isn't accidentally empty. +import { readFileSync } from 'node:fs'; import { ok, strictEqual } from 'node:assert'; import { BXL_BUILD_INFO, VERSION } from '../../src/index.ts'; strictEqual(BXL_BUILD_INFO.version, VERSION, 'version mirrors VERSION'); +// The runtime version and the published version are separate declarations, and +// a release bumps both (scripts/set-version.ts). Drift would ship a package +// that misreports itself to any consumer reading BXL_BUILD_INFO, so hold them +// equal here rather than trusting the release path to have done it. +const manifestVersion = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +).version; +strictEqual(VERSION, manifestVersion, 'VERSION mirrors package.json'); + ok(Array.isArray(BXL_BUILD_INFO.features)); ok(BXL_BUILD_INFO.features.length > 0, 'features list is non-empty'); diff --git a/packages/bxl/tests/unit/publish-cli.ts b/packages/bxl/tests/unit/publish-cli.ts new file mode 100644 index 00000000000..1215c42bfc6 --- /dev/null +++ b/packages/bxl/tests/unit/publish-cli.ts @@ -0,0 +1,72 @@ +// The published package's exports map: the subpaths an installed +// `@cardstack/bxl` serves, and how they are derived from the ones this repo +// serves. +// +// `scripts/build.ts` asserts the same invariant, but only when a build runs. +// Checking it here means ordinary `pnpm test` catches a subpath added to one map +// and forgotten in the other. + +import { deepStrictEqual, strictEqual } from 'node:assert'; +import { readFileSync } from 'node:fs'; + +import { publishedExportsFor } from '../../scripts/build.ts'; + +let checks = 0; + +const manifest = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); + +// The invariant itself: what the published package serves is what deriving the +// development map gives, entry for entry — not merely the same subpaths. +checks++; +deepStrictEqual( + manifest.publishConfig.exports, + publishedExportsFor(manifest.exports), + 'publishConfig.exports mirrors exports', +); + +// Every subpath a consumer can reach resolves to something the tarball ships. +for (const [subpath, target] of Object.entries( + manifest.publishConfig.exports, +)) { + checks++; + const shipped = + target === './package.json' || + manifest.files.some( + (entry: string) => + target.startsWith(`./${entry}/`) || target === `./${entry}`, + ); + strictEqual(shipped, true, `${subpath} → ${target} is covered by "files"`); +} + +// The derivation, on its own terms. +checks++; +deepStrictEqual( + publishedExportsFor({ + '.': './src/index.ts', + './package.json': './package.json', + './mutation': './src/mutation/index.ts', + './syntax/textmate': './src/bxl/syntax/bxl.tmLanguage.json', + './*': './src/*.ts', + }), + { + '.': './dist/index.js', + './package.json': './package.json', + './mutation': './dist/mutation/index.js', + // A data file moves but keeps its extension — the compiler doesn't emit it, + // the build copies it. + './syntax/textmate': './dist/bxl/syntax/bxl.tmLanguage.json', + // The wildcard is a pattern in both maps, and translates like any other. + './*': './dist/*.js', + }, +); + +// Only `src/` moves. A target that already points elsewhere is left alone, so +// the derivation can't invent a `dist/` path for something that never had one. +checks++; +deepStrictEqual(publishedExportsFor({ './docs': './docs/README.md' }), { + './docs': './docs/README.md', +}); + +console.log(`published exports map: ${checks} checks passed`); diff --git a/packages/bxl/tests/unit/release-cli.ts b/packages/bxl/tests/unit/release-cli.ts new file mode 100644 index 00000000000..87a21b630ea --- /dev/null +++ b/packages/bxl/tests/unit/release-cli.ts @@ -0,0 +1,537 @@ +// The release decisions behind the npm publish: which merges publish, what +// version they publish as, and which prerelease counter is free. +// +// These run on the pure functions in scripts/compute-release.ts, with no git, +// npm, or filesystem in the way. Getting them wrong is expensive in a way a +// failed build is not — a published version can be deprecated but never +// replaced, and a wrong one either skips a release consumers are waiting on or +// burns a version number on nothing. + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; + +import { + classifyBumpFromTitle, + computeRelease, + diffTouchesCatalogEntries, + nextManualUnstableVersion, + resolveStableBase, + touchesPublishedSurface, + unstableCounters, + type BumpLevel, +} from '../../scripts/compute-release.ts'; +import { promoteUnreleased } from '../../scripts/promote-changelog.ts'; +import { withVersionDeclaration } from '../../scripts/set-version.ts'; + +let checks = 0; + +function bumpFor(title: string, body = ''): BumpLevel { + checks++; + return classifyBumpFromTitle(title, body); +} + +// --- prefix → bump level --- + +strictEqual(bumpFor('feat: add ROUNDBANK'), 'minor'); +strictEqual(bumpFor('fix: NORM.DIST rejects a zero deviation'), 'patch'); +strictEqual(bumpFor('perf: memoize the compiled program'), 'patch'); +strictEqual(bumpFor('refactor: split the registry'), 'patch'); +strictEqual(bumpFor('chore: tidy the fixtures'), 'none'); +strictEqual(bumpFor('docs: describe the derive profile'), 'none'); +strictEqual(bumpFor('test: cover the mutation planner'), 'none'); + +// A scope is part of the convention and does not change the level. +strictEqual(bumpFor('fix(compiler): accept a trailing comma'), 'patch'); + +// Either way of declaring a breaking change is a major. +strictEqual(bumpFor('feat!: drop the legacy jq tag'), 'major'); +strictEqual(bumpFor('fix(runtime)!: reject bare identifiers'), 'major'); +strictEqual( + bumpFor('fix: reject bare identifiers', 'BREAKING CHANGE: bare identifiers'), + 'major', +); +// The footer only counts at the start of a line, not quoted mid-sentence. +strictEqual( + bumpFor('fix: a fix', 'Not a BREAKING CHANGE: just discussing one'), + 'patch', +); + +// No prefix, or one that isn't ours, publishes nothing. Silence is the safe +// direction: a title that doesn't ask for a release doesn't get one. +strictEqual(bumpFor('Add ROUNDBANK'), 'none'); +strictEqual(bumpFor('wip: still working'), 'none'); +strictEqual(bumpFor('FEAT: shouting'), 'none'); + +// --- changed files → does the artifact move --- + +const surfaceCases: [string, boolean][] = [ + ['packages/bxl/src/index.ts', true], + ['packages/bxl/src/formulajs/statistical.ts', true], + ['packages/bxl/docs/syntax-reference.md', true], + ['packages/bxl/README.md', true], + ['packages/bxl/NOTICE.md', true], + ['packages/bxl/CHANGELOG.md', true], + ['packages/bxl/LICENSE', true], + ['packages/bxl/LICENSES/Apache-2.0.txt', true], + ['packages/bxl/package.json', true], + ['packages/bxl/tsconfig.json', true], + ['packages/bxl/tsconfig.build.json', true], + ['packages/bxl/scripts/build.ts', true], + // Real work that ships nothing. + ['packages/bxl/tests/unit/bxl-formula-cli.ts', false], + ['packages/bxl/examples/authorization/run.ts', false], + ['packages/bxl/eslint-rules/no-bare-identifier.js', false], + ['packages/bxl/scripts/run-tests.mjs', false], + ['packages/bxl/scripts/compute-release.ts', false], + ['packages/bxl/.eslintignore', false], + // Another package's src is not this one's. + ['packages/host/src/index.ts', false], + ['packages/boxel-cli/src/index.ts', false], +]; +for (const [file, expected] of surfaceCases) { + checks++; + strictEqual( + touchesPublishedSurface([file]), + expected, + `${file} → ${expected ? 'publishes' : 'does not publish'}`, + ); +} + +// One shipping file among many that don't is still a release. +checks++; +strictEqual( + touchesPublishedSurface([ + 'packages/bxl/tests/unit/bxl-formula-cli.ts', + 'packages/bxl/src/formulajs/financial.ts', + ]), + true, +); + +// --- the two together --- + +const stableBase = '0.5.1'; +const shipping = ['packages/bxl/src/index.ts']; +const notShipping = ['packages/bxl/tests/unit/bxl-formula-cli.ts']; + +function release( + prTitle: string, + changedFiles: string[], + currentVersion: string, + prereleaseCounter = 0, + lastStableBase = stableBase, + catalogAffectsDependencies = false, +) { + checks++; + return computeRelease({ + catalogAffectsDependencies, + changedFiles, + currentVersion, + lastStableBase, + prBody: '', + prereleaseCounter, + prTitle, + }); +} + +// From a stable version, the first prerelease of the bumped base. +deepStrictEqual(release('feat: add ROUNDBANK', shipping, '0.5.1'), { + bootstrapStableTag: null, + bump: 'minor', + nextVersion: '0.6.0-unstable.0', + prereleaseCounter: 0, +}); +deepStrictEqual(release('fix: correct NORM.DIST', shipping, '0.5.1'), { + bootstrapStableTag: null, + bump: 'patch', + nextVersion: '0.5.2-unstable.0', + prereleaseCounter: 0, +}); +deepStrictEqual(release('feat!: drop the legacy tag', shipping, '0.5.1'), { + bootstrapStableTag: null, + bump: 'major', + nextVersion: '1.0.0-unstable.0', + prereleaseCounter: 0, +}); + +// A bumpable prefix that ships nothing, and a shipping change that doesn't ask +// for a release, both publish nothing. +deepStrictEqual(release('feat: add a test helper', notShipping, '0.5.1'), { + bootstrapStableTag: null, + bump: 'none', + nextVersion: null, + prereleaseCounter: 0, +}); +deepStrictEqual(release('chore: reword a comment', shipping, '0.5.1'), { + bootstrapStableTag: null, + bump: 'none', + nextVersion: null, + prereleaseCounter: 0, +}); + +// Already on a prerelease: the base is the accumulation of everything since the +// last stable release, so a same-or-smaller bump holds it steady and only the +// counter moves. +deepStrictEqual(release('fix: another fix', shipping, '0.5.2-unstable.0', 1), { + bootstrapStableTag: null, + bump: 'patch', + nextVersion: '0.5.2-unstable.1', + prereleaseCounter: 1, +}); +deepStrictEqual(release('fix: a third fix', shipping, '0.6.0-unstable.4', 5), { + bootstrapStableTag: null, + bump: 'patch', + nextVersion: '0.6.0-unstable.5', + prereleaseCounter: 5, +}); + +// A larger bump escalates the base, keeping the counter npm handed us. +deepStrictEqual( + release('feat: add ROUNDBANK', shipping, '0.5.2-unstable.2', 3), + { + bootstrapStableTag: null, + bump: 'minor', + nextVersion: '0.6.0-unstable.3', + prereleaseCounter: 3, + }, +); +deepStrictEqual(release('feat!: breaking', shipping, '0.6.0-unstable.1', 2), { + bootstrapStableTag: null, + bump: 'major', + nextVersion: '1.0.0-unstable.2', + prereleaseCounter: 2, +}); + +// The base is computed from the last stable release, not from the prerelease +// itself — otherwise every merge would ratchet the base forward again. +deepStrictEqual( + release('fix: a fix', shipping, '1.0.0-unstable.7', 8, '0.9.3'), + { + bootstrapStableTag: null, + bump: 'patch', + nextVersion: '1.0.0-unstable.8', + prereleaseCounter: 8, + }, +); + +checks++; +throws( + () => release('feat: add ROUNDBANK', shipping, 'not-a-version'), + /Invalid semver/, + 'an unparseable current version fails loudly rather than guessing', +); + +// --- the workspace catalog as published surface --- + +// A catalog entry this package depends on resolves into the published manifest, +// so it releases even though no file under the package changed. +deepStrictEqual( + release('fix: pick up the validator fix', [], '0.5.1', 0, stableBase, true), + { + bootstrapStableTag: null, + bump: 'patch', + nextVersion: '0.5.2-unstable.0', + prereleaseCounter: 0, + }, +); +// Still gated by the prefix, like any other change. +deepStrictEqual( + release('chore: bump the catalog', [], '0.5.1', 0, stableBase, true), + { + bootstrapStableTag: null, + bump: 'none', + nextVersion: null, + prereleaseCounter: 0, + }, +); + +const catalogDiff = (lines: string[]) => + ['--- a/pnpm-workspace.yaml', '+++ b/pnpm-workspace.yaml', ...lines].join( + '\n', + ); +const bxlDeps = ['bessel', 'jstat', 'validator']; + +checks++; +strictEqual( + diffTouchesCatalogEntries( + catalogDiff(['- validator: ^13.15.35', '+ validator: ^13.16.0']), + bxlDeps, + ), + true, +); +checks++; +strictEqual( + diffTouchesCatalogEntries( + catalogDiff(["- 'jstat': ^1.9.6", "+ 'jstat': ^1.9.7"]), + bxlDeps, + ), + true, + 'a quoted key is the same entry', +); +// Someone else's dependency moving changes nothing about what this package +// ships, and the catalog holds a few hundred of them. +checks++; +strictEqual( + diffTouchesCatalogEntries( + catalogDiff(['- eslint: ^8.57.1', '+ eslint: ^9.0.0']), + bxlDeps, + ), + false, +); +// The `---`/`+++` file headers name the file, not an entry. +checks++; +strictEqual(diffTouchesCatalogEntries(catalogDiff([]), bxlDeps), false); +// A name appearing as a substring of another entry is not that entry. +checks++; +strictEqual( + diffTouchesCatalogEntries( + catalogDiff(['+ validator-extra: ^1.0.0', '+ "@types/jstat": ^1.0.0']), + bxlDeps, + ), + false, +); +checks++; +strictEqual( + diffTouchesCatalogEntries(catalogDiff(['+ bessel: ^1.0.3']), []), + false, + 'a package with no dependencies has no catalog entries to watch', +); + +// --- prerelease counters already taken on npm --- + +const published = [ + '0.5.0', + '0.5.1', + '0.5.2-unstable.0', + '0.5.2-unstable.1', + '0.5.2-unstable.3', + '0.6.0-unstable.0', + '0.3.20-unstable.4', +]; + +checks++; +deepStrictEqual(unstableCounters('0.5.2', published), [0, 1, 3]); +checks++; +deepStrictEqual(unstableCounters('0.6.0', published), [0]); +checks++; +deepStrictEqual(unstableCounters('9.9.9', published), []); + +// A patch of 20 is not a patch of 2 — the comparison is on parsed components, +// not on the string. +checks++; +deepStrictEqual(unstableCounters('0.3.2', published), []); +checks++; +deepStrictEqual(unstableCounters('0.3.20', published), [4]); + +// A registry that answers with something unexpected doesn't take the run down. +checks++; +deepStrictEqual( + unstableCounters('0.5.2', [ + null, + 42, + {}, + 'not-a-version', + '0.5.2-unstable.9', + ]), + [9], +); + +// A stable release and a differently-tagged prerelease are not counters. +checks++; +deepStrictEqual(unstableCounters('0.5.1', ['0.5.1', '0.5.1-beta.0']), []); + +// --- the stable release a prerelease series builds on --- + +// Tagged releases are the record, and the highest stable one wins regardless of +// the order tags come back in. +checks++; +deepStrictEqual( + resolveStableBase( + ['bxl-v0.4.2', 'bxl-v0.5.1', 'bxl-v0.5.0', 'bxl-v0.6.0-unstable.3'], + '0.6.0-unstable.4', + ), + { base: '0.5.1', tagged: true }, +); +checks++; +deepStrictEqual( + resolveStableBase(['bxl-v0.9.0', 'bxl-v0.10.0'], '0.10.1-unstable.0'), + { base: '0.10.0', tagged: true }, + '0.10.0 is later than 0.9.0 — compared as versions, not as strings', +); +// Tags for other packages share the repo and must not be read as this one's. +checks++; +deepStrictEqual( + resolveStableBase(['boxel-cli-v1.2.3', 'bxl-v0.5.1'], '0.5.2-unstable.0'), + { base: '0.5.1', tagged: true }, +); + +// Before the first release there is no tag, and the manifest still holds the +// base — but nothing records it, so the workflow is asked to tag it. +checks++; +deepStrictEqual(resolveStableBase([], '0.5.1'), { + base: '0.5.1', + tagged: false, +}); +// A prerelease tag is not a release, so it does not answer the question either. +checks++; +deepStrictEqual(resolveStableBase(['bxl-v0.6.0-unstable.0'], '0.5.1'), { + base: '0.5.1', + tagged: false, +}); + +// The state that has no answer: prereleases have moved the manifest off the base +// and no tag kept it. Guessing here would either ratchet the version on every +// merge or silently under-bump, so it stops instead. +checks++; +throws( + () => resolveStableBase(['bxl-v0.6.0-unstable.0'], '0.6.0-unstable.0'), + /stable base it builds on is\s+unknowable/, +); +checks++; +throws(() => resolveStableBase([], '0.6.0-unstable.1'), /unknowable/); + +// --- the version a manual republish takes --- + +// Mid-series: the base holds and the counter advances past what npm has. +checks++; +strictEqual( + nextManualUnstableVersion('0.6.0-unstable.3', [ + '0.5.1', + '0.6.0-unstable.0', + '0.6.0-unstable.3', + ]), + '0.6.0-unstable.4', +); +// From a stable manifest that was never published, the base is free to use. +checks++; +strictEqual(nextManualUnstableVersion('0.5.1', []), '0.5.1-unstable.0'); + +// Directly after a promotion the manifest holds a released version. Publishing +// `0.6.0-unstable.4` then would order the `unstable` tag *below* `latest`, since +// a prerelease sorts before the release it names — so move to the next patch. +checks++; +strictEqual( + nextManualUnstableVersion('0.6.0', [ + '0.6.0', + '0.6.0-unstable.0', + '0.6.0-unstable.3', + ]), + '0.6.1-unstable.0', +); +checks++; +strictEqual( + nextManualUnstableVersion('0.6.0', ['0.6.0', '0.6.1-unstable.0']), + '0.6.1-unstable.1', + 'and it keeps counting from whatever that next base already published', +); + +// --- stamping the version into the entry module --- + +const entry = [ + "export const NAME = 'bxl';", + "export const VERSION = '0.5.1';", + 'export const OTHER = 1;', +].join('\n'); + +checks++; +strictEqual( + withVersionDeclaration(entry, '0.6.0-unstable.3'), + [ + "export const NAME = 'bxl';", + "export const VERSION = '0.6.0-unstable.3';", + 'export const OTHER = 1;', + ].join('\n'), +); + +checks++; +throws( + () => withVersionDeclaration('export const NAME = 1;', '0.6.0'), + /found 0/, + 'a source without the declaration stops the release', +); +checks++; +throws( + () => withVersionDeclaration(`${entry}\n${entry}`, '0.6.0'), + /found 2/, + 'a duplicated declaration stops the release', +); +checks++; +throws( + () => withVersionDeclaration(entry, 'v0.6.0'), + /not a version this package publishes/, +); +checks++; +throws( + () => withVersionDeclaration(entry, '0.6.0-beta.1'), + /not a version this package publishes/, + 'only the unstable prerelease tag is ours to publish', +); + +// --- closing out the changelog on a stable cut --- + +const changelog = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '- A thing.', + '', + '## [0.5.1] — 2026-08-02', + '', + '- An older thing.', + '', +].join('\n'); + +const promoted = promoteUnreleased(changelog, '0.6.0', '2026-08-18'); +checks++; +strictEqual( + promoted.changelog, + [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [0.6.0] — 2026-08-18', + '', + '### Added', + '', + '- A thing.', + '', + '## [0.5.1] — 2026-08-02', + '', + '- An older thing.', + '', + ].join('\n'), +); +checks++; +strictEqual(promoted.notes, '### Added\n\n- A thing.'); + +// The section runs to the end of the file when no release has been recorded yet. +checks++; +strictEqual( + promoteUnreleased( + '# Changelog\n\n## [Unreleased]\n\n- The first thing.\n', + '0.1.0', + '2026-08-18', + ).notes, + '- The first thing.', +); + +checks++; +throws( + () => + promoteUnreleased( + '# Changelog\n\n## [Unreleased]\n\n## [0.5.1] — 2026-08-02\n', + '0.6.0', + '2026-08-18', + ), + /section is empty/, + 'a release with nothing recorded stops rather than shipping a bare heading', +); +checks++; +throws( + () => promoteUnreleased('# Changelog\n', '0.6.0', '2026-08-18'), + /no "## \[Unreleased\]" heading/, +); + +console.log(`release decisions: ${checks} checks passed`); diff --git a/packages/bxl/tsconfig.build.json b/packages/bxl/tsconfig.build.json new file mode 100644 index 00000000000..925104af9ca --- /dev/null +++ b/packages/bxl/tsconfig.build.json @@ -0,0 +1,28 @@ +{ + // Emit configuration for the published npm artifact. `tsconfig.json` is the + // development one: `noEmit`, and `.ts` specifiers resolved in place. Here + // the same sources compile to `dist/` — see scripts/build.ts for why the + // tarball cannot carry raw TypeScript. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + // Publishing output built from failing sources is worse than publishing + // nothing, so refuse to emit on a type error. + "noEmitOnError": true, + "declaration": true, + // Both maps resolve against `src/`, which ships in the tarball alongside + // `dist/` — so a consumer stepping through the JavaScript or jumping to a + // definition lands on the real TypeScript. + "declarationMap": true, + "sourceMap": true, + "inlineSourceMap": false, + "inlineSources": false, + "rootDir": "src", + "outDir": "dist", + // Rewrites `./x.ts` specifiers to `./x.js` in the emitted JavaScript, + // including dynamic imports. Declaration emit is not covered; build.ts + // rewrites those. + "rewriteRelativeImportExtensions": true + }, + "include": ["src"] +} diff --git a/packages/bxl/tsconfig.json b/packages/bxl/tsconfig.json index 0388275911b..5e368ba44c2 100644 --- a/packages/bxl/tsconfig.json +++ b/packages/bxl/tsconfig.json @@ -27,5 +27,9 @@ "strict": true, "skipLibCheck": true, "types": ["node"] - } + }, + // `dist/` is the published artifact — the same modules again as JavaScript + // plus declarations, which would double every symbol here. `node_modules` is + // only listed because naming `exclude` at all replaces the default list. + "exclude": ["node_modules", "dist"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc3126ea939..9b8d9a774be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1765,6 +1765,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.1 concurrently: specifier: 'catalog:' version: 8.2.2 @@ -1774,6 +1777,9 @@ importers: eslint: specifier: 'catalog:' version: 8.57.1 + semver: + specifier: ^7.7.0 + version: 7.8.5 typescript: specifier: 'catalog:' version: 5.9.3 @@ -17252,7 +17258,7 @@ snapshots: calculate-cache-key-for-tree: 2.0.0 ember-cli-babel: 7.26.11 ember-cli-version-checker: 5.1.2 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -17391,7 +17397,7 @@ snapshots: lodash: 4.18.1 resolve: 1.22.12 resolve-package-path: 4.0.3 - semver: 7.8.4 + semver: 7.8.5 typescript-memoize: 1.1.1 walk-sync: 3.0.0 transitivePeerDependencies: @@ -17452,7 +17458,7 @@ snapshots: find-up: 5.0.0 lodash: 4.18.1 resolve: 1.22.12 - semver: 7.8.4 + semver: 7.8.5 optionalDependencies: '@glint/template': 1.7.7 transitivePeerDependencies: @@ -17492,7 +17498,7 @@ snapshots: minimatch: 3.1.5 pkg-entry-points: 1.1.2 resolve-package-path: 4.0.3 - semver: 7.8.4 + semver: 7.8.5 typescript-memoize: 1.1.1 transitivePeerDependencies: - supports-color @@ -18772,7 +18778,7 @@ snapshots: '@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.28.0 forwarded-parse: 2.1.2 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -18905,7 +18911,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.8.4 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -18917,7 +18923,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.8.4 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -18929,7 +18935,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.8.4 + semver: 7.8.5 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -20385,7 +20391,7 @@ snapshots: debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.8.4 + semver: 7.8.5 tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -20400,7 +20406,7 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.9 - semver: 7.8.4 + semver: 7.8.5 ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -20431,7 +20437,7 @@ snapshots: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) eslint: 8.57.1 eslint-scope: 5.1.1 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color - typescript @@ -20446,7 +20452,7 @@ snapshots: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) eslint: 9.39.5 eslint-scope: 5.1.1 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color - typescript @@ -20673,7 +20679,7 @@ snapshots: parse-semver: 1.1.1 read: 1.0.7 secretlint: 10.2.2 - semver: 7.8.4 + semver: 7.8.5 tmp: 0.2.7 typed-rest-client: 1.8.11 url-join: 4.0.1 @@ -21183,7 +21189,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 babel-import-util: 2.1.1 - semver: 7.8.4 + semver: 7.8.5 babel-plugin-dynamic-import-node@2.3.3: dependencies: @@ -22377,7 +22383,7 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.15) postcss-value-parser: 4.2.0 schema-utils: 3.3.0 - semver: 7.8.4 + semver: 7.8.5 webpack: 5.107.2(postcss@8.5.15) css-loader@5.2.7(webpack@5.107.2): @@ -22391,7 +22397,7 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.15) postcss-value-parser: 4.2.0 schema-utils: 3.3.0 - semver: 7.8.4 + semver: 7.8.5 webpack: 5.107.2 css-select@5.2.2: @@ -22930,7 +22936,7 @@ snapshots: '@one-ini/wasm': 0.2.1 commander: 14.0.3 minimatch: 10.2.5 - semver: 7.8.4 + semver: 7.8.5 ee-first@1.1.1: {} @@ -23350,7 +23356,7 @@ snapshots: fs-extra: 9.1.0 resolve: 1.22.12 rsvp: 4.8.5 - semver: 7.8.4 + semver: 7.8.5 stagehand: 1.0.1 walk-sync: 2.2.0 transitivePeerDependencies: @@ -23378,7 +23384,7 @@ snapshots: ember-cli-version-checker@5.1.2: dependencies: resolve-package-path: 3.1.0 - semver: 7.8.4 + semver: 7.8.5 silent-error: 1.1.1 transitivePeerDependencies: - supports-color @@ -23597,7 +23603,7 @@ snapshots: resolve-package-path: 4.0.3 safe-stable-stringify: 2.5.0 sane: 5.0.1 - semver: 7.8.4 + semver: 7.8.5 silent-error: 1.1.1 sort-package-json: 3.7.1 symlink-or-copy: 1.3.1 @@ -23797,7 +23803,7 @@ snapshots: npmlog: 7.0.1 qunit: 2.26.0 rimraf: 5.0.10 - semver: 7.8.4 + semver: 7.8.5 silent-error: 1.1.1 transitivePeerDependencies: - '@glint/template' @@ -24172,7 +24178,7 @@ snapshots: lodash: 4.18.1 package-json: 6.5.0 remote-git-tags: 3.0.0 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - encoding @@ -24519,12 +24525,12 @@ snapshots: eslint-compat-utils@0.5.1(eslint@8.57.1): dependencies: eslint: 8.57.1 - semver: 7.8.4 + semver: 7.8.5 eslint-compat-utils@0.5.1(eslint@9.39.5): dependencies: eslint: 9.39.5 - semver: 7.8.4 + semver: 7.8.5 eslint-config-prettier@9.1.2(eslint@8.57.1): dependencies: @@ -24828,7 +24834,7 @@ snapshots: read-pkg-up: 7.0.1 regexp-tree: 0.1.27 regjsparser: 0.10.0 - semver: 7.8.4 + semver: 7.8.5 strip-indent: 3.0.0 transitivePeerDependencies: - supports-color @@ -27645,7 +27651,7 @@ snapshots: node-abi@3.92.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 optional: true node-addon-api@4.3.0: @@ -27704,7 +27710,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.8.4 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@2.1.1: @@ -27719,14 +27725,14 @@ snapshots: dependencies: hosted-git-info: 6.1.3 proc-log: 3.0.0 - semver: 7.8.4 + semver: 7.8.5 validate-npm-package-name: 5.0.1 npm-package-arg@13.0.2: dependencies: hosted-git-info: 9.0.3 proc-log: 6.1.0 - semver: 7.8.4 + semver: 7.8.5 validate-npm-package-name: 7.0.2 npm-run-all@4.1.5: @@ -29503,7 +29509,7 @@ snapshots: get-stdin: 9.0.0 git-hooks-list: 3.2.0 is-plain-obj: 4.1.0 - semver: 7.8.4 + semver: 7.8.5 sort-object-keys: 1.1.3 tinyglobby: 0.2.17 @@ -29513,7 +29519,7 @@ snapshots: detect-newline: 4.0.1 git-hooks-list: 4.2.1 is-plain-obj: 4.1.0 - semver: 7.8.4 + semver: 7.8.5 sort-object-keys: 2.1.0 tinyglobby: 0.2.17 @@ -29822,7 +29828,7 @@ snapshots: methods: 1.1.2 mime: 2.6.0 qs: 6.15.2 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -30179,7 +30185,7 @@ snapshots: dependencies: growly: 1.3.0 is-wsl: 2.2.0 - semver: 7.8.4 + semver: 7.8.5 shellwords: 0.1.1 which: 2.0.2 @@ -30376,7 +30382,7 @@ snapshots: typescript-auto-import-cache@0.3.6: dependencies: - semver: 7.8.4 + semver: 7.8.5 typescript-eslint@8.19.1(eslint@9.39.5)(typescript@5.9.3): dependencies: @@ -30763,7 +30769,7 @@ snapshots: volar-service-typescript@0.0.71(@volar/language-service@2.4.28): dependencies: path-browserify: 1.0.1 - semver: 7.8.4 + semver: 7.8.5 typescript-auto-import-cache: 0.3.6 vscode-languageserver-textdocument: 1.0.12 vscode-nls: 5.2.0