diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 3b83e59..db436d4 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -59,12 +59,12 @@ jobs:
- name: Run unit tests with coverage
run: npm run test:coverage
- - name: Comment coverage on PR
+ - name: Comment ngx coverage on PR
uses: ArtiomTr/jest-coverage-report-action@262a7bb0b20c4d1d6b6b026af0f008f78da72788 # v2.3.1
if: github.event_name == 'pull_request'
continue-on-error: true
with:
- custom-title: Coverage report
+ custom-title: Ngx coverage report
coverage-file: ./coverage/report.json
# Tests already ran above; the action only formats the report.
skip-step: all
@@ -105,6 +105,30 @@ jobs:
- name: Smoke-test the packed packages
run: npm run smoke
+ release:
+ name: Release config
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+ with:
+ # The release check compares bootstrap-sha with the tagged commit.
+ fetch-tags: true
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22.x
+
+ # Plain Node: neither needs the workspace's dependencies.
+ - name: Test the publish script and the release workflow's wiring
+ run: npm run test:release
+
+ # Replays releases through the Release Please version the release
+ # workflow runs, on this repository's config.
+ - name: Check the release config
+ run: npm run release:check
+
lint:
name: Lint and format
runs-on: ubuntu-latest
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 8df3dbd..60c286c 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -4,17 +4,22 @@ on:
push:
branches:
- main
+ # Retries a failed or partial publish: run it on the release tag,
+ # e.g. `gh workflow run release-please.yml --ref ngx-ui-watchtower-v0.5.0`.
+ workflow_dispatch:
permissions: {}
jobs:
release-please:
+ if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
- release_created: ${{ steps.release.outputs['projects/ngx-ui-watchtower--release_created'] }}
+ core_release_created: ${{ steps.release.outputs['projects/ui-watchtower--release_created'] }}
+ ngx_release_created: ${{ steps.release.outputs['projects/ngx-ui-watchtower--release_created'] }}
steps:
- uses: googleapis/release-please-action@v5
id: release
@@ -26,7 +31,13 @@ jobs:
publish:
needs: release-please
- if: ${{ needs.release-please.outputs.release_created == 'true' }}
+ # After a release of either package, or when run by hand to retry. On a
+ # manual run release-please is skipped, which !cancelled() lets through.
+ if: >-
+ ${{ !cancelled() && (
+ github.event_name == 'workflow_dispatch' ||
+ needs.release-please.outputs.core_release_created == 'true' ||
+ needs.release-please.outputs.ngx_release_created == 'true') }}
runs-on: ubuntu-latest
permissions:
# Required for npm trusted publishing (OIDC). No npm token needed.
@@ -48,9 +59,10 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Build library
+ - name: Build both packages
run: npm run build
+ # The core, then the Angular package: each only if its release tag
+ # points at this commit and the version isn't on npm yet.
- name: Publish to npm
- working-directory: dist/ngx-ui-watchtower
- run: npm publish
+ run: node scripts/publish-packages.mjs
diff --git a/README.md b/README.md
index 8527ec9..6f9d70f 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,29 @@
-
-
-
-
-
+
+
-# Angular UI Watchtower
+# UI Watchtower
-Source of [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower),
-a telemetry library for Angular apps: scenarios (user end-to-end journeys or specific functional workflows through an app), BI events and
-logs, with an optional AWS CloudWatch RUM sink. It builds on
-[`@absaoss-cps/ui-watchtower`](projects/ui-watchtower/README.md), the
-framework-independent engine, which lives here too.
+Telemetry for frontend applications, in three parts:
-How to use the library: [projects/ngx-ui-watchtower/README.md](projects/ngx-ui-watchtower/README.md)
-(also the npm page).
-How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md).
+- **Scenarios** — did a user journey or a workflow succeed, and how long did
+ it take?
+- **BI events** — which features are used?
+- **Logs** — what exactly happened?
+
+Events go to AWS CloudWatch RUM or another destination you choose; logs go
+to a backend you provide.
+
+It comes as two packages, each with its own version:
+
+| Package | Use it in | Guide |
+| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- |
+| [`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower) | Any frontend: plain JavaScript or TypeScript, or any framework. Framework adapters are built on it ([how](docs/ADAPTERS.md)). | [README](projects/ui-watchtower/README.md) |
+| [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower) | Angular applications. A thin adapter over `@absaoss-cps/ui-watchtower`: Angular providers, injectable services and Observables around the same engine. | [README](projects/ngx-ui-watchtower/README.md) |
+
+Each guide is also the package's npm page. How it is designed:
+[projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md).
## Layout
@@ -35,10 +42,13 @@ projects/ngx-ui-watchtower/
README.md usage guide, published to npm
DESIGN.md full design
package.json the published package's manifest
-scripts/ the public API snapshot, and the packaging smoke test
+docs/ ADAPTERS.md: how to write a framework adapter
+scripts/ the public API snapshot, the packaging smoke test, and publishing
```
-The Angular package depends on the core, never the other way round.
+The Angular package depends on the core, never the other way round. Each
+package has its own version; a core release also releases the Angular
+package, with its dependency moved (see [Releases](#releases)).
## Getting started
@@ -56,20 +66,22 @@ application does. Tests and the typecheck read the core's source.
## Scripts
-| Script | What it does |
-| ----------------------- | -------------------------------------------------------------- |
-| `npm run build` | Builds both packages (production), the core first |
-| `npm run watch` | Builds the core, then rebuilds the Angular package on change |
-| `npm run test` | Runs the unit tests of both packages (Jest) |
-| `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular |
-| `npm run test:coverage` | Runs the unit tests with coverage, as CI does |
-| `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test |
-| `npm run api:check` | Builds both packages, then compares their public APIs |
-| `npm run api:update` | Builds both packages, then rewrites their snapshots |
-| `npm run smoke` | Installs the built packages, packed, into throwaway consumers |
-| `npm run lint` | ESLint, no warnings allowed |
-| `npm run format` | Formats with Prettier |
-| `npm run format:check` | Checks formatting without writing |
+| Script | What it does |
+| ----------------------- | ---------------------------------------------------------------- |
+| `npm run build` | Builds both packages (production), the core first |
+| `npm run watch` | Builds the core, then rebuilds the Angular package on change |
+| `npm run test` | Runs the unit tests of both packages (Jest), then `test:release` |
+| `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular |
+| `npm run test:release` | Tests the publish script and the release workflow's wiring |
+| `npm run test:coverage` | Runs the unit tests with coverage, as CI does; fails below 90% |
+| `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test |
+| `npm run api:check` | Builds both packages, then compares their public APIs |
+| `npm run api:update` | Builds both packages, then rewrites their snapshots |
+| `npm run smoke` | Installs the built packages, packed, into throwaway consumers |
+| `npm run release:check` | Replays releases through Release Please on the release config |
+| `npm run lint` | ESLint, no warnings allowed |
+| `npm run format` | Formats with Prettier |
+| `npm run format:check` | Checks formatting without writing |
Four checks guard what consumers depend on:
@@ -106,6 +118,7 @@ npm run format:check
npm run build
npm run api:check
npm run smoke
+npm run release:check
```
CI runs the same checks on every pull request and every push to `main`.
@@ -124,15 +137,78 @@ Squash-merge pull requests: the title then becomes the commit message on
## Releases
Releases are automated with [release-please](https://github.com/googleapis/release-please)
-— don't publish by hand.
-
-1. Every push to `main` updates a release PR. `feat` bumps the minor version,
- `fix`, `perf` and `revert` the patch; until 1.0, a breaking change bumps
- the minor. Other types don't trigger a release.
-2. The release PR holds the version bump and the `CHANGELOG.md` entry.
-3. Merging it tags the release, creates the GitHub release and publishes the
- package to npm from GitHub Actions through trusted publishing — no npm
- token.
+— don't publish by hand. Each package has its own version and changelog, and
+is released when it changes:
+
+| A change to | Releases |
+| ------------------- | ------------------------------------------------------------------------------------------ |
+| The Angular package | The Angular package |
+| The core | The core, and a patch of the Angular package that moves its dependency to `^` the new core |
+| Both | Each, with its own bump |
+
+1. Every push to `main` updates one release PR, for every package with
+ changes. `feat` bumps the minor version, `fix`, `perf` and `revert` the
+ patch; until 1.0, a breaking change bumps the minor. Other types don't
+ trigger a release.
+2. The release PR holds each released package's version bump and
+ `CHANGELOG.md` entry. When the core is released, Release Please's
+ `node-workspace` plugin also moves the Angular package's dependency on the
+ core and releases it as a patch, so it always depends on the core it was
+ built against.
+3. Merging it tags each release, `ui-watchtower-v` or
+ `ngx-ui-watchtower-v` (until 0.4.0, the Angular package's tags
+ were `v`), and creates its GitHub release. GitHub Actions then
+ publishes what was released, the core before the Angular package, through
+ trusted publishing — no npm token.
+
+**A failed publish** stops before the next package, so the Angular package
+never depends on a core that isn't on npm. To finish a partial release, run
+the workflow again on the release tag:
+
+```bash
+gh workflow run release-please.yml --ref ngx-ui-watchtower-v
+```
+
+[`scripts/publish-packages.mjs`](scripts/publish-packages.mjs) publishes a
+package only if its release tag points at that commit and npm doesn't have
+the version yet; any other answer from npm stops it.
+
+**The core's first release is 0.1.0, and its first publish is by hand.** The
+core's `initial-version` in `release-please-config.json` sets that version.
+npm sets up trusted publishing only for a package that exists. Before the
+first release PR that releases the core is merged, an `@absaoss-cps` owner
+publishes the core from that PR's branch:
+
+```bash
+npm login
+git fetch origin
+git switch release-please--branches--main
+npm ci && npm run build
+npm publish dist/ui-watchtower
+```
+
+Then, on npmjs.com, in the package's settings, add the trusted publisher the
+Angular package has: GitHub Actions, `AbsaOSS/ui-watchtower`, workflow
+`release-please.yml`. After the merge, publishing finds 0.1.0 on npm, skips
+it, and publishes the Angular package. If the release PR is merged first,
+publishing stops at the core; publish it as above from the
+`ui-watchtower-v0.1.0` tag, then run the workflow on the release tag.
+
+`bootstrap-sha` in `release-please-config.json` is the commit tagged
+`v0.4.0`: it keeps what that release shipped out of the first changelogs,
+and is read only until both packages have a release. If another `v` release
+reaches `main` first, set it to that release's commit. `npm run release:check`
+compares the two.
+
+`npm run release:check` replays releases through the Release Please version
+the workflow runs, on this config:
+
+- the first release, with the core at 0.1.0;
+- an Angular-only change releasing only the Angular package;
+- a core change also releasing the Angular package, with its dependency moved;
+- merging creating the tags publishing looks for.
+
+It installs Release Please, so it needs a network.
## License
diff --git a/assets/logo/logo-stacked-auto.svg b/assets/logo/logo-stacked-auto.svg
index ff6f3a4..ee0fc8f 100644
--- a/assets/logo/logo-stacked-auto.svg
+++ b/assets/logo/logo-stacked-auto.svg
@@ -1 +1 @@
-
+
diff --git a/assets/logo/logo-stacked-dark.svg b/assets/logo/logo-stacked-dark.svg
index 2231b0f..5659080 100644
--- a/assets/logo/logo-stacked-dark.svg
+++ b/assets/logo/logo-stacked-dark.svg
@@ -1 +1 @@
-
+
diff --git a/assets/logo/logo-stacked-light.svg b/assets/logo/logo-stacked-light.svg
index 3191bf6..d4a894e 100644
--- a/assets/logo/logo-stacked-light.svg
+++ b/assets/logo/logo-stacked-light.svg
@@ -1 +1 @@
-
+
diff --git a/assets/logo/ngx-logo-stacked-auto.svg b/assets/logo/ngx-logo-stacked-auto.svg
new file mode 100644
index 0000000..53ea0e8
--- /dev/null
+++ b/assets/logo/ngx-logo-stacked-auto.svg
@@ -0,0 +1 @@
+
diff --git a/assets/logo/ngx-logo-stacked-dark.svg b/assets/logo/ngx-logo-stacked-dark.svg
new file mode 100644
index 0000000..82f8618
--- /dev/null
+++ b/assets/logo/ngx-logo-stacked-dark.svg
@@ -0,0 +1 @@
+
diff --git a/assets/logo/ngx-logo-stacked-light.svg b/assets/logo/ngx-logo-stacked-light.svg
new file mode 100644
index 0000000..ff137bb
--- /dev/null
+++ b/assets/logo/ngx-logo-stacked-light.svg
@@ -0,0 +1 @@
+
diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md
new file mode 100644
index 0000000..cc660e9
--- /dev/null
+++ b/docs/ADAPTERS.md
@@ -0,0 +1,127 @@
+# Writing a framework adapter
+
+`@absaoss-cps/ui-watchtower` works on its own, in any frontend. An adapter
+is optional: it only makes the library feel native to one framework.
+[`@absaoss-cps/ngx-ui-watchtower`](../projects/ngx-ui-watchtower/README.md), the
+Angular adapter, is a complete example.
+
+## What an adapter is
+
+An adapter is wiring, never a second engine. It reads the framework's
+configuration, creates the core's objects, hands them out the framework's
+way (dependency injection, context, hooks) and disposes them when the
+framework tears down. Scenarios, BI events, logging, redaction and the
+broadcast protocol stay in the core. If an adapter needs a behaviour the
+core lacks, add it to the core, where every adapter and every plain
+application gets it.
+
+An adapter needs only the package's public entry points: no deep import, no
+private file.
+
+## Steps
+
+1. **One engine per application, or per fragment.** Create it with
+ `uwtCreateTelemetry` at startup, from the framework's configuration, and
+ share it through the framework's context or dependency injection, never
+ one per component. If the framework's dependency injection resolves each
+ concern on its own, as Angular's does, construct `UwtScenarioTelemetry`,
+ `UwtBITelemetry` and `UwtLogTelemetry` instead, from
+ `uwtResolveTelemetryConfig(overrides)`, one destination and one log store.
+2. **The platform.** Pass `document: null` when rendering on a server, and
+ `runOutside` if the framework waits for pending timers before it is
+ stable, as Angular's zone does.
+3. **Failures.** Pass a `report`, such as `uwtConsoleReporter` in
+ development. Telemetry calls never throw; `uwtCreateTelemetry` does, when
+ the identity (its application, environment or version), destination or
+ log store is missing.
+4. **Reactive APIs.** Adapt `scenarios.onSettled(listener)`, which returns
+ an unsubscribe function, to the framework's primitive: an Observable, a
+ signal, a store.
+5. **Destinations.** Accept any `UwtTelemetrySink`, and offer the core's:
+ the RUM sink, started with an unawaited `init()`; for a fragment, the
+ broadcast sink and log provider sharing one `uwtConnectBroadcastChannel()`;
+ for a shell, a `UwtTelemetryBroadcastHost`. Pass each the engine's
+ `enabled`, and the shared connection `open: enabled`.
+6. **Teardown.** When the application or fragment is destroyed, dispose the
+ engine and then what you created for it. Every `dispose()` can be called
+ more than once.
+7. **Names.** Re-export the four registries, so applications declare their
+ names on your package's module path and reach the same registries:
+
+ ```ts
+ export type {
+ UwtBIEventNames,
+ UwtLoggerNames,
+ UwtScenarioNames,
+ UwtScenarioSteps
+ } from '@absaoss-cps/ui-watchtower';
+ ```
+
+8. **The dependency.** Depend on `@absaoss-cps/ui-watchtower` with a caret
+ range, as a dependency rather than a peer, so installing the adapter
+ installs the core.
+
+## How the Angular adapter does it
+
+| Step | In `@absaoss-cps/ngx-ui-watchtower` |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Engine | Three root services, one per concern, each building its core class from injected config: [services/](../projects/ngx-ui-watchtower/src/lib/services/). A missing provider still fails where Angular reports it. |
+| Platform | `uwtBrowserDocument()` returns `null` outside a browser (`PLATFORM_ID`); `uwtRunOutsideZone()` wraps `NgZone.runOutsideAngular`: [uwt-telemetry-safe.util.ts](../projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts) |
+| Failures | `uwtDevModeReporter`: failures and dropped data in development mode only, configuration problems always, prefixed `[ngx-ui-watchtower]` (same file) |
+| Reactive | `settled$` is a `Subject` fed by `onSettled` and completed on destroy: [uwt-scenario-telemetry.service.ts](../projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts); `traceScenario` passes the reporter to the core's `/rxjs` |
+| Destinations | `@Injectable()` subclasses of the core's sinks, host and log provider, built from `inject()`. In `'broadcast'` mode one `UwtBroadcastConnectionRef` per injector gives the sink and log provider one channel: [uwt-broadcast.messages.ts](../projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts) |
+| Teardown | The scenario and logger services and every destination subclass call `dispose()` from `ngOnDestroy`; the BI service holds nothing to release |
+| Names | [public-api.ts](../projects/ngx-ui-watchtower/src/public-api.ts) re-exports the registries, so `declare module '@absaoss-cps/ngx-ui-watchtower'` keeps working |
+| Dependency | `"@absaoss-cps/ui-watchtower": "^"` in its `package.json`, moved by Release Please |
+
+## Testing an adapter
+
+The core's own tests cover the engine and the protocol. An adapter's tests
+cover its wiring:
+
+- **One engine per scope.** Everything the framework hands out in one
+ application shares one engine, destination and log store.
+- **Teardown.** Destroying the application disposes everything the adapter
+ created; destroying it twice does nothing more.
+- **Off and on the server.** With telemetry off, or rendering on a server,
+ nothing listens to the page, no channel opens and no RUM client starts.
+- **Names.** A type test that declares names on the adapter's module path:
+ declared names compile, others don't, and plain strings compile while
+ nothing is declared. See
+ [type-tests/](../projects/ngx-ui-watchtower/type-tests/).
+- **Shells and fragments.** An adapter's fragment and shell must exchange
+ the recorded messages:
+ [uwt-broadcast.wire.spec.ts](../projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts)
+ holds every pairing to `uwt-broadcast.wire.fixture.json`.
+
+## Adding an adapter to this repository
+
+An adapter can live anywhere and depend on the published core. To release it
+from this repository, with a version of its own; each core release then also
+releases it, with its dependency on the core moved:
+
+1. **The package.** `projects//` with its `package.json` (its own
+ version, and `^` the core's current version as a dependency), README and
+ LICENSE, built into `dist/` by `npm run build`, after the core.
+2. **Release Please.** Add the package to `release-please-config.json`, with
+ a `component` and an `initial-version` (e.g. `0.1.0`), its first release.
+ Release Please adds it to `.release-please-manifest.json` when it
+ releases.
+3. **Publishing.** Add it to `PACKAGES` in
+ [scripts/publish-packages.mjs](../scripts/publish-packages.mjs), after the
+ core, with `dependsOn: '@absaoss-cps/ui-watchtower'`. Add its
+ `--release_created` output to `.github/workflows/release-please.yml`, and
+ to the publish job's condition.
+4. **Checks.** Add it to `PACKAGES` in
+ [scripts/api-snapshot.mjs](../scripts/api-snapshot.mjs) and run
+ `npm run api:update`; add a consumer to
+ [scripts/smoke/smoke.mjs](../scripts/smoke/smoke.mjs). The release
+ scenarios in [scripts/release-check.mjs](../scripts/release-check.mjs) and
+ the workflow test in
+ [scripts/publish-packages.test.mjs](../scripts/publish-packages.test.mjs)
+ name the core and the Angular package; extend them to the new one.
+5. **Docs.** Add a row to the root README's package table.
+
+Start with `PACKAGES`: `npm run test:release` then fails, in CI too, until
+the config (or the manifest), the package's `package.json` and the release
+job's outputs all match it.
diff --git a/package.json b/package.json
index 330c96f..e3be703 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,9 @@
"ng": "ng",
"build": "ng build --configuration=production --project=ui-watchtower && ng build --configuration=production --project=ngx-ui-watchtower",
"watch": "ng build ui-watchtower --configuration development && ng build ngx-ui-watchtower --watch --configuration development",
- "test": "npm run test:core && ng test --project=ngx-ui-watchtower",
+ "test": "npm run test:core && ng test --project=ngx-ui-watchtower && npm run test:release",
"test:core": "jest --config projects/ui-watchtower/jest.config.js",
+ "test:release": "node --test scripts/*.test.mjs",
"test:coverage": "jest --config projects/ui-watchtower/jest.config.js --coverage --coverageReporters=text-summary --coverageReporters=lcov --json --outputFile=./coverage/ui-watchtower/report.json && ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json",
"lint": "eslint \"**/*.ts\" --max-warnings=0",
"format": "prettier --write \"**/*.{ts,mjs,html}\"",
@@ -14,7 +15,8 @@
"typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json",
"api:check": "npm run build && node scripts/api-snapshot.mjs",
"api:update": "npm run build && node scripts/api-snapshot.mjs --update",
- "smoke": "node scripts/smoke/smoke.mjs"
+ "smoke": "node scripts/smoke/smoke.mjs",
+ "release:check": "node scripts/release-check.mjs"
},
"private": true,
"dependencies": {
diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md
index 1f0e8fd..b3b58a6 100644
--- a/projects/ngx-ui-watchtower/DESIGN.md
+++ b/projects/ngx-ui-watchtower/DESIGN.md
@@ -96,7 +96,9 @@ fails where it always did: the logger needs a log provider but no
destination, scenarios need a destination and no log provider. The services
are root singletons, so each concern exists once per injector, and all of
them share the one destination and log provider. `settled$` is the core's
-`onSettled` as an Observable that completes on destroy; `traceScenario` is the
+`onSettled` as an Observable that completes on destroy. It listens to the
+core only while subscribed, so no record is copied while nobody is
+subscribed; `traceScenario` is the
core's `./rxjs` operator, reporting through this package's dev-mode
reporter. Framework-free applications compose the same three classes with
`uwtCreateTelemetry`.
@@ -210,7 +212,10 @@ providers: [
the same names. The dependency runs one way: the core imports neither
Angular nor this package, which a lint rule enforces. The library build
compiles against the core's built declarations, not its source, so it sees
- the core exactly as an application does.
+ the core exactly as an application does. An application upgrading from
+ 0.4 changes nothing. Another framework's adapter needs only the core's
+ public entry points, as this package does;
+ `docs/ADAPTERS.md` is a short guide.
- **What the core owns, this package re-exports.** The engine, the models
and name registries, the configuration types and defaults
(`uwtResolveTelemetryConfig` completes every `with*` feature), redaction,
@@ -238,13 +243,23 @@ providers: [
fixes each entry point's `rootDir` to its own `src`, so
`@absaoss-cps/ngx-ui-watchtower/rum` can't import the main entry's
internals by relative path and carries small copies of what it needs: its
- Angular helpers and the reporter.
+ Angular helpers and the reporter. A spec (`uwt-rum-internal.util.spec.ts`)
+ runs both copies against the same cases, and fails if their exports or
+ behaviour differ, so they can't drift apart.
- **What is published is tested as published.** `npm run smoke` installs
both tarballs into consumers outside the workspace: a framework-free one
with neither Angular nor `aws-rum-web` installed, and an Angular
application, prerendered as a fragment without `aws-rum-web` — which then
holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk
loaded on demand.
+- **Each package has its own version.** A change to this package releases
+ only this package. A core release also releases this package as a patch:
+ Release Please's `node-workspace` plugin moves its dependency on the core
+ to `^` the new version in the same release PR, so it always depends on the
+ core it was built against. The release workflow publishes the core first
+ and this package only after it, so this package never depends on a core
+ version npm doesn't have. Publishing skips what npm already has, so running
+ the workflow again on the release tag completes a partial release.
- **Test doubles live in the specs that use them**, declared inline, never in
a shared file and never exported. The one browser gap needing a stub is
`BroadcastChannel`, which jsdom doesn't implement.
@@ -2198,6 +2213,12 @@ providers: [
Code inside a fragment does not change: same services, same calls. One
session, one budget, one bundle.
+| Realm | Destination | RUM and `UWT_RUM_CREDENTIALS_PROVIDER` | `UWT_LOG_API_PROVIDER` |
+| -------------- | --------------------------------------------------- | -------------------------------------- | -------------------------------------- |
+| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's |
+| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs |
+| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | Bound by `'broadcast'`, to the shell's |
+
Either side may be built without Angular. The protocol — the messages, the
host, the forwarding sink and log provider — lives in
`@absaoss-cps/ui-watchtower`, and this package's classes are injectable
diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md
index dbe32a6..1831450 100644
--- a/projects/ngx-ui-watchtower/README.md
+++ b/projects/ngx-ui-watchtower/README.md
@@ -1,8 +1,8 @@
-
-
-
+
+
+
@@ -16,6 +16,12 @@ Telemetry for Angular apps, in three parts:
Events go to AWS CloudWatch RUM. Logs go to a backend you provide.
+It is a thin Angular adapter over
+[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower),
+the framework-independent engine that does the work, and can be used in any
+frontend. This package adds what Angular expects: providers, injectable
+services, Observables.
+
The library's names are prefixed `Uwt`, short for UI Watchtower —
`UwtLoggerService`, `provideUwtTelemetry`, `UWT_LOG_API_PROVIDER`.
@@ -26,10 +32,15 @@ npm install @absaoss-cps/ngx-ui-watchtower
npm install aws-rum-web # only for the AWS RUM sink
```
-Requires Angular 21 or 22 and RxJS 7.8. It brings
-[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower),
-the framework-independent engine it builds on, with it: there is nothing more
-to install or configure.
+Requires Angular 21 or 22 and RxJS 7.8. It installs
+`@absaoss-cps/ui-watchtower` with it: there is nothing more to install or
+configure. If your application also imports the core directly, don't install
+a version this package doesn't accept: npm would keep two copies, and name
+declarations and `instanceof` checks would no longer match between them. Let
+npm pick one version for both.
+
+Upgrading from 0.4 needs no code or configuration change: the imports,
+providers, services, name declarations and behavior stay the same.
## Setup
@@ -351,6 +362,14 @@ providers: [
];
```
+Who owns what:
+
+| Realm | Destination | RUM and its credentials provider | Log backend |
+| -------------- | --------------------------------------------------- | -------------------------------------- | ------------------------------------- |
+| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's |
+| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs |
+| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | None: `'broadcast'` forwards them |
+
Fragment code doesn't change. Give each fragment its own `application` name.
A shell or a fragment needn't be Angular: one built on
[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower)
diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md
index ec808bf..f272257 100644
--- a/projects/ui-watchtower/README.md
+++ b/projects/ui-watchtower/README.md
@@ -1,12 +1,29 @@
+
+
+
+
+
+
+
+
# ui-watchtower
-The framework-independent engine behind
-[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower):
-scenario health telemetry, business/UX events and application logs, for any
-JavaScript or TypeScript application.
+Telemetry for any frontend, in three parts:
+
+- **Scenarios** — did a user journey or a workflow succeed, and how long did
+ it take?
+- **BI events** — which features are used?
+- **Logs** — what exactly happened?
+
+It needs no framework and no adapter: install it and call it directly, from
+plain JavaScript or TypeScript or from an application built with any
+framework. It imports nothing framework-specific and touches no browser
+global when imported, so it also loads in Node, on a server and in a worker.
-It imports no framework and touches no browser global when imported, so it
-loads in Node, on a server and in a worker as well as in a browser.
+Adapters are optional. For Angular there is
+[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower),
+which wraps this package in providers and injectable services. Adapters for
+other frameworks can be added the same way.
## Install
@@ -55,7 +72,7 @@ Configuration errors still throw, when the engine is created.
| Option | What it does |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
-| `identity` | Application, environment and version, carried by every record |
+| `identity` | Application, environment, version and optional `eventNamespace`, carried by every record |
| `destination` | A `UwtTelemetrySink`: where scenarios, BI events and mirrored errors go |
| `logApi` | A `UwtLogApiProvider`: where logs go, and are read back from |
| `scenarios`, `logging`, `bi`, `redaction` | Overrides of each concern's defaults |
@@ -65,9 +82,29 @@ Configuration errors still throw, when the engine is created.
| `runOutside` | Schedules timers outside a framework's change detection |
`dispose()` stops watching the page, settles scenarios still in flight as
-abandoned and flushes; the engine does nothing after that, and leaves no
-listener or timer behind. The destination and log store you passed are
-yours to close.
+abandoned and flushes; no listener is left behind, and nothing the engine had
+scheduled. That is all it changes. Something may still run while your
+application shuts down, and the destination and log store you passed are
+yours, so scenarios, BI events and logs started afterwards work as before. You
+close the destination and the log store.
+
+### Turning telemetry off
+
+Keep the same calls and pass one switch:
+
+```ts
+const telemetry = uwtCreateTelemetry({ ...options, enabled: false });
+```
+
+When off, nothing is recorded, sent or logged, and nothing listens to the
+page. Your code doesn't change: `start()` still returns a scenario (inert:
+it records nothing and never settles), lookups find nothing, loggers do
+nothing and `query()` resolves `[]`. Pass the switch on to what you created
+for the engine, so that off it requests no credentials, downloads no SDK and
+opens no channel: `enabled` to the RUM sink and the broadcast classes, and
+`open` to `uwtConnectBroadcastChannel()`. Off, a sink or log provider sends
+nothing even over a shared connection, but only `open: false` keeps the
+channel itself from opening.
## AWS CloudWatch RUM
@@ -119,10 +156,21 @@ const telemetry = uwtCreateTelemetry({
});
```
-Either side can be an Angular application using
-`@absaoss-cps/ngx-ui-watchtower`; they exchange the same messages. Dispose
-the host, the sink, the log provider and the connection when their realm goes
-away.
+Each side can use a different framework, or none: they exchange the same
+messages. Give each fragment its own `application` name, and the shell's
+`eventNamespace`.
+
+Who owns what:
+
+| Realm | Destination | RUM client and credentials | Log store |
+| -------------- | ----------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
+| Standalone app | Its own: `UwtRumTelemetrySink`, or another sink | The app's | The app's |
+| Shell | Its own, plus a `UwtTelemetryBroadcastHost` | The shell's: the only one on a page | The shell's, which also stores fragments' records |
+| Fragment | `UwtBroadcastTelemetrySink` | None: the shell's records for it | None: `UwtBroadcastLogApiProvider` forwards |
+
+A fragment reports the shell's session and user. Whoever creates something
+disposes it: dispose the engine, then the host, the sink, the log provider
+and the connection, when their realm goes away.
## Declare your names
@@ -149,9 +197,7 @@ declare module '@absaoss-cps/ui-watchtower' {
export {};
```
-Until you declare any, plain strings are accepted. An Angular application
-augments `@absaoss-cps/ngx-ui-watchtower` instead, which re-exports these
-registries: either module path reaches the same ones.
+Until you declare any, plain strings are accepted.
## Seeing what telemetry skips
@@ -183,7 +229,7 @@ customers$.pipe(traceScenario(scenario)).subscribe();
| Group | Exports |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Engine | `uwtCreateTelemetry`, `UwtTelemetry`, `UwtTelemetryOptions`, `UwtTelemetryDocument` |
-| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, for an adapter composing them itself |
+| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, to compose them yourself |
| Configuration | `UwtTelemetryIdentity`, the per-concern config types, `UWT_DEFAULT_TELEMETRY_CONFIG`, `uwtResolveTelemetryConfig` |
| Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` |
| Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types |
diff --git a/release-please-config.json b/release-please-config.json
index 2da4450..677b0c1 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -1,27 +1,32 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "node",
- "include-component-in-tag": false,
- "bootstrap-sha": "ebd38afab87af29f9ee27ecfa0eea90b81978881",
+ "bootstrap-sha": "1c05bd23b30ddf8105aab40ece85db32750cd2db",
+ "bump-minor-pre-major": true,
+ "changelog-path": "CHANGELOG.md",
+ "changelog-sections": [
+ { "type": "feat", "section": "Features" },
+ { "type": "fix", "section": "Bug Fixes" },
+ { "type": "perf", "section": "Performance Improvements" },
+ { "type": "revert", "section": "Reverts" },
+ { "type": "docs", "section": "Documentation", "hidden": true },
+ { "type": "style", "section": "Styles", "hidden": true },
+ { "type": "chore", "section": "Miscellaneous Chores", "hidden": true },
+ { "type": "refactor", "section": "Code Refactoring", "hidden": true },
+ { "type": "test", "section": "Tests", "hidden": true },
+ { "type": "build", "section": "Build System", "hidden": true },
+ { "type": "ci", "section": "Continuous Integration", "hidden": true }
+ ],
"packages": {
+ "projects/ui-watchtower": {
+ "package-name": "@absaoss-cps/ui-watchtower",
+ "component": "ui-watchtower",
+ "initial-version": "0.1.0"
+ },
"projects/ngx-ui-watchtower": {
- "release-type": "node",
"package-name": "@absaoss-cps/ngx-ui-watchtower",
- "changelog-path": "CHANGELOG.md",
- "bump-minor-pre-major": true,
- "changelog-sections": [
- { "type": "feat", "section": "Features" },
- { "type": "fix", "section": "Bug Fixes" },
- { "type": "perf", "section": "Performance Improvements" },
- { "type": "revert", "section": "Reverts" },
- { "type": "docs", "section": "Documentation", "hidden": true },
- { "type": "style", "section": "Styles", "hidden": true },
- { "type": "chore", "section": "Miscellaneous Chores", "hidden": true },
- { "type": "refactor", "section": "Code Refactoring", "hidden": true },
- { "type": "test", "section": "Tests", "hidden": true },
- { "type": "build", "section": "Build System", "hidden": true },
- { "type": "ci", "section": "Continuous Integration", "hidden": true }
- ]
+ "component": "ngx-ui-watchtower"
}
- }
+ },
+ "plugins": [{ "type": "node-workspace" }]
}
diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs
new file mode 100644
index 0000000..dda2b61
--- /dev/null
+++ b/scripts/publish-packages.mjs
@@ -0,0 +1,198 @@
+// Publishes the packages released at the checked-out commit: the core first,
+// then the Angular package that depends on it. Run after `npm run build`; it
+// publishes the built dist/ directories, never sources or the private root.
+//
+// A package is published only if its release tag points at this commit and
+// its version isn't on npm yet, so a failed run can simply be run again on
+// the release tag. Anything npm reports other than "not found" stops the run
+// before any later package: a failed core is never followed by its adapter.
+import { spawnSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+/** In publishing order: each package after those it depends on. */
+export const PACKAGES = [
+ {
+ name: '@absaoss-cps/ui-watchtower',
+ path: 'projects/ui-watchtower',
+ component: 'ui-watchtower',
+ dist: 'dist/ui-watchtower'
+ },
+ {
+ name: '@absaoss-cps/ngx-ui-watchtower',
+ path: 'projects/ngx-ui-watchtower',
+ component: 'ngx-ui-watchtower',
+ dist: 'dist/ngx-ui-watchtower',
+ dependsOn: '@absaoss-cps/ui-watchtower'
+ }
+];
+
+/**
+ * @param {object} options
+ * @param {string} options.root the repository root, holding the built dist/
+ * @param {(command: string, args: string[], options?: {cwd?: string, inherit?: boolean}) =>
+ * {status: number | null, stdout: string, stderr: string}} options.run runs a command
+ * @param {(line: string) => void} options.log prints progress
+ */
+export function publishPackages({ root, run, log }) {
+ const releases = PACKAGES.map((pkg) => {
+ const manifest = readBuiltManifest(root, pkg);
+ const tag = `${pkg.component}-v${manifest.version}`;
+ return { ...pkg, manifest, version: manifest.version, tag };
+ });
+ const head = git(run, ['rev-parse', 'HEAD']).trim();
+ const here = releases.filter((release) => tagPointsAt(run, release, head));
+ if (here.length === 0) {
+ throw new Error(
+ `no release tag points at ${head}: run this on a release tag`
+ );
+ }
+ here.forEach((release) => checkDependencyRange(release, releases));
+
+ const onNpm = new Set();
+ for (const release of releases) {
+ const label = `${release.name}@${release.version}`;
+ if (!here.includes(release)) {
+ log(`${label}: skipped, not released at this commit`);
+ continue;
+ }
+ if (isPublished(run, release.name, release.version)) {
+ log(`${label}: skipped, already on npm`);
+ } else {
+ checkDependencyOnNpm(run, release, releases, onNpm);
+ const result = run('npm', ['publish'], {
+ cwd: join(root, release.dist),
+ inherit: true
+ });
+ if (result.status !== 0) {
+ throw new Error(`npm publish ${label} failed (exit ${result.status})`);
+ }
+ log(`${label}: published`);
+ }
+ onNpm.add(release.name);
+ }
+}
+
+/** The built manifest, which must be the package's own: never the root's. */
+function readBuiltManifest(root, pkg) {
+ const manifest = JSON.parse(
+ readFileSync(join(root, pkg.dist, 'package.json'), 'utf8')
+ );
+ if (manifest.name !== pkg.name || manifest.private) {
+ throw new Error(`${pkg.dist} is not the publishable ${pkg.name}`);
+ }
+ return manifest;
+}
+
+function git(run, args) {
+ const result = run('git', args);
+ if (result.status !== 0) {
+ throw new Error(`git ${args.join(' ')} failed: ${firstLine(result)}`);
+ }
+ return result.stdout;
+}
+
+/**
+ * A lightweight tag is listed as its commit; an annotated one as its tag
+ * object, and as `^{}` the commit it points at, listed only when asked for.
+ */
+function tagPointsAt(run, release, head) {
+ const ref = `refs/tags/${release.tag}`;
+ const listing = git(run, ['ls-remote', '--tags', 'origin', ref, `${ref}^{}`]);
+ return listing.split('\n').some((line) => line.split('\t')[0] === head);
+}
+
+/**
+ * The adapter depends on `^` the core it was built against: Release Please's
+ * node-workspace plugin moves that range with every core release.
+ */
+function checkDependencyRange(release, releases) {
+ if (!release.dependsOn) {
+ return;
+ }
+ const dependency = releases.find((other) => other.name === release.dependsOn);
+ const range = release.manifest.dependencies?.[dependency.name];
+ if (range !== `^${dependency.version}`) {
+ throw new Error(
+ `${release.name}@${release.version} depends on ${dependency.name}@${range}, ` +
+ `not on ^${dependency.version}, the version it was built against`
+ );
+ }
+}
+
+function checkDependencyOnNpm(run, release, releases, onNpm) {
+ const dependency = releases.find((other) => other.name === release.dependsOn);
+ if (
+ dependency &&
+ !onNpm.has(dependency.name) &&
+ !isPublished(run, dependency.name, dependency.version)
+ ) {
+ throw new Error(
+ `${release.name}@${release.version} needs ` +
+ `${dependency.name}@${dependency.version}, which is not on npm`
+ );
+ }
+}
+
+/** On npm, or not found there; any other answer is an error, never a guess. */
+function isPublished(run, name, version) {
+ const spec = `${name}@${version}`;
+ const result = run('npm', [
+ 'view',
+ spec,
+ 'version',
+ '--json',
+ '--prefer-online'
+ ]);
+ const output = parseJson(result.stdout);
+ if (result.status === 0 && output === version) {
+ return true;
+ }
+ if (result.status !== 0 && output?.error?.code === 'E404') {
+ return false;
+ }
+ const reason = output?.error
+ ? `${output.error.code}: ${output.error.summary}`
+ : firstLine(result);
+ throw new Error(`npm view ${spec} failed: ${reason}`);
+}
+
+function parseJson(text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return undefined;
+ }
+}
+
+function firstLine({ status, stdout, stderr }) {
+ return (stderr || stdout).trim().split('\n')[0] || `exit ${status}`;
+}
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+function run(command, args, { cwd = ROOT, inherit = false } = {}) {
+ const result = spawnSync(command, args, {
+ cwd,
+ encoding: 'utf8',
+ stdio: inherit ? 'inherit' : 'pipe'
+ });
+ return {
+ status: result.status,
+ stdout: result.stdout ?? '',
+ stderr: result.stderr ?? String(result.error ?? '')
+ };
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ try {
+ publishPackages({ root: ROOT, run, log: (line) => console.log(line) });
+ } catch (error) {
+ console.error(`publish: ${error.message}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/publish-packages.test.mjs b/scripts/publish-packages.test.mjs
new file mode 100644
index 0000000..57bc418
--- /dev/null
+++ b/scripts/publish-packages.test.mjs
@@ -0,0 +1,350 @@
+// Tests of the publish script, and of how the release workflow and config
+// use it. Offline: git and npm are simulated. `npm run test:release`.
+import assert from 'node:assert/strict';
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { after, describe, it } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { PACKAGES, publishPackages } from './publish-packages.mjs';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const [CORE, NGX] = PACKAGES.map((pkg) => pkg.name);
+const HEAD = 'a1b2c3d';
+const CORE_TAG = 'ui-watchtower-v0.6.0';
+const NGX_TAG = 'ngx-ui-watchtower-v0.5.2';
+
+const roots = [];
+after(() =>
+ roots.forEach((root) => rmSync(root, { recursive: true, force: true }))
+);
+
+/**
+ * Each package built at its own version: the core at 0.6.0, the Angular
+ * package at 0.5.2 on `^0.6.0`. `tags` point at HEAD, `annotated` too as
+ * annotated tags, `elsewhere` at another commit; the registry holds `onNpm`. `failing` makes a command fail:
+ * `{ 'view ': '', 'publish ': true }`.
+ */
+function workspace({
+ tags = [CORE_TAG, NGX_TAG],
+ annotated = [],
+ elsewhere = [],
+ onNpm = [],
+ failing = {},
+ range = '^0.6.0',
+ built = {}
+} = {}) {
+ const root = mkdtempSync(join(tmpdir(), 'uwt-publish-'));
+ roots.push(root);
+ const manifests = {
+ [CORE]: { name: CORE, version: '0.6.0' },
+ [NGX]: { name: NGX, version: '0.5.2', dependencies: { [CORE]: range } },
+ ...built
+ };
+ for (const pkg of PACKAGES) {
+ mkdirSync(join(root, pkg.dist), { recursive: true });
+ writeFileSync(
+ join(root, pkg.dist, 'package.json'),
+ JSON.stringify(manifests[pkg.name])
+ );
+ }
+
+ const registry = new Set(onNpm);
+ const published = [];
+ const commands = [];
+ const ok = (stdout) => ({ status: 0, stdout, stderr: '' });
+ const npmError = (code) => ({
+ status: 1,
+ stdout: JSON.stringify({
+ error: { code, summary: `${code} from the registry` }
+ }),
+ stderr: ''
+ });
+ const run = (command, args, { cwd } = {}) => {
+ commands.push([command, ...args].join(' '));
+ if (command === 'git' && args[0] === 'rev-parse') {
+ return ok(`${HEAD}\n`);
+ }
+ if (command === 'git' && args[0] === 'ls-remote') {
+ // As git lists them: a lightweight tag as its commit, an annotated one
+ // as its tag object and then `^{}` its commit; only the refs asked for.
+ const asked = args.slice(3);
+ const tag = asked[0].replace('refs/tags/', '').replace(/\^\{\}$/, '');
+ const sha = [...tags, ...annotated].includes(tag)
+ ? HEAD
+ : elsewhere.includes(tag)
+ ? 'f00ba47'
+ : '';
+ const listed = !sha
+ ? []
+ : annotated.includes(tag)
+ ? [
+ ['7a9c0b1', `refs/tags/${tag}`],
+ [sha, `refs/tags/${tag}^{}`]
+ ]
+ : [[sha, `refs/tags/${tag}`]];
+ return ok(
+ listed
+ .filter(([, ref]) => asked.includes(ref))
+ .map(([at, ref]) => `${at}\t${ref}\n`)
+ .join('')
+ );
+ }
+ if (command === 'npm' && args[0] === 'view') {
+ const spec = args[1];
+ if (failing[`view ${spec}`]) {
+ return npmError(failing[`view ${spec}`]);
+ }
+ return registry.has(spec)
+ ? ok(JSON.stringify(spec.split('@').pop()))
+ : npmError('E404');
+ }
+ if (command === 'npm' && args[0] === 'publish') {
+ const { name, version } = JSON.parse(
+ readFileSync(join(cwd, 'package.json'), 'utf8')
+ );
+ if (failing[`publish ${name}`]) {
+ return { status: 1, stdout: '', stderr: '' };
+ }
+ registry.add(`${name}@${version}`);
+ published.push(name);
+ return ok('');
+ }
+ throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
+ };
+ const lines = [];
+ return {
+ root,
+ run,
+ log: (line) => lines.push(line),
+ published,
+ commands,
+ lines
+ };
+}
+
+describe('publishing a release', () => {
+ it('publishes a first release, when the core has never been on npm: the core first', () => {
+ const release = workspace();
+
+ publishPackages(release);
+
+ assert.deepEqual(release.published, [CORE, NGX]);
+ });
+
+ it('publishes a release of both packages, the core first', () => {
+ const release = workspace({ onNpm: [`${CORE}@0.5.0`, `${NGX}@0.5.1`] });
+
+ publishPackages(release);
+
+ assert.deepEqual(release.published, [CORE, NGX]);
+ assert.deepEqual(release.lines, [
+ `${CORE}@0.6.0: published`,
+ `${NGX}@0.5.2: published`
+ ]);
+ });
+
+ it('retries a partial release: skips the core already on npm, publishes the Angular package', () => {
+ const release = workspace({ onNpm: [`${CORE}@0.6.0`] });
+
+ publishPackages(release);
+
+ assert.deepEqual(release.published, [NGX]);
+ assert.deepEqual(release.lines, [
+ `${CORE}@0.6.0: skipped, already on npm`,
+ `${NGX}@0.5.2: published`
+ ]);
+ });
+
+ it('can run again after a complete release, publishing nothing', () => {
+ const release = workspace({ onNpm: [`${CORE}@0.6.0`, `${NGX}@0.5.2`] });
+
+ publishPackages(release);
+
+ assert.deepEqual(release.published, []);
+ });
+
+ it('publishes on annotated release tags, as on lightweight ones', () => {
+ const release = workspace({ tags: [], annotated: [CORE_TAG, NGX_TAG] });
+
+ publishPackages(release);
+
+ assert.deepEqual(release.published, [CORE, NGX]);
+ });
+
+ it('publishes nothing at a commit no release tag points at', () => {
+ const release = workspace({ tags: [], elsewhere: [CORE_TAG, NGX_TAG] });
+
+ assert.throws(
+ () => publishPackages(release),
+ /no release tag points at a1b2c3d/
+ );
+ assert.ok(!release.commands.some((command) => command.startsWith('npm')));
+ });
+
+ it('never publishes the Angular package once the core fails to publish', () => {
+ const release = workspace({ failing: { [`publish ${CORE}`]: true } });
+
+ assert.throws(
+ () => publishPackages(release),
+ /npm publish @absaoss-cps\/ui-watchtower@0\.6\.0 failed/
+ );
+ assert.deepEqual(release.published, []);
+ assert.ok(!release.commands.some((command) => command.includes(NGX)));
+ });
+
+ it('stops on any npm answer other than not found, taking it for neither published nor missing', () => {
+ const release = workspace({ failing: { [`view ${CORE}@0.6.0`]: 'E401' } });
+
+ assert.throws(
+ () => publishPackages(release),
+ /npm view @absaoss-cps\/ui-watchtower@0\.6\.0 failed: E401/
+ );
+ assert.deepEqual(release.published, []);
+ });
+
+ it('publishes only the package released at this commit', () => {
+ const release = workspace({
+ tags: [NGX_TAG],
+ elsewhere: [CORE_TAG],
+ onNpm: [`${CORE}@0.6.0`]
+ });
+
+ publishPackages(release);
+
+ assert.deepEqual(release.published, [NGX]);
+ assert.equal(
+ release.lines[0],
+ `${CORE}@0.6.0: skipped, not released at this commit`
+ );
+ });
+
+ it('does not publish the Angular package before the core version it needs is on npm', () => {
+ const release = workspace({ tags: [NGX_TAG], elsewhere: [CORE_TAG] });
+
+ assert.throws(
+ () => publishPackages(release),
+ /needs @absaoss-cps\/ui-watchtower@0\.6\.0, which is not on npm/
+ );
+ assert.deepEqual(release.published, []);
+ });
+
+ it('publishes nothing when the Angular package does not depend on the core it was built against', () => {
+ const release = workspace({ range: '^0.5.0' });
+
+ assert.throws(
+ () => publishPackages(release),
+ /depends on @absaoss-cps\/ui-watchtower@\^0\.5\.0/
+ );
+ assert.deepEqual(release.published, []);
+ });
+
+ it('publishes nothing from a directory that is not the built package', () => {
+ const release = workspace({
+ built: { [NGX]: { name: NGX, version: '0.5.2', private: true } }
+ });
+
+ assert.throws(
+ () => publishPackages(release),
+ /dist\/ngx-ui-watchtower is not the publishable/
+ );
+ assert.deepEqual(release.published, []);
+ });
+});
+
+describe('the release wiring', () => {
+ const read = (path) => readFileSync(join(ROOT, path), 'utf8');
+ const config = JSON.parse(read('release-please-config.json'));
+ const workflow = read('.github/workflows/release-please.yml');
+ const releaseJob = workflow.slice(
+ workflow.indexOf('\n release-please:'),
+ workflow.indexOf('\n publish:')
+ );
+ const publishJob = workflow.slice(workflow.indexOf('\n publish:'));
+
+ it('publishes the packages Release Please releases, under the same names and tags', () => {
+ const manifest = JSON.parse(read('.release-please-manifest.json'));
+ const plugins = config.plugins.map((plugin) => plugin.type);
+
+ assert.ok(
+ plugins.includes('node-workspace'),
+ "node-workspace moves the adapters' dependency on the core"
+ );
+ assert.ok(
+ !plugins.includes('linked-versions'),
+ 'each package versioned on its own'
+ );
+
+ for (const pkg of PACKAGES) {
+ assert.equal(config.packages[pkg.path].component, pkg.component);
+ assert.equal(config.packages[pkg.path]['package-name'], pkg.name);
+ assert.equal(JSON.parse(read(`${pkg.path}/package.json`)).name, pkg.name);
+ assert.ok(
+ pkg.path in manifest || config.packages[pkg.path]['initial-version'],
+ `${pkg.path} is in the manifest, or has an initial-version`
+ );
+ assert.match(pkg.dist, /^dist\//);
+ }
+ assert.equal(JSON.parse(read('package.json')).private, true);
+ });
+
+ it('runs Release Please on pushes, and exposes each package release', () => {
+ assert.match(releaseJob, /if: github\.event_name == 'push'/);
+ for (const pkg of PACKAGES) {
+ assert.ok(
+ releaseJob.includes(
+ `steps.release.outputs['${pkg.path}--release_created']`
+ )
+ );
+ }
+ });
+
+ it('publishes after a release of either package, or when run by hand to retry', () => {
+ const condition = publishJob.slice(
+ publishJob.indexOf('if:'),
+ publishJob.indexOf('runs-on:')
+ );
+ const terms = condition.replace(/\s+/g, ' ');
+
+ assert.match(workflow, /\n {2}workflow_dispatch:/);
+ assert.ok(terms.includes('!cancelled()'));
+ assert.ok(terms.includes("github.event_name == 'workflow_dispatch'"));
+ assert.ok(
+ terms.includes(
+ "needs.release-please.outputs.core_release_created == 'true'"
+ )
+ );
+ assert.ok(
+ terms.includes(
+ "needs.release-please.outputs.ngx_release_created == 'true'"
+ )
+ );
+ });
+
+ it('builds, then publishes through this script, with trusted publishing and no cache', () => {
+ const steps = [
+ 'run: npm ci',
+ 'run: npm run build',
+ 'run: node scripts/publish-packages.mjs'
+ ];
+ const positions = steps.map((step) => publishJob.indexOf(step));
+
+ assert.ok(
+ positions.every((position) => position > 0),
+ 'every step is there'
+ );
+ assert.deepEqual(
+ [...positions].sort((a, b) => a - b),
+ positions
+ );
+ assert.match(publishJob, /id-token: write/);
+ assert.match(publishJob, /package-manager-cache: false/);
+ assert.doesNotMatch(publishJob, /npm publish/);
+ });
+});
diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs
new file mode 100644
index 0000000..b77a2a9
--- /dev/null
+++ b/scripts/release-check.mjs
@@ -0,0 +1,490 @@
+// Replays releases through Release Please on this repository's release config,
+// as the release workflow runs it: what each release PR changes, and which
+// releases merging it creates. GitHub is simulated in memory; Release Please
+// is installed, at the version the workflow's action bundles, into a
+// temporary directory, so this needs a network. `npm run release:check`.
+import { execFileSync, spawnSync } from 'node:child_process';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { PACKAGES } from './publish-packages.mjs';
+
+/** What googleapis/release-please-action@v5 (5.0.0) bundles: keep in step. */
+const RELEASE_PLEASE = '17.6.0';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const WORK = mkdtempSync(join(tmpdir(), 'uwt-release-check-'));
+const [CORE, NGX] = PACKAGES;
+const read = (path) => readFileSync(join(ROOT, path), 'utf8');
+const config = JSON.parse(read('release-please-config.json'));
+const manifest = JSON.parse(read('.release-please-manifest.json'));
+
+function check(condition, what) {
+ if (!condition) {
+ throw new Error(`release-check: expected ${what}`);
+ }
+}
+
+/** A repository holding `files` on main, its releases, commits (newest first) and merged PRs. */
+function gitHub({ files, releases = [], commits = [], pulls = [] }, Errors) {
+ const contents = (path) => {
+ if (!(path in files)) {
+ throw new Errors.FileNotFoundError(path);
+ }
+ return {
+ sha: path,
+ content: '',
+ parsedContent: files[path],
+ mode: '100644'
+ };
+ };
+ const yieldAll = (items) =>
+ async function* () {
+ yield* items;
+ };
+ return {
+ repository: {
+ owner: 'AbsaOSS',
+ repo: 'ui-watchtower',
+ defaultBranch: 'main'
+ },
+ getFileContentsOnBranch: async (path) => contents(path),
+ getFileJson: async (path) => JSON.parse(contents(path).parsedContent),
+ releaseIterator: yieldAll(releases),
+ tagIterator: yieldAll(
+ releases.map((release) => ({ name: release.tagName, sha: release.sha }))
+ ),
+ mergeCommitIterator: yieldAll(commits),
+ pullRequestIterator: yieldAll(pulls),
+ findFilesByFilenameAndRef: async () => [],
+ findFilesByGlobAndRef: async () => []
+ };
+}
+
+const release = (tagName, sha) => ({
+ name: tagName,
+ tagName,
+ sha,
+ notes: '',
+ url: ''
+});
+const commit = (sha, message, ...files) => ({ sha, message, files });
+const file = (pkg, name = 'src/index.ts') => `${pkg.path}/${name}`;
+const json = (files, path) => JSON.parse(files[path]);
+
+/**
+ * The release files with each package at its version in `versions` (by
+ * component), and a dependent on `^` its dependency's: what a release leaves
+ * behind.
+ */
+function filesAt(versions) {
+ const files = {
+ 'release-please-config.json': read('release-please-config.json'),
+ '.release-please-manifest.json': JSON.stringify(
+ Object.fromEntries(
+ PACKAGES.map((pkg) => [pkg.path, versions[pkg.component]])
+ )
+ )
+ };
+ for (const pkg of PACKAGES) {
+ const packageJson = JSON.parse(read(`${pkg.path}/package.json`));
+ packageJson.version = versions[pkg.component];
+ if (pkg.dependsOn) {
+ const dependency = PACKAGES.find((other) => other.name === pkg.dependsOn);
+ packageJson.dependencies[pkg.dependsOn] =
+ `^${versions[dependency.component]}`;
+ }
+ files[`${pkg.path}/package.json`] = JSON.stringify(packageJson, null, 2);
+ }
+ return files;
+}
+
+/** The minor release after `version`: what a `feat` brings before 1.0. */
+function minorAfter(version) {
+ const [major, minor] = version.split('.');
+ return `${major}.${Number(minor) + 1}.0`;
+}
+
+/** The repository after merging `pullRequest`. */
+function merge(files, pullRequest) {
+ const merged = { ...files };
+ for (const { path, createIfMissing, updater } of pullRequest.updates) {
+ if (path in merged || createIfMissing) {
+ merged[path] = updater.updateContent(merged[path]);
+ }
+ }
+ return merged;
+}
+
+/**
+ * Only keys Release Please defines: in its schema, or read by its config
+ * parser (the schema leaves a few out, `component` and `package-name` among
+ * them). A misspelt key is in neither, and would be silently ignored.
+ */
+function checkConfigKeys(configSchema, parser) {
+ const parsed = [...parser.matchAll(/config\['([\w-]+)'\]/g)].map(
+ (match) => match[1]
+ );
+ const known = (options, defined, where) => {
+ const keys = new Set([...Object.keys(defined), ...parsed]);
+ const unknown = Object.keys(options).filter((key) => !keys.has(key));
+ check(unknown.length === 0, `only known keys ${where}, not ${unknown}`);
+ };
+ known(config, configSchema.properties, 'in the config');
+ for (const [path, options] of Object.entries(config.packages)) {
+ known(
+ options,
+ configSchema.definitions.ReleaserConfigOptions.properties,
+ `for ${path}`
+ );
+ }
+ const plugins = configSchema.allOf[1].properties.plugins.items.anyOf;
+ for (const plugin of config.plugins) {
+ const variant = plugins.find((option) =>
+ option.properties?.type?.enum?.includes(plugin.type)
+ );
+ check(variant, `a plugin Release Please defines, not ${plugin.type}`);
+ const unknown = Object.keys(plugin).filter(
+ (key) => !(key in variant.properties)
+ );
+ check(
+ unknown.length === 0,
+ `only keys the ${plugin.type} plugin defines, not ${unknown}`
+ );
+ }
+}
+
+async function main() {
+ writeFileSync(join(WORK, 'package.json'), '{ "private": true }');
+ execFileSync(
+ 'npm',
+ [
+ 'install',
+ `release-please@${RELEASE_PLEASE}`,
+ '--no-audit',
+ '--no-fund',
+ '--loglevel=error'
+ ],
+ { cwd: WORK, stdio: 'inherit' }
+ );
+ const require = createRequire(join(WORK, 'package.json'));
+ const {
+ Errors,
+ Manifest,
+ configSchema,
+ setLogger
+ } = require('release-please');
+ const parser = readFileSync(
+ require.resolve('release-please/build/src/manifest.js'),
+ 'utf8'
+ );
+ const silent = () => {};
+ setLogger({
+ error: silent,
+ warn: silent,
+ info: silent,
+ debug: silent,
+ trace: silent
+ });
+
+ const manifestOf = (state) =>
+ Manifest.fromManifest(
+ gitHub(state, Errors),
+ 'main',
+ 'release-please-config.json',
+ '.release-please-manifest.json'
+ );
+ /** The release PR Release Please would open, if any. */
+ const pullRequestFor = async (state) => {
+ const pullRequests = await (await manifestOf(state)).buildPullRequests();
+ check(
+ pullRequests.length <= 1,
+ `one release PR at most, got ${pullRequests.length}`
+ );
+ return pullRequests[0];
+ };
+ /** After the PR: each package's version, and the Angular package's core range. */
+ const outcome = (files, pullRequest) => {
+ const merged = merge(files, pullRequest);
+ const versions = Object.fromEntries(
+ PACKAGES.map((pkg) => [
+ pkg.component,
+ json(merged, `${pkg.path}/package.json`).version
+ ])
+ );
+ const range = json(merged, `${NGX.path}/package.json`).dependencies[
+ CORE.name
+ ];
+ return { merged, versions, range };
+ };
+ /** A package's changelog entry for `version`: from its heading to the next. */
+ const entry = (merged, pkg, version) => {
+ const changelog = merged[`${pkg.path}/CHANGELOG.md`] ?? '';
+ const heading = new RegExp(
+ `^## \\[?${version.replace(/\./g, '\\.')}\\]?[ (]`,
+ 'm'
+ );
+ const start = changelog.search(heading);
+ if (start < 0) {
+ return '';
+ }
+ const end = changelog.indexOf('\n## ', start + 1);
+ return changelog.slice(start, end < 0 ? undefined : end);
+ };
+ /** The releases merging `pullRequest` creates, as `path tag sha`. */
+ const releasesAfter = async (state, pullRequest) => {
+ const title = pullRequest.title.toString();
+ const releases = await (
+ await manifestOf({
+ files: merge(state.files, pullRequest),
+ releases: state.releases,
+ commits: [commit('m1', title), ...state.commits],
+ pulls: [
+ {
+ headBranchName: pullRequest.headRefName,
+ baseBranchName: 'main',
+ number: 1,
+ title,
+ body: pullRequest.body.toString(),
+ labels: pullRequest.labels,
+ files: [],
+ sha: 'm1'
+ }
+ ]
+ })
+ ).buildReleases();
+ return releases
+ .map((created) => `${created.path} ${created.tag} ${created.sha}`)
+ .sort();
+ };
+ /** The releases expected for `[package, version]` pairs. */
+ const tagsOf = (...released) =>
+ released
+ .map(([pkg, version]) => `${pkg.path} ${pkg.component}-v${version} m1`)
+ .sort();
+ /** Each package released at its version in `versions`, then `changes`. */
+ const releasedAt = (versions, ...changes) => ({
+ files: filesAt(versions),
+ releases: PACKAGES.map((pkg) =>
+ release(`${pkg.component}-v${versions[pkg.component]}`, 'r1')
+ ),
+ commits: [
+ ...changes,
+ commit(
+ 'r1',
+ 'chore: release main',
+ ...PACKAGES.map((pkg) => file(pkg, 'package.json'))
+ )
+ ]
+ });
+
+ checkConfigKeys(configSchema, parser);
+ check(
+ PACKAGES.every(
+ (pkg) =>
+ pkg.path in manifest || config.packages[pkg.path]?.['initial-version']
+ ),
+ 'every package in the manifest, or given an initial-version'
+ );
+ check(
+ !config.plugins.some((plugin) => plugin.type === 'linked-versions'),
+ 'independent versions: no linked-versions group'
+ );
+ console.log(
+ '✔ config: only keys Release Please defines; each package versioned on its own'
+ );
+
+ // The first release, replayed from the workspace as it is. The core: its
+ // initial-version until it has a release of its own. The Angular package:
+ // its last release tagged v (before its tags named the component)
+ // at bootstrap-sha, which keeps what that release shipped out of the new
+ // changelog. While git has that tag, bootstrap-sha must be its commit.
+ const current = manifest[NGX.path];
+ const coreFirst = manifest[CORE.path]
+ ? minorAfter(manifest[CORE.path])
+ : config.packages[CORE.path]['initial-version'];
+ const ngxNext = minorAfter(current);
+ const lastTagged = spawnSync(
+ 'git',
+ ['rev-parse', '--verify', '--quiet', `v${current}^{commit}`],
+ { cwd: ROOT, encoding: 'utf8' }
+ ).stdout?.trim();
+ if (lastTagged) {
+ check(
+ config['bootstrap-sha'] === lastTagged,
+ `bootstrap-sha to be ${lastTagged}, the commit tagged v${current}`
+ );
+ }
+ const first = {
+ files: {
+ 'release-please-config.json': read('release-please-config.json'),
+ '.release-please-manifest.json': read('.release-please-manifest.json'),
+ [`${CORE.path}/package.json`]: read(`${CORE.path}/package.json`),
+ [`${NGX.path}/package.json`]: read(`${NGX.path}/package.json`),
+ [`${NGX.path}/CHANGELOG.md`]: read(`${NGX.path}/CHANGELOG.md`)
+ },
+ releases: [release(`v${current}`, config['bootstrap-sha'])],
+ commits: [
+ commit(
+ 'c3',
+ 'feat: add the framework-independent engine',
+ file(CORE),
+ file(NGX)
+ ),
+ commit('c2', 'fix: flush logs on page hide', file(NGX)),
+ commit('c1', 'feat: document the workspace', 'README.md'),
+ commit(
+ config['bootstrap-sha'],
+ `chore(main): release ${current}`,
+ file(NGX, 'package.json')
+ ),
+ commit('c0', 'feat: a feature already released', file(NGX))
+ ]
+ };
+ const firstPullRequest = await pullRequestFor(first);
+ check(firstPullRequest, 'a release PR for the first release');
+ const firstRelease = outcome(first.files, firstPullRequest);
+ const coreEntry = entry(firstRelease.merged, CORE, coreFirst);
+ const ngxEntry = entry(firstRelease.merged, NGX, ngxNext);
+ check(
+ firstRelease.versions[CORE.component] === coreFirst &&
+ firstRelease.versions[NGX.component] === ngxNext,
+ `the core at ${coreFirst} and the Angular package at ${ngxNext}, got ${JSON.stringify(firstRelease.versions)}`
+ );
+ check(
+ firstRelease.range === `^${coreFirst}`,
+ `the Angular package on ^${coreFirst}, got ${firstRelease.range}`
+ );
+ const firstManifest = json(
+ firstRelease.merged,
+ '.release-please-manifest.json'
+ );
+ check(
+ firstManifest[CORE.path] === coreFirst &&
+ firstManifest[NGX.path] === ngxNext,
+ `the manifest at ${coreFirst} and ${ngxNext}, got ${JSON.stringify(firstManifest)}`
+ );
+ check(
+ coreEntry && ngxEntry,
+ `a ${coreFirst} entry in the core's changelog and a ${ngxNext} one in the Angular package's`
+ );
+ check(
+ coreEntry.includes('add the framework-independent engine') &&
+ !coreEntry.includes('flush logs on page hide'),
+ "only the core's own changes in its changelog"
+ );
+ check(
+ ngxEntry.includes('flush logs on page hide') &&
+ ![coreEntry, ngxEntry].some((text) => text.includes('already released')),
+ 'nothing from the release before in the new entries'
+ );
+ const firstTags = await releasesAfter(first, firstPullRequest);
+ const firstExpected = tagsOf([CORE, coreFirst], [NGX, ngxNext]);
+ check(
+ JSON.stringify(firstTags) === JSON.stringify(firstExpected),
+ `merging it to create ${firstExpected.join(', ')}; got ${firstTags.join(', ')}`
+ );
+ console.log(
+ `✔ first release: the core at ${coreFirst}, the Angular package ${current} → ${ngxNext} on ^${coreFirst}, two changelogs` +
+ (lastTagged ? `, from v${current}'s commit` : '') +
+ `; merging creates ${CORE.component}-v${coreFirst} and ${NGX.component}-v${ngxNext}`
+ );
+
+ // Later releases, from packages at versions of their own.
+ const start = { [CORE.component]: '0.6.0', [NGX.component]: '0.5.1' };
+ for (const [what, changes, expected] of [
+ [
+ 'an Angular-only fix releases only the Angular package',
+ [commit('d1', 'fix: an Angular-only fix', file(NGX))],
+ { core: '0.6.0', ngx: '0.5.2', released: [NGX] }
+ ],
+ [
+ 'a core feature releases the core, and the Angular package to move its dependency',
+ [commit('d1', 'feat: a core feature', file(CORE))],
+ { core: '0.7.0', ngx: '0.5.2', released: [CORE, NGX] }
+ ],
+ [
+ 'a breaking core change before 1.0 does the same',
+ [commit('d1', 'feat!: drop an option', file(CORE))],
+ { core: '0.7.0', ngx: '0.5.2', released: [CORE, NGX] }
+ ],
+ [
+ 'changes to both give each its own bump',
+ [
+ commit('d2', 'feat: an Angular feature', file(NGX)),
+ commit('d1', 'fix: a core fix', file(CORE))
+ ],
+ { core: '0.6.1', ngx: '0.6.0', released: [CORE, NGX] }
+ ]
+ ]) {
+ const state = releasedAt(start, ...changes);
+ const pullRequest = await pullRequestFor(state);
+ check(pullRequest, `a release PR: ${what}`);
+ const { merged, versions, range } = outcome(state.files, pullRequest);
+ check(
+ versions[CORE.component] === expected.core &&
+ versions[NGX.component] === expected.ngx,
+ `${what}: the core at ${expected.core} and the Angular package at ${expected.ngx}, got ${JSON.stringify(versions)}`
+ );
+ check(
+ range === `^${expected.core}`,
+ `${what}: the Angular package on ^${expected.core}, got ${range}`
+ );
+ if (expected.released.includes(CORE)) {
+ const ngxEntry = entry(merged, NGX, expected.ngx);
+ check(
+ ngxEntry.includes(
+ `${CORE.name} bumped from ^${start[CORE.component]} to ^${expected.core}`
+ ),
+ `${what}: the dependency update in the Angular package's changelog, got ${JSON.stringify(ngxEntry)}`
+ );
+ }
+ const tags = await releasesAfter(state, pullRequest);
+ const tagsExpected = tagsOf(
+ ...expected.released.map((pkg) => [
+ pkg,
+ pkg === CORE ? expected.core : expected.ngx
+ ])
+ );
+ check(
+ JSON.stringify(tags) === JSON.stringify(tagsExpected),
+ `${what}: merging to create ${tagsExpected.join(', ')}; got ${tags.join(', ')}`
+ );
+ const core =
+ expected.core === start[CORE.component]
+ ? `core unchanged at ${expected.core}`
+ : `core ${start[CORE.component]} → ${expected.core}`;
+ console.log(
+ `✔ ${what}: ${core}, Angular ${start[NGX.component]} → ${expected.ngx} on ^${expected.core}`
+ );
+ }
+
+ const quiet = releasedAt(
+ start,
+ commit('e4', 'docs: explain a setting', file(CORE, 'README.md')),
+ commit('e3', 'refactor: tidy the engine', file(CORE)),
+ commit('e2', 'test: cover a case', file(NGX, 'src/a.spec.ts')),
+ commit('e1', 'feat: a workspace script', 'scripts/tool.mjs')
+ );
+ check(
+ !(await pullRequestFor(quiet)),
+ 'no release PR without a user-facing change to a package'
+ );
+ console.log(
+ '✔ no release from docs, refactors or tests, nor from changes outside the packages'
+ );
+}
+
+try {
+ await main();
+ console.log('release-check: ok');
+} catch (error) {
+ console.error(
+ error.message.startsWith('release-check:') ? error.message : error
+ );
+ process.exitCode = 1;
+} finally {
+ rmSync(WORK, { recursive: true, force: true });
+}