diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dddf35e..28c1f9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,10 @@ on: description: 'Recovery only: version to release from main, e.g. 2.0.0' required: true type: string + github_packages_only: + description: 'Mirror an existing release to GitHub Packages only; never tag or publish to npm' + type: boolean + default: false # Avoid concurrent publication attempts. concurrency: @@ -23,7 +27,7 @@ jobs: release: name: Tag, publish SDK, and create release if: >- - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.github_packages_only) || (github.event.pull_request.merged == true && (startsWith(github.event.pull_request.head.ref, 'release/v') || startsWith(github.event.pull_request.head.ref, 'dev-v'))) @@ -32,6 +36,8 @@ jobs: permissions: contents: write id-token: write + outputs: + tag: ${{ steps.metadata.outputs.tag }} env: RELEASE_BRANCH: ${{ github.event.pull_request.head.ref || format('release/v{0}', inputs.version) }} steps: @@ -99,3 +105,45 @@ jobs: if [[ "$RELEASE_TAG" == *-* ]]; then extra+=(--prerelease); fi gh release create "$RELEASE_TAG" artifacts/*.tgz artifacts/SHA256SUMS --verify-tag --notes-file RELEASE.md --title "$RELEASE_TAG" "${extra[@]}" fi + + github_packages: + name: Publish verified release to GitHub Packages + needs: release + if: >- + always() && + (needs.release.result == 'success' || + (github.event_name == 'workflow_dispatch' && inputs.github_packages_only)) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + env: + RELEASE_TAG: ${{ needs.release.outputs.tag || format('v{0}', inputs.version) }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 24.15.0 + registry-url: https://npm.pkg.github.com + scope: '@fleetbase' + - name: Download the existing release artifact without rebuilding + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + git merge-base --is-ancestor "$RELEASE_TAG^{commit}" origin/main + gh release download "$RELEASE_TAG" --pattern '*.tgz' --pattern SHA256SUMS --dir artifacts + - name: Publish identical bytes and verify GitHub Packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/publish-github.mjs "$RELEASE_TAG" + - name: Report GitHub package location and visibility + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api /orgs/fleetbase/packages/npm/sdk --jq '{name, visibility, html_url, repository: .repository.full_name}' diff --git a/MODERNIZATION_PLAN.md b/MODERNIZATION_PLAN.md deleted file mode 100644 index 709a783..0000000 --- a/MODERNIZATION_PLAN.md +++ /dev/null @@ -1,348 +0,0 @@ -# Fleetbase JavaScript SDK modernization and v2 release plan - -Status: first-party SDK regressions remediated on draft PR #36; native acceptance and owner-controlled release gates remain. Historical implementation PR #33 was closed by the branch rename. See [consumer compatibility review](docs/consumer-compatibility-review.md). - -Target branch: `release/v2.0.0` - -Prepared: 2026-08-31 - -## Executive summary - -The SDK needs a controlled replacement of its internals, packaging, tests, and release process—not an in-place rewrite with unverifiable compatibility claims. The recommended program is a compatibility-first v2 release: freeze and test the v1 public contract, refactor behind that contract, publish prereleases for real consumer testing, and promote only after every release gate passes. - -The v2 designation provides a safe adoption boundary for consumers and room to correct packaging and type-resolution behavior. It is not permission to discard the v1 API. Existing constructors, default and named exports, store/resource methods, adapter extension points, resource names, return shapes, mutation behavior, and error behavior are compatibility requirements unless a change is explicitly documented, deprecated, and approved. - -The work should be delivered as small, independently reviewable pull requests on this release branch. A single “big bang” rewrite would make regression diagnosis and API review unreasonably difficult. - -## Investigation findings - -### Repository and release state - -- npm currently publishes `@fleetbase/sdk@1.2.13`; it was published on 2025-02-03. The current npm package has 112 files and includes source, tests, CI configuration, editor configuration, build configuration, declarations, minified bundles, and source maps rather than a deliberate runtime-only allowlist. -- `master` points to v1.2.13. The open `dev-v1.2.14` pull request (#23) adds service quotes and is not included in `master`. The v2 contract inventory must include that API even if #23 is superseded rather than merged. -- Releases are tag-triggered but not lifecycle-managed. Versioning, changelog generation, GitHub Releases, tags, and package publication are not coordinated by one auditable release flow. -- npm and GitHub Packages are rebuilt and published in separate jobs. There is no guarantee they receive the same bytes. -- npm publication uses a long-lived token written into `~/.npmrc`; the workflow does not use npm trusted publishing (OIDC), release provenance, protected environments, or an immutable build artifact. - -### CI baseline - -- Source lint currently passes, but `pnpm run lint:tests` fails with 42 `no-undef` errors because Mocha globals are not configured. -- The current test run produces 15 passing and 9 failing tests without Fleetbase credentials and a reachable API. All nine failures come from live API CRUD tests. Pull-request correctness therefore depends on secrets, network availability, mutable remote data, and cleanup succeeding. -- No coverage provider, coverage report, or coverage threshold is configured. “100% coverage” is currently neither measured nor enforced. -- The workflow tests only Node 18, which reached end of life in March 2025. As of this plan, Node 22 and 24 are the supported LTS lines; Node 26 is Current. -- pnpm is installed as unpinned `latest`. This plan PR reproduced the resulting breakage in GitHub Actions on 2026-08-31: the selected pnpm required Node 22.13 or newer while the job ran Node 18.20.8, so dependency installation failed before tests. The cache also hashes `package-lock.json` even though the repository uses `pnpm-lock.yaml`, and installs are not explicitly documented as frozen/immutable. -- CI has no concurrency cancellation, minimal permission declarations, dependency review, code scanning, package smoke tests, type tests, bundle-size budget, or cross-platform/consumer matrix. - -### Dependency security baseline - -- A fresh `pnpm audit` on 2026-08-31 reported 1 critical, 39 high, 27 moderate, and 6 low findings in the lockfile. -- The runtime path includes Axios 1.7.9 and a vulnerable `form-data` version. Reported issues include the critical unsafe multipart-boundary advisory plus high-severity server-side request forgery, credential leakage, denial-of-service, redirect/proxy, and prototype-pollution advisories affecting the installed Axios line. -- Open Dependabot PR #31 proposes Axios 1.15.2, but current audit advisories require Axios 1.16.0 or newer for several findings. It must be re-evaluated rather than merged as a presumed complete security fix. -- High-severity build/test findings also affect the installed Rollup, Babel, glob/minimatch, serialization, and YAML-related dependency paths. Runtime and development findings should be triaged separately, but both must be resolved or explicitly risk-accepted before release. - -### Packaging and consumer compatibility - -- `package.json` declares `"type": "module"` but points the `require` export and `main` field to `dist/cjs/fleetbase.js`. A CommonJS build in a module-scoped `.js` file is ambiguous/incorrect packaging. A Node 24 smoke test returned an empty object from `require('@fleetbase/sdk')`, while ESM import exposed the expected named/default API. The CommonJS entry must be emitted as `.cjs` (with matching declarations) and tested from the packed tarball. -- Type declarations are present but `package.json` has no `types` field or `types` export condition and there is no root declaration entry matching the package entry point. Consumers therefore cannot reliably resolve them. -- Several declarations are invalid or stale: unresolved `[type]` and `mixed` placeholders, undeclared Axios types, incorrect return types, missing v1 exports/stores, and imports that do not match the implementation. -- Rollup minifies ESM and CommonJS outputs as well as the browser bundle, bundles Axios into every format, and emits roughly 314 KB per JavaScript entry in the audited local build. The packed v1.2.14 candidate was approximately 788 KB compressed and contained 112 files. -- The package has no `files` allowlist, `sideEffects` declaration, `engines`, `packageManager`, export-map type conditions, explicit browser/node entry strategy, or automated package-lint/type-resolution checks. -- `src/resources/vehicle.js` imports `Point` from the published package name instead of a local source module. That self-import can couple a source build to stale `dist` output, create duplicate identities, and obscure circular dependencies. -- Generated `dist` files are committed and a normal rebuild changes all generated bundles/source maps, creating high-noise diffs and a risk that source, package version, tag, and shipped bytes diverge. - -### Correctness and maintainability risks found during source review - -These are examples that the test program must capture before and during the refactor; they are not an exhaustive defect list. - -- `Resource.empty()` assigns `attribues` instead of `attributes`. -- `Resource.save({ onlyDirty: true })` calls `savedirty()` instead of `saveDirty()`. -- Each `Resource` instance assigns `this.changes = {}` while its prototype also defines `changes()`, shadowing the method. -- Async resource flags are reset only on success, so rejected requests can leave `isLoading`, `isSaving`, `isReloading`, or `isDestroying` stuck. -- `Fleetbase.setAdapter()` changes only the SDK property; stores created in the constructor continue using their previous adapters. -- `Store.destroy()` supplies adapter arguments inconsistently and can drop caller options. -- `isLongitude()` rejects valid longitudes between -90 and 90. -- Collection helpers reference `get` and `compare` without importing or defining them; lint suppression hides the problem. -- The registry starts with a misspelled `storse` key. -- The browser adapter assumes every response body is JSON, collapses structured HTTP errors into generic `Error` objects, uses environment globals directly, and attempts to set a browser `User-Agent` header. -- The environment detector classifies React Native as Node, but the Node adapter depends on Axios/Node-oriented behavior. The empty Ember adapter is exported but never automatically selected. -- The public declaration surface and runtime export surface have already drifted. A declaration-only review cannot prove compatibility. - -## Goals and non-goals - -### Required outcomes - -1. Deterministic, secure CI and automatic releases suitable for a public SDK. -2. Enforced 100% statement, branch, function, and line coverage for the maintained source boundary. -3. A complete internal refactor with an executable compatibility contract for v1 consumers. -4. Verified use across supported JavaScript runtimes, module systems, bundlers, frameworks, TypeScript modes, and common package managers. -5. A smaller, intentional package whose contents, types, exports, provenance, and release notes can be inspected before publication. - -### Non-goals - -- Redesigning the Fleetbase HTTP API. -- Removing v1 runtime APIs merely because they are awkward. -- Treating generated declaration files, coverage exclusions, snapshots, or ignored branches as a substitute for behavioral tests. -- Requiring live production credentials in pull-request CI. -- Publishing automatically from an arbitrary branch, unreviewed commit, or locally generated tag. - -## Compatibility contract - -Before refactoring, generate and commit a machine-readable v1 contract from both `v1.2.13` and the pending v1.2.14 service-quote work. - -The contract must cover: - -- Default export and every named export, including class/function identity and whether each is constructible. -- `Fleetbase` constructor arguments, `newInstance`, options/defaults, public properties, store names, adapter selection, `setAdapter`, and `getAdapter`. -- All adapter constructors and HTTP verb signatures, request URL/header/body behavior, error mapping, and custom-adapter extension behavior. -- Store constructors, actions, CRUD/query signatures, option forwarding, collection serialization, callbacks, and return types. -- Resource constructors, public fields/getters/methods, mutation/dirty tracking, flags, serialization, dates, save/reload/destroy behavior, and subclasses. -- Registry/resolver behavior and extension hooks. -- Utility functions/classes and edge-case behavior, including legacy quirks that consumers may rely on. -- ESM default/named imports, CommonJS `require`, documented deep imports if any exist in published consumers, and browser global behavior. -- TypeScript entry points and representative compile-time contracts. - -Use API snapshots plus black-box contract tests against packed v1 and v2 tarballs. When a current behavior is a confirmed defect, record it in a compatibility decision log with one of four dispositions: preserve, fix compatibly, deprecate then replace, or approve as breaking. No behavior changes should be hidden inside refactor commits. - -## Target architecture - -### Source and types - -- Migrate implementation modules to strict TypeScript incrementally, starting at leaf utilities/adapters and ending with stores/resources/Fleetbase. Do not change public names while moving files. -- Define explicit generic types for resource attributes, API envelopes, pagination/meta, request options, adapters, stores, actions, and errors. Replace `any`, `[type]`, and `mixed` placeholders at public boundaries. -- Generate declarations from the same source used to generate JavaScript. Add API-report review (for example, API Extractor or an equivalent declaration snapshot) so public type changes are visible in pull requests. -- Separate pure domain behavior from environment-specific transport. Keep the adapter interface stable and inject `fetch`/transport dependencies where needed for deterministic tests. -- Remove source-to-package self-imports and hidden global dependencies. Keep the registry only if compatibility tests prove it is needed; otherwise retain it as a thin compatibility facade over explicit factories. -- Introduce a typed `FleetbaseError` hierarchy while preserving legacy `Error` compatibility (`instanceof Error`, message semantics) and exposing status, code, request ID, and safe response data where available. - -### Package outputs - -- Publish one ESM entry (`.js` under `type: module`) and one true CommonJS entry (`.cjs`), with matching ESM/CJS declarations (`.d.ts`/`.d.cts`) and a `types` condition first in each export branch. -- Keep `main` and `module` fallbacks only where they improve compatibility with older tooling; make the `exports` map the source of truth. -- Provide an environment-neutral root and explicit adapter subpaths if needed, such as `@fleetbase/sdk/adapters/browser` and `@fleetbase/sdk/adapters/node`. Unknown runtimes must receive a safe default rather than pretending to be Node. -- Prefer standards-based `fetch` for supported Node and browsers if compatibility testing confirms equivalent behavior. If Axios remains, externalize it from browser-neutral bundles and isolate it to the Node adapter entry. -- Emit an optional minified IIFE/UMD browser artifact separately. Do not minify library ESM/CJS. Preserve source maps. -- Add `files` to publish only runtime outputs, declarations, README, license, and changelog. Exclude tests, source (unless intentionally offered for debugging), CI, editor settings, and build configuration. -- Declare `engines`, a pinned `packageManager`, `sideEffects` truthfully, repository/homepage/bugs metadata, and `publishConfig` for the public npm package. -- Enforce package checks with `pnpm pack`, tarball-content assertions, Publint, `@arethetypeswrong/cli`, ESM/CJS runtime smoke tests, type fixtures, and bundle-size budgets. - -## Testing strategy and 100% coverage - -### Test layers - -1. **Pure unit tests:** utilities, collections, resources, registry/resolver, store actions, state flags, serialization, errors, and every branch/failure path. -2. **Transport contract tests:** browser and Node adapters against mock HTTP servers/interceptors. Verify methods, query encoding, headers, bodies, empty/non-JSON responses, aborts, timeouts, network failures, all supported error envelopes, and option forwarding without external credentials. -3. **SDK contract tests:** constructor/defaults, all stores/resources/actions, custom adapters, adapter replacement, and v1 behavior snapshots. -4. **Package tests:** install only the generated tarball into isolated fixtures; test Node ESM and CommonJS, browser bundlers, TypeScript resolution, default/named/subpath imports, tree shaking, and absence of undeclared files/dependencies. -5. **Framework fixtures:** minimal build-and-run fixtures for Vite vanilla, React, Vue, Svelte, Angular, Next.js (client and server), Nuxt, Ember, and React Native/Expo where support is claimed. Fixtures should import the packed tarball, not workspace source. -6. **Optional integration tests:** scheduled or manually dispatched tests against a disposable Fleetbase environment. They validate real API drift but do not replace deterministic PR tests and do not block unrelated contributions on production availability. - -Use Vitest with V8 coverage (or an equivalently maintained runner/provider) and enforce 100% for lines, statements, functions, and branches in the maintained source. Coverage exclusions require code-owner review and must be limited to generated files, declarations, and demonstrably unreachable platform shims. Mutation testing on core store/resource/adapter behavior should be added as a scheduled quality signal so a numeric 100% cannot be achieved with weak assertions. - -Every regression fix must first add a failing test. The initial coverage PR must include a coverage-gap inventory and may raise thresholds in reviewed increments, but the release branch cannot reach stable v2 until all four metrics are 100% on a clean CI run. - -## Compatibility matrix - -### Required release-blocking matrix - -| Dimension | Required support/gate | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| Node | Node 22 and 24 LTS on Linux; smoke tests on macOS and Windows; Node 26 allowed-to-fail until it enters LTS, then promoted | -| Module systems | Native ESM import, dynamic import, and CommonJS require from the packed tarball | -| TypeScript | `node16`, `nodenext`, and `bundler` resolution; strict consumer fixtures; declarations match runtime exports | -| Package managers | npm, pnpm, Yarn Berry, and Bun install/import smoke tests using the same tarball | -| Bundlers | Vite/Rollup, webpack, esbuild, and the bundlers exercised transitively by required framework fixtures | -| Browsers | Current and previous major Chrome, Firefox, Safari, and Edge through browser tests; document the browserslist policy | -| Frameworks | Vite vanilla, React, Vue, Svelte, Angular, Next.js, Nuxt, and Ember minimal production builds | -| Mobile/edge | React Native/Expo and one standards-based edge runtime are release-blocking only after adapter behavior is explicitly supported and documented | - -The matrix must distinguish “supported and blocking” from “best effort/canary.” A badge or README claim is permitted only after the corresponding fixture runs in CI. - -## CI design - -Split workflows by responsibility and use least-privilege permissions, pinned major action versions, dependency caching through `setup-node`, a pinned pnpm version, and frozen lockfile installs. - -### `ci.yml` — every pull request and protected-branch push - -- Concurrency group with cancellation of superseded runs. -- Fast quality job: formatting check, source/test lint, strict typecheck, API report check, dependency-boundary/static checks. -- Unit/contract job on Node 22 and 24; upload coverage once; enforce all four 100% thresholds. -- Cross-platform smoke job for Linux, macOS, and Windows. -- Build once, create the npm tarball once, upload it as an immutable Actions artifact, and run all package/consumer jobs against that exact tarball. -- Package-quality job: content allowlist, Publint, Are The Types Wrong, ESM/CJS/type tests, export parity, source-map check, license/readme check, bundle-size budget. -- Framework/bundler and package-manager fixture jobs, grouped to control runtime while keeping failures attributable. -- Dependency review for pull requests and CodeQL/security scanning on supported triggers. -- Production-dependency audit as a release blocker, plus a separately reviewed full-toolchain audit; temporary exceptions must name the advisory, exposure, owner, expiry, and remediation issue. -- No Fleetbase production secrets in untrusted pull-request jobs. - -### `integration.yml` — scheduled and manual - -- Use an isolated Fleetbase tenant/environment and short-lived credentials. -- Create uniquely named records, track IDs, and clean them in `finally` hooks. -- Separate read-only health/API-contract checks from mutation tests. -- Upload sanitized diagnostics; never log keys or sensitive response bodies. -- Alert on API drift, but define an explicit policy for when a drift failure blocks a release. - -### `release.yml` — reviewed automation - -Use Changesets to record semver intent where useful, and prepare `release/v` PRs with current release notes. Merging a reviewed release branch into `main` automatically runs the complete release in `release.yml`; the PR approval is the publishing authorization. The workflow must: - -1. Re-run the complete protected CI suite on the exact commit. -2. Build and pack once. -3. Verify version/tag consistency and package contents. -4. Create an annotated tag on the exact PR merge commit, then publish to npm using the existing `NPM_AUTH_TOKEN` secret, matching the other modules, without a second approval or enable flag. Retain OIDC permissions for provenance; trusted-publisher registration is not required. -5. Publish provenance automatically, create the GitHub Release after registry verification, and attach checksums plus the exact tarball or build metadata. -6. Optionally publish the identical tarball to GitHub Packages in a separate authorized step; never rebuild it. -7. Verify the registry version, provenance, ESM/CJS imports, and a minimal API construction smoke test after publication. - -Configure prerelease channels: `next` for v2 prereleases and `latest` only for the approved stable promotion. Add a manual emergency path with the same protected gates and an auditable rollback/deprecation procedure; do not allow ad hoc local publishing. - -## Delivery phases and acceptance gates - -### Phase 0 — establish the release baseline - -Deliverables: - -- Decide whether to merge PR #23 as v1.2.14 first or supersede it in v2; in either case import its service-quote API into the v1 contract. -- Inventory npm exports, types, bundle contents, README examples, known downstream Fleetbase consumers, and documented deep imports. -- Add architecture decision records for runtime support, transport choice, TypeScript migration, package outputs, release automation, and committed-vs-generated `dist` policy. -- Capture current defects/quirks in a compatibility decision log. - -Gate: reviewers approve the contract and support matrix before refactor work starts. - -### Phase 1 — deterministic test and CI foundation - -Deliverables: - -- Replace live-API pull-request tests with deterministic unit/contract tests. -- Configure test globals correctly, frozen installs, current LTS matrix, caching, concurrency, permissions, and artifact upload. -- Upgrade or replace vulnerable runtime and toolchain dependencies; re-run the lockfile audit and document any time-bounded exceptions. -- Add initial package tarball tests and coverage reporting. -- Keep the live suite only as isolated scheduled/manual integration coverage. - -Gate: all PR checks are deterministic and green from a clean clone with no Fleetbase secrets. - -### Phase 2 — freeze v1 behavior and reach meaningful 100% coverage - -Deliverables: - -- Black-box contract harness that can execute against packed v1 and v2 candidates. -- Tests for every public API and the defect/failure paths listed in this plan. -- Four-axis 100% coverage enforcement and mutation-testing baseline. - -Gate: 100% lines, statements, branches, and functions on maintained source, plus reviewed mutation survivors; no broad ignores. - -### Phase 3 — package and type correctness - -Deliverables: - -- Correct ESM/CJS file extensions, export map, type conditions, generated declarations, package allowlist, metadata, and adapter subpaths. -- Package-lint, type-resolution, tarball, bundle-size, and source-map gates. -- Deprecation-compatible shims for any legacy import form that can be supported safely. - -Gate: every package fixture passes against the tarball; runtime/type export parity is exact; package contents and size meet approved budgets. - -### Phase 4 — internal TypeScript refactor - -Suggested review sequence: - -1. Pure utilities and collections. -2. Errors and shared HTTP contracts. -3. Browser/universal and Node adapters. -4. Registry/resolver and extension hooks. -5. Store and store actions. -6. Resource base class and resource subclasses. -7. `Fleetbase` client construction and adapter/store orchestration. -8. Remove compatibility scaffolding that contract evidence proves unnecessary. - -Gate after each pull request: v1 contract, 100% coverage, API report, tarball tests, and affected compatibility fixtures remain green. Refactor commits must not mix undocumented behavior changes. - -### Phase 5 — framework and package-manager expansion - -Deliverables: - -- CI fixtures for the required matrix. -- Explicit environment detection/adapter selection rules. -- Framework-specific documentation only where configuration is actually required (SSR boundaries, client-only keys, React Native transport, Ember use, edge limitations). -- Tested installation commands for npm, pnpm, Yarn, and Bun. - -Gate: every claimed environment installs and builds from the packed candidate; unsupported environments fail with actionable errors. - -### Phase 6 — prerelease validation - -Deliverables: - -- Publish `2.0.0-alpha`, then `beta`, then release candidates under the `next` dist-tag through the protected release workflow. -- Test at least the known Fleetbase first-party consumers plus representative ESM, CommonJS, TypeScript, browser, SSR, Ember, and mobile consumers. -- Publish migration guidance, compatibility table, deprecations, package-size changes, and rollback instructions. - -Gate: no unreviewed contract deltas; no critical/high defects; consumer sign-off recorded; release candidate passes full CI and post-publish verification. - -### Phase 7 — stable v2 and maintenance - -Deliverables: - -- Promote the approved candidate to `latest` without rebuilding different bytes. -- GitHub Release, changelog, provenance, checksums, migration guide, and support policy. -- Dependabot/Renovate grouping policy, monthly dependency maintenance, scheduled integration/mutation tests, and a defined Node/browser support rollover cadence. - -Gate: npm provenance and post-publish smoke checks succeed; rollback owner and procedure are available during the release window. - -## Pull-request slicing - -Use small PRs into `release/v2.0.0`, each with a changeset only when it changes a published behavior. Suggested slices: - -1. CI/test runner foundation. -2. v1 API and package contract snapshots. -3. Deterministic adapter test harness and removal of live PR dependencies. -4. Coverage expansion by module group until 100%. -5. Correct package outputs/export map/types and consumer fixtures. -6. Utilities/collection refactor. -7. Adapter/error refactor. -8. Store/resource refactor. -9. Client/registry refactor. -10. Framework/package-manager fixtures and documentation. -11. Release automation with a dry-run/staging proof. -12. v2 prerelease, first-party validation, and stable-promotion PR. - -Every PR description must include: public API effect, compatibility evidence, test/coverage evidence, package artifact diff, risk, and rollback. Generated output must never be accepted without its source change and a reproducibility check. - -## Release approval checklist - -- [x] v1.2.13 and v1.2.14 candidate contracts are captured and reviewed. -- [x] All maintained source is at 100% lines/statements/functions/branches. -- [x] Mutation-testing results for core modules are reviewed. -- [x] ESM, CommonJS, and TypeScript consumers pass from the packed tarball. -- [x] Required runtime/framework/bundler/package-manager matrix is green. -- [x] Publint and Are The Types Wrong report no release-blocking defects. -- [x] Package content and size budgets pass; no secrets or internal-only files are present. -- [x] API report contains no unapproved changes. -- [x] Full CI passes on the release commit with frozen dependencies. -- [x] Release workflow dry run proves one immutable artifact is used throughout. -- [ ] The existing `NPM_AUTH_TOKEN` is available to this repository and authorized for `@fleetbase/sdk`; the workflow retains provenance without an additional environment approval. -- [x] Migration guide, changelog, support policy, deprecations, and rollback steps are published. -- [ ] First-party Fleetbase consumers pass against the release candidate. -- [ ] Post-publish verification and provenance checks pass before `latest` promotion. - -## Success measures - -- Pull requests require no Fleetbase credentials and have repeatable results. -- Coverage remains at 100% across all four metrics, with reviewed mutation-test quality. -- Runtime and type exports cannot drift without a visible CI failure. -- The same verified tarball is used for all registries and release evidence. -- `require()` and `import` return compatible documented APIs. -- Package contents are intentionally allowlisted and substantially smaller than the current 112-file/1.72 MB unpacked publication baseline. -- All compatibility claims correspond to a maintained CI fixture. -- A release requires review but no local versioning, tagging, changelog editing, credential file creation, or manual npm publish command. - -## Primary references - -- [Node.js package entry points and conditional exports](https://nodejs.org/api/packages.html) -- [Node.js supported release lines](https://nodejs.org/en/about/previous-releases) -- [TypeScript declaration publishing](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html) -- [TypeScript package export resolution](https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-exports) -- [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) -- [npm provenance](https://docs.npmjs.com/generating-provenance-statements/) -- [GitHub Actions package publishing](https://docs.github.com/en/actions/tutorials/publish-packages/publish-nodejs-packages) -- [GitHub `setup-node` package-manager caching](https://github.com/actions/setup-node/blob/main/docs/advanced-usage.md#caching-packages-data) -- [Vitest coverage configuration](https://vitest.dev/config/coverage.html) -- [Changesets workflow and commands](https://github.com/changesets/changesets/blob/main/docs/command-line-options.md) diff --git a/README.md b/README.md index 6fced99..2a7df74 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Pull-request tests are deterministic and do not need Fleetbase credentials. API Maintainers can also run the secret-gated `Live API integration` workflow. It installs the exact packed SDK candidate and performs a read-only current-organization request using the `FLEETBASE_PUBLIC_KEY` repository secret; scheduled runs skip cleanly until that secret is configured, while manually dispatched runs fail clearly when it is absent. -See the [modernization plan](https://github.com/fleetbase/fleetbase-js/blob/main/MODERNIZATION_PLAN.md) for the v2 compatibility and release program and the [contribution guide](https://github.com/fleetbase/fleetbase-js/blob/main/CONTRIBUTING.md) for the development workflow. +See the [release guide](https://github.com/fleetbase/fleetbase-js/blob/main/docs/releasing.md) for publishing and the [contribution guide](https://github.com/fleetbase/fleetbase-js/blob/main/CONTRIBUTING.md) for the development workflow. ## License diff --git a/docs/releasing.md b/docs/releasing.md index c233793..53b989f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,6 +20,7 @@ The workflow retains `id-token: write` for npm provenance; npm publication is au 6. Continue in the **same job** to pack and publish the SDK to npm using `NPM_AUTH_TOKEN`, with provenance. This does not rely on a second tag-triggered workflow, so GitHub's token-trigger recursion rules cannot silently stop the release. Unlike the modules' split tag/publish workflows, no `_GITHUB_AUTH_TOKEN` is needed. 7. Verify the registry tarball checksum byte-for-byte and test clean ESM/CommonJS installation. 8. Create the GitHub release from RELEASE.md and attach the exact published tarball and SHA256SUMS. Preserve workflow artifacts for 90 days. +9. A dependent job downloads that release asset, checks its identity and checksum against npm, and publishes the identical tarball to GitHub Packages. It uses `GITHUB_TOKEN` with `packages: write`, not `NPM_AUTH_TOKEN`, and verifies the downloaded GitHub package byte-for-byte. Nothing is rebuilt. There is only one publishing workflow. Ordinary feature PRs, closed-but-unmerged PRs, branch pushes, and manual tag pushes do not publish. @@ -29,7 +30,13 @@ Use `next` for prereleases and `latest` for stable releases. Example: merging `r Re-running the failed merge workflow uses the same reviewed commit. Existing tags on that commit are accepted; tags on other commits are never moved. If npm already accepted the version, the publisher continues only if its tarball matches the newly packed artifact byte-for-byte. -The workflow_dispatch recovery path runs **only on main** and requires an explicit version. It releases that dispatch's main commit, subject to the same metadata and verification checks. A recovery commit cannot reuse an existing version/tag pointing elsewhere: rerun the original release or prepare a new version. +The normal workflow_dispatch recovery path runs **only on main** and requires an explicit version. It releases that dispatch's main commit, subject to the same metadata and verification checks. A recovery commit cannot reuse an existing version/tag pointing elsewhere: rerun the original release or prepare a new version. + +### GitHub Packages-only recovery + +Run `Release SDK` manually with the existing version (for example `2.0.0`) and `github_packages_only` enabled. This skips the npm/tag/release job entirely and mirrors the existing release asset. It can run from a reviewed workflow-fix branch to recover a missing mirror before that fix merges; the existing release tag must still belong to main. It cannot rebuild, create a new tag, or republish to npm. Retries accept an existing identical GitHub package but refuse conflicting bytes. + +GitHub Packages uses `https://npm.pkg.github.com` and links the package to this repository via the artifact's `repository` field. Its job intentionally disables npm-specific provenance generation; the original npm provenance and release checksums remain intact. Package access/visibility is managed by GitHub independently of npm. See [GitHub's npm registry documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry). Missing npm authorization or a failed validation fails the release visibly; it does not report success after silently skipping publication. A failed npm publication can leave the tag created; use the retry path after correcting authorization. Do not delete/move tags or replace published bytes. diff --git a/package.json b/package.json index e38ddff..821b4ec 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", - "test:release": "node --test scripts/release-tag.test.mjs", + "test:release": "node --test scripts/release-tag.test.mjs scripts/release-github.test.mjs", "test:mutation": "stryker run", "package:check": "publint && attw --pack . && node scripts/verify-package.mjs", "verify": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test:release && pnpm run test:coverage && pnpm run build && pnpm run package:check", diff --git a/scripts/publish-github.mjs b/scripts/publish-github.mjs new file mode 100644 index 0000000..085c5e4 --- /dev/null +++ b/scripts/publish-github.mjs @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const registry = 'https://npm.pkg.github.com'; +const hash = (bytes, algorithm = 'sha256') => createHash(algorithm).update(bytes).digest('hex'); + +export function validateArtifact(tag, pkg, filename, bytes, checksums) { + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag) || tag !== `v${pkg.version}`) { + throw new Error('Release tag and artifact version must agree.'); + } + if (pkg.name !== '@fleetbase/sdk' || pkg.repository?.url !== 'git+https://github.com/fleetbase/fleetbase-js.git') { + throw new Error('Artifact must belong to @fleetbase/sdk in fleetbase/fleetbase-js.'); + } + if (filename !== `fleetbase-sdk-${pkg.version}.tgz` || checksums.trim() !== `${hash(bytes)} ${filename}`) { + throw new Error('Release artifact checksum or filename does not match.'); + } + return { spec: `${pkg.name}@${pkg.version}`, distTag: pkg.version.includes('-') ? 'next' : 'latest' }; +} + +export function npmCommand(args) { + const result = spawnSync('npm', [...args, '--registry', registry], { encoding: 'utf8' }); + if (result.error) throw result.error; + if (result.status !== 0) { + let code; + try { + code = JSON.parse(result.stdout).error?.code; + } catch { + // npm can fail before producing JSON; never log authentication-related output. + } + throw Object.assign(new Error(`GitHub Packages npm ${args[0]} failed (${code ?? result.status}).`), { code }); + } + return JSON.parse(result.stdout); +} + +export async function mirrorArtifact({ spec, distTag, tarball, bytes, run = npmCommand }) { + let existing; + try { + existing = run(['view', spec, 'dist', '--json']); + } catch (error) { + if (error.code !== 'E404') throw error; + } + if (existing) { + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + if (existing.integrity ? existing.integrity !== integrity : existing.shasum !== hash(bytes, 'sha1')) { + throw new Error('GitHub Packages already has different bytes for this version; refusing to overwrite.'); + } + } else { + // The tarball's npm provenance setting must not be applied to GitHub's registry. + run(['publish', tarball, '--ignore-scripts', '--provenance=false', '--tag', distTag, '--json']); + } + + const destination = await mkdtemp(join(tmpdir(), 'fleetbase-github-verify-')); + try { + const packed = run(['pack', spec, '--ignore-scripts', '--pack-destination', destination, '--json']); + const filename = packed[0]?.filename; + if (!filename || filename !== filename.split(/[\\/]/).pop()) throw new Error('Invalid registry tarball filename.'); + const downloaded = await readFile(join(destination, filename)); + if (hash(downloaded) !== hash(bytes)) throw new Error('GitHub Packages downloaded tarball differs from the release artifact.'); + } finally { + await rm(destination, { recursive: true, force: true }); + } + return existing ? 'verified existing' : 'published and verified'; +} + +async function main() { + if (process.env.CI !== 'true' || process.env.GITHUB_ACTIONS !== 'true') { + throw new Error('GitHub Packages publication is restricted to GitHub Actions.'); + } + if (!process.env.NODE_AUTH_TOKEN) throw new Error('GitHub Packages requires NODE_AUTH_TOKEN.'); + const tag = process.argv[2]; + const directory = resolve('artifacts'); + const tarballs = (await readdir(directory)).filter((name) => name.endsWith('.tgz')); + if (tarballs.length !== 1) throw new Error('Expected exactly one release tarball.'); + const filename = tarballs[0]; + const tarball = join(directory, filename); + const bytes = await readFile(tarball); + const pkg = JSON.parse(execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8' })); + const metadata = validateArtifact(tag, pkg, filename, bytes, await readFile(join(directory, 'SHA256SUMS'), 'utf8')); + + // The published npm bytes are the independent source of truth for recovery, too. + const response = await fetch(`https://registry.npmjs.org/@fleetbase%2Fsdk/${pkg.version}`, { signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`npm release metadata returned HTTP ${response.status}.`); + const npmMetadata = await response.json(); + const npmTarball = new URL(npmMetadata.dist.tarball); + if (npmTarball.origin !== 'https://registry.npmjs.org') throw new Error('Unexpected npm tarball origin.'); + const published = await fetch(npmTarball, { signal: AbortSignal.timeout(30_000), redirect: 'error' }); + if (!published.ok || hash(Buffer.from(await published.arrayBuffer())) !== hash(bytes)) { + throw new Error('Release artifact does not match the already published npm bytes.'); + } + const result = await mirrorArtifact({ ...metadata, tarball, bytes }); + console.log(`${result}: ${metadata.spec} on GitHub Packages, identical to npm and the release asset.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/release-github.test.mjs b/scripts/release-github.test.mjs new file mode 100644 index 0000000..ef4abfe --- /dev/null +++ b/scripts/release-github.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { mirrorArtifact, validateArtifact } from './publish-github.mjs'; + +const bytes = Buffer.from('immutable release fixture'); +const checksum = createHash('sha256').update(bytes).digest('hex'); +const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; +const pkg = { name: '@fleetbase/sdk', version: '2.0.0', repository: { url: 'git+https://github.com/fleetbase/fleetbase-js.git' } }; +const filename = 'fleetbase-sdk-2.0.0.tgz'; +const checksums = `${checksum} ${filename}\n`; + +test('validates artifact identity, release version, filename, and checksum', () => { + assert.deepEqual(validateArtifact('v2.0.0', pkg, filename, bytes, checksums), { spec: '@fleetbase/sdk@2.0.0', distTag: 'latest' }); + assert.throws(() => validateArtifact('v2.0.1', pkg, filename, bytes, checksums), /version/); + assert.throws(() => validateArtifact('main', pkg, filename, bytes, checksums), /version/); + assert.throws(() => validateArtifact('v2.0.0', { ...pkg, name: '@other/sdk' }, filename, bytes, checksums), /belong/); + assert.throws(() => validateArtifact('v2.0.0', { ...pkg, repository: {} }, filename, bytes, checksums), /belong/); + assert.throws(() => validateArtifact('v2.0.0', pkg, '../bad.tgz', bytes, checksums), /filename/); + assert.throws(() => validateArtifact('v2.0.0', pkg, filename, Buffer.from('changed'), checksums), /checksum/); + const version = '2.1.0-rc.1'; + const name = `fleetbase-sdk-${version}.tgz`; + assert.equal(validateArtifact(`v${version}`, { ...pkg, version }, name, bytes, `${checksum} ${name}`).distTag, 'next'); +}); + +function runner({ existing, error, downloaded = bytes } = {}) { + const calls = []; + const run = (args) => { + calls.push(args); + if (args[0] === 'view') { + if (error) throw error; + if (existing) return existing; + throw Object.assign(new Error('Not found'), { code: 'E404' }); + } + if (args[0] === 'pack') { + writeFileSync(join(args[args.indexOf('--pack-destination') + 1], filename), downloaded); + return [{ filename }]; + } + return {}; + }; + return { run, calls }; +} + +const artifact = { spec: '@fleetbase/sdk@2.0.0', distTag: 'latest', tarball: '/fixture/released.tgz', bytes }; + +test('publishes the original tarball without scripts or npm provenance and verifies downloaded bytes', async () => { + const { run, calls } = runner(); + assert.equal(await mirrorArtifact({ ...artifact, run }), 'published and verified'); + assert.deepEqual(calls[1], ['publish', artifact.tarball, '--ignore-scripts', '--provenance=false', '--tag', 'latest', '--json']); + assert.equal(calls[2][0], 'pack'); +}); + +test('retry verifies an identical existing version without republishing', async () => { + for (const existing of [{ integrity }, { shasum: createHash('sha1').update(bytes).digest('hex') }]) { + const { run, calls } = runner({ existing }); + assert.equal(await mirrorArtifact({ ...artifact, run }), 'verified existing'); + assert.deepEqual( + calls.map(([command]) => command), + ['view', 'pack'] + ); + } +}); + +test('refuses conflicts and authentication errors without publishing', async () => { + const conflict = runner({ existing: { integrity: 'sha512-wrong' } }); + await assert.rejects(mirrorArtifact({ ...artifact, run: conflict.run }), /different bytes/); + assert.equal(conflict.calls.length, 1); + const denied = runner({ error: Object.assign(new Error('Unauthorized'), { code: 'E401' }) }); + await assert.rejects(mirrorArtifact({ ...artifact, run: denied.run }), /Unauthorized/); + assert.equal(denied.calls.length, 1); +}); + +test('rejects registry tarballs that differ from the released bytes', async () => { + const { run } = runner({ downloaded: Buffer.from('wrong artifact') }); + await assert.rejects(mirrorArtifact({ ...artifact, run }), /differs/); +}); + +test('CLI refuses publication outside GitHub Actions', () => { + const result = spawnSync(process.execPath, ['scripts/publish-github.mjs', 'v2.0.0'], { env: { ...process.env, CI: '', GITHUB_ACTIONS: '' }, encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /restricted to GitHub Actions/); +});