From 432d48e0d2d5e88d5fab976255c77b630bfe217a Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:53 +0100 Subject: [PATCH] feat: release and publish both packages independently, with docs and logos --- .github/workflows/checks.yml | 28 +- .github/workflows/release-please.yml | 22 +- README.md | 154 ++++++-- assets/logo/logo-stacked-auto.svg | 2 +- assets/logo/logo-stacked-dark.svg | 2 +- assets/logo/logo-stacked-light.svg | 2 +- assets/logo/ngx-logo-stacked-auto.svg | 1 + assets/logo/ngx-logo-stacked-dark.svg | 1 + assets/logo/ngx-logo-stacked-light.svg | 1 + docs/ADAPTERS.md | 127 +++++++ package.json | 6 +- projects/ngx-ui-watchtower/DESIGN.md | 27 +- projects/ngx-ui-watchtower/README.md | 33 +- projects/ui-watchtower/README.md | 82 ++++- release-please-config.json | 43 ++- scripts/publish-packages.mjs | 198 ++++++++++ scripts/publish-packages.test.mjs | 350 ++++++++++++++++++ scripts/release-check.mjs | 490 +++++++++++++++++++++++++ 18 files changed, 1471 insertions(+), 98 deletions(-) create mode 100644 assets/logo/ngx-logo-stacked-auto.svg create mode 100644 assets/logo/ngx-logo-stacked-dark.svg create mode 100644 assets/logo/ngx-logo-stacked-light.svg create mode 100644 docs/ADAPTERS.md create mode 100644 scripts/publish-packages.mjs create mode 100644 scripts/publish-packages.test.mjs create mode 100644 scripts/release-check.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fbe6838..b2041ed 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -62,12 +62,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@v2 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 @@ -108,6 +108,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 2059a80..ccff32f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@

- - - - Angular UI Watchtower - + UI Watchtower + UI Watchtower

-# 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` | Compares the built public APIs with their committed snapshots | -| `npm run api:update` | Writes those snapshots again, for an intended API change | -| `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` | Compares the built public APIs with their committed snapshots | +| `npm run api:update` | Writes those snapshots again, for an intended API change | +| `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 @@ -Angular UI Watchtower +UI Watchtower 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 @@ -Angular UI Watchtower +UI Watchtower 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 @@ -Angular UI Watchtower +UI Watchtower 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 @@ +Angular UI Watchtower 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 @@ +Angular UI Watchtower 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 @@ +Angular UI Watchtower 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 2b85890..354a0d9 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": "node scripts/api-snapshot.mjs", "api:update": "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 @@

- - - Angular UI Watchtower + + + Angular UI Watchtower

@@ -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 + +

+ # 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 }); +}