From d85aa2d6c4e2bfce96e1cdec8bfe553034fb8fe5 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 5 Nov 2026 12:21:30 +0100 Subject: [PATCH 1/2] ci: publish the verified npm prerelease cohort --- .github/workflows/ci_check.yml | 10 +++ .github/workflows/npm_release.yml | 90 +++++++++++++++++++++++++++ docs/provenance.md | 37 ++++++++++- specs/CATALOG.md | 6 +- specs/catalog.json | 4 +- specs/shared-ui-framework-delivery.md | 23 ++++--- tests/delivery.test.mjs | 74 +++++++++++++++++++++- tooling/npm-prerelease.mjs | 44 +++++++++++++ tooling/packaging.mjs | 7 ++- 9 files changed, 276 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/npm_release.yml create mode 100644 tooling/npm-prerelease.mjs diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml index 9280783..2fe282e 100644 --- a/.github/workflows/ci_check.yml +++ b/.github/workflows/ci_check.yml @@ -69,3 +69,13 @@ jobs: if-no-files-found: error include-hidden-files: true retention-days: 7 + - name: Retain the verified npm candidate for the production SHA + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: npm-candidate-${{ github.sha }} + path: | + test-results/packed-cohort.json + test-results/packages/*.tgz + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml new file mode 100644 index 0000000..521eeff --- /dev/null +++ b/.github/workflows/npm_release.yml @@ -0,0 +1,90 @@ +name: Publish ha-ui npm prerelease + +on: + push: + tags: ['v*-next.*'] + +concurrency: + group: ha-ui-npm-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + candidate: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + run-id: ${{ steps.ci.outputs.run-id }} + steps: + - name: Find the successful CI run for this production commit + id: ci + env: + GH_TOKEN: ${{ github.token }} + run: | + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-next\.[0-9]+$ ]] + run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci_check.yml/runs?event=push&branch=master&head_sha=$GITHUB_SHA&status=success&per_page=10" \ + --jq '.workflow_runs[0].id // empty') + [[ "$run_id" =~ ^[1-9][0-9]*$ ]] + echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + + publish: + needs: candidate + runs-on: ubuntu-latest + environment: npm + timeout-minutes: 10 + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.0.1 + with: + node-version-file: .node-version + registry-url: https://registry.npmjs.org + package-manager-cache: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: npm-candidate-${{ github.sha }} + path: ${{ runner.temp }}/ha-ui-npm-candidate + github-token: ${{ github.token }} + run-id: ${{ needs.candidate.outputs.run-id }} + - name: Publish the verified archives through npm Trusted Publishing + env: + NPM_CANDIDATE_DIR: ${{ runner.temp }}/ha-ui-npm-candidate + run: | + node --input-type=module <<'NODE' + import {execFileSync} from 'node:child_process'; + import {appendFileSync,readFileSync} from 'node:fs'; + import {join} from 'node:path'; + import {packageCoordinates,sha256} from './tooling/packaging.mjs'; + import {observeNpmVersion,publishNpmPrerelease} from './tooling/npm-prerelease.mjs'; + + const directory=process.env.NPM_CANDIDATE_DIR; + const {candidate}=JSON.parse(readFileSync(join(directory,'packed-cohort.json'),'utf8')); + const tag=process.env.GITHUB_REF_NAME; + const version=tag.slice(1); + const paths=new Map(packageCoordinates.map(name=>[ + name,join(directory,'packages',name.slice(1).replace('/','-')+'-'+version+'.tgz'), + ])); + const archives=new Map([...paths].map(([name,path])=>[name,readFileSync(path)])); + const result=await publishNpmPrerelease({ + record:candidate,sourceRevision:process.env.GITHUB_SHA,lockSha256:sha256(readFileSync('pnpm-lock.yaml')),tag, + oidc:Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL&&process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN), + archives,observe:observeNpmVersion, + publish:async entry=>execFileSync('npm',[ + 'publish',paths.get(entry.coordinate),'--access','public','--tag','next','--ignore-scripts', + ],{stdio:'inherit',timeout:120_000}), + append:async entry=>appendFileSync(process.env.GITHUB_STEP_SUMMARY, + '- '+entry.coordinate+'@'+entry.version+' `'+entry.sha256+'`\n'), + }); + if(result.state!=='published')throw new Error(result.state+': '+result.reason); + NODE diff --git a/docs/provenance.md b/docs/provenance.md index bd084c4..e4da8b6 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -218,11 +218,44 @@ source revision and lock. `verifyCandidate` fails before use if any bytes, revis lock change. `assertShippedManifest` requires exact version pins for the shared cohort and rejects local fixture references in shared dependencies and overrides. -The release use case requires separate Gateway and Domus evidence for the same candidate -and a successful publisher-rights check. Registry observations are stored for the exact +Stable publication requires separate Gateway and Domus evidence for the same candidate. +The npm prerelease publishes reviewed packed artifacts so that those applications can +complete installed acceptance. Both channels require an authorized publisher. Registry observations are stored for the exact coordinate, version and digest. Recovery reads before attempting a missing publication and checks the observed digest before recording it. A conflict remains blocked; a partial publication leaves consumer pins unadvanced. An uncertain attempt is inspected once, and a later run rechecks the registry before publishing. The delivery tests exercise this policy with a deterministic provider; no registry publication or publisher rights are claimed by those tests. + +### Publish a prerelease to npm + +The core and the four adapters share one exact version. Prepare the package manifests +and packed consumer pins in a reviewed version change, including the Angular core +dependency and the fixture lockfiles. Merge it to the production +branch and wait for its `CI Check` to succeed. Create a version tag such as +`v0.1.0-next.1` at that production commit. [The npm workflow](../.github/workflows/npm_release.yml) +starts automatically and uses the five tarballs retained by CI for that SHA. + +Configure an npm Trusted Publisher for each coordinate in the table below. Use +organization `vypdev`, repository `ha-ui`, workflow `npm_release.yml`, and environment +`npm`. Enable direct publishing for that workflow. npm authorizes each write and +generates provenance from the GitHub runner. + +| Package | Intended installation | +| --- | --- | +| `@vypdev/ha-ui` | Lit or browser custom elements | +| `@vypdev/ha-ui-react` | React | +| `@vypdev/ha-ui-vue` | Vue | +| `@vypdev/ha-ui-angular` | Angular | +| `@vypdev/ha-ui-svelte` | Svelte | + +The workflow publishes the core first and sets the npm `next` tag. It downloads the +exact version of each package and checks its SHA-256 against the CI artifact before +reporting success. If a run stops after publishing some packages, rerun the same +workflow. Existing versions are verified by hash and reused. An unexpected hash +requires a new reviewed package version. Keep consumer pins on the previous cohort +until the workflow confirms all five archives. + +See [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) for configuring +the publisher and [consumer adoption](migration.md) for testing the installed version. diff --git a/specs/CATALOG.md b/specs/CATALOG.md index d4538cf..4245aa7 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -10,7 +10,7 @@ debt or convert unknown historic intent into a design decision. | Capability ID | Status | Scope | Primary SDD | Evidence | |---|---|---|---|---| -| `shared-ui-foundation` | Proposed | One DOM component implementation with React, Vue, Angular and Svelte adapters, direct Lit/DOM use, shared Storybook and verified Gateway/Domus adoption. Partial source; no complete capability or release accepted. | [Shared Home Assistant presentation components and framework adapters](./shared-ui-foundation.md) + 1 companion | 335 paths · 2026-11-05 | +| `shared-ui-foundation` | Proposed | One DOM component implementation with React, Vue, Angular and Svelte adapters, direct Lit/DOM use, shared Storybook and verified Gateway/Domus adoption. Partial source; no complete capability or release accepted. | [Shared Home Assistant presentation components and framework adapters](./shared-ui-foundation.md) + 1 companion | 337 paths · 2026-11-05 | ## Evidence map @@ -19,9 +19,9 @@ debt or convert unknown historic intent into a design decision. - Owner: vypdev maintainers; Efrain Espada (accountable product/publication owner) - Last verified: 2026-11-05 - Specifications: [`specs/shared-ui-foundation.md`](./shared-ui-foundation.md) · [`specs/shared-ui-framework-delivery.md`](./shared-ui-framework-delivery.md) -- Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) · [`.github/workflows/storybook_pages.yml`](../.github/workflows/storybook_pages.yml) +- Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/ci_check.yml`](../.github/workflows/ci_check.yml) · [`.github/workflows/npm_release.yml`](../.github/workflows/npm_release.yml) · [`.github/workflows/storybook_pages.yml`](../.github/workflows/storybook_pages.yml) - Entrypoints: [`packages/core/src/index.ts`](../packages/core/src/index.ts) · [`packages/core/src/components/tabs.ts`](../packages/core/src/components/tabs.ts) · [`packages/react/src/index.ts`](../packages/react/src/index.ts) · [`packages/vue/src/index.ts`](../packages/vue/src/index.ts) · [`packages/angular/src/index.ts`](../packages/angular/src/index.ts) · [`packages/svelte/src/index.ts`](../packages/svelte/src/index.ts) · [`packages/core/src/components/field.ts`](../packages/core/src/components/field.ts) · [`packages/core/src/field-contracts.ts`](../packages/core/src/field-contracts.ts) · [`.storybook/main.ts`](../.storybook/main.ts) · [`packages/core/src/button-contracts.ts`](../packages/core/src/button-contracts.ts) · [`packages/core/src/components/button.ts`](../packages/core/src/components/button.ts) · [`packages/core/src/select-contracts.ts`](../packages/core/src/select-contracts.ts) · [`packages/core/src/components/select.ts`](../packages/core/src/components/select.ts) · [`packages/core/src/checkbox-contracts.ts`](../packages/core/src/checkbox-contracts.ts) · [`packages/core/src/components/checkbox.ts`](../packages/core/src/components/checkbox.ts) · [`packages/core/src/components/dialog.ts`](../packages/core/src/components/dialog.ts) · [`packages/core/src/dialog-contracts.ts`](../packages/core/src/dialog-contracts.ts) · [`packages/core/src/components/card.ts`](../packages/core/src/components/card.ts) · [`packages/core/src/card-contracts.ts`](../packages/core/src/card-contracts.ts) · [`packages/core/src/layout-contracts.ts`](../packages/core/src/layout-contracts.ts) · [`packages/core/src/components/layout.ts`](../packages/core/src/components/layout.ts) · [`packages/core/src/components/feedback.ts`](../packages/core/src/components/feedback.ts) · [`packages/core/src/feedback-contracts.ts`](../packages/core/src/feedback-contracts.ts) -- Core code: [`packages/core/src/contracts.ts`](../packages/core/src/contracts.ts) · [`tooling/coverage.mjs`](../tooling/coverage.mjs) · [`packages/core/src/components/tabs.ts`](../packages/core/src/components/tabs.ts) · [`packages/react/src/index.ts`](../packages/react/src/index.ts) · [`packages/vue/src/index.ts`](../packages/vue/src/index.ts) · [`packages/angular/src/index.ts`](../packages/angular/src/index.ts) · [`packages/svelte/src/index.ts`](../packages/svelte/src/index.ts) · [`packages/svelte/src/Tabs.svelte`](../packages/svelte/src/Tabs.svelte) · [`packages/svelte/src/types.ts`](../packages/svelte/src/types.ts) · [`packages/core/src/components/field.ts`](../packages/core/src/components/field.ts) · [`packages/core/src/field-contracts.ts`](../packages/core/src/field-contracts.ts) · [`packages/core/src/presentation-text.ts`](../packages/core/src/presentation-text.ts) · [`packages/react/src/text-field.ts`](../packages/react/src/text-field.ts) · [`packages/vue/src/text-field.ts`](../packages/vue/src/text-field.ts) · [`packages/angular/src/text-field.ts`](../packages/angular/src/text-field.ts) · [`packages/svelte/src/Field.svelte`](../packages/svelte/src/Field.svelte) · [`stories/catalog-state.ts`](../stories/catalog-state.ts) · [`stories/catalog-frame.ts`](../stories/catalog-frame.ts) · [`stories/catalog-example.ts`](../stories/catalog-example.ts) · [`stories/foundation.example.ts`](../stories/foundation.example.ts) · [`stories/panels.example.ts`](../stories/panels.example.ts) · [`stories/field.example.ts`](../stories/field.example.ts) · [`.storybook/preview.ts`](../.storybook/preview.ts) · [`packages/core/src/button-contracts.ts`](../packages/core/src/button-contracts.ts) · [`packages/core/src/components/button.ts`](../packages/core/src/components/button.ts) · [`stories/button.example.ts`](../stories/button.example.ts) · [`packages/react/src/button.ts`](../packages/react/src/button.ts) · [`packages/vue/src/button.ts`](../packages/vue/src/button.ts) · [`packages/angular/src/button.ts`](../packages/angular/src/button.ts) · [`packages/svelte/src/Button.svelte`](../packages/svelte/src/Button.svelte) · [`packages/core/src/select-contracts.ts`](../packages/core/src/select-contracts.ts) · [`packages/core/src/components/select.ts`](../packages/core/src/components/select.ts) · [`packages/react/src/select.ts`](../packages/react/src/select.ts) · [`packages/vue/src/select.ts`](../packages/vue/src/select.ts) · [`packages/angular/src/select.ts`](../packages/angular/src/select.ts) · [`packages/svelte/src/Select.svelte`](../packages/svelte/src/Select.svelte) · [`stories/select.example.ts`](../stories/select.example.ts) · [`packages/core/src/checkbox-contracts.ts`](../packages/core/src/checkbox-contracts.ts) · [`packages/core/src/components/checkbox.ts`](../packages/core/src/components/checkbox.ts) · [`packages/core/src/components/implicit-submit.ts`](../packages/core/src/components/implicit-submit.ts) · [`packages/react/src/checkbox.ts`](../packages/react/src/checkbox.ts) · [`packages/vue/src/checkbox.ts`](../packages/vue/src/checkbox.ts) · [`packages/angular/src/checkbox.ts`](../packages/angular/src/checkbox.ts) · [`packages/svelte/src/Checkbox.svelte`](../packages/svelte/src/Checkbox.svelte) · [`stories/checkbox.example.ts`](../stories/checkbox.example.ts) · [`stories/catalog-completion.ts`](../stories/catalog-completion.ts) · [`stories/navigation-icons.ts`](../stories/navigation-icons.ts) · [`packages/core/src/navigation-scroll.ts`](../packages/core/src/navigation-scroll.ts) · [`packages/core/src/components/navigation-overflow.ts`](../packages/core/src/components/navigation-overflow.ts) · [`packages/vue/src/native-host-attributes.ts`](../packages/vue/src/native-host-attributes.ts) · [`packages/core/src/components/dialog.ts`](../packages/core/src/components/dialog.ts) · [`packages/core/src/dialog-contracts.ts`](../packages/core/src/dialog-contracts.ts) · [`packages/core/src/components/card.ts`](../packages/core/src/components/card.ts) · [`packages/core/src/card-contracts.ts`](../packages/core/src/card-contracts.ts) · [`packages/react/src/dialog.ts`](../packages/react/src/dialog.ts) · [`packages/react/src/card.ts`](../packages/react/src/card.ts) · [`packages/vue/src/dialog.ts`](../packages/vue/src/dialog.ts) · [`packages/vue/src/card.ts`](../packages/vue/src/card.ts) · [`packages/angular/src/dialog.ts`](../packages/angular/src/dialog.ts) · [`packages/angular/src/card.ts`](../packages/angular/src/card.ts) · [`packages/svelte/src/Dialog.svelte`](../packages/svelte/src/Dialog.svelte) · [`packages/svelte/src/Card.svelte`](../packages/svelte/src/Card.svelte) · [`stories/dialog.example.ts`](../stories/dialog.example.ts) · [`stories/card.example.ts`](../stories/card.example.ts) · [`tooling/raster-archive.mjs`](../tooling/raster-archive.mjs) · [`packages/core/src/layout-contracts.ts`](../packages/core/src/layout-contracts.ts) · [`packages/core/src/components/layout.ts`](../packages/core/src/components/layout.ts) · [`packages/react/src/layout.ts`](../packages/react/src/layout.ts) · [`packages/vue/src/layout.ts`](../packages/vue/src/layout.ts) · [`packages/angular/src/layout.ts`](../packages/angular/src/layout.ts) · [`packages/svelte/src/Row.svelte`](../packages/svelte/src/Row.svelte) · [`packages/svelte/src/Grid.svelte`](../packages/svelte/src/Grid.svelte) · [`stories/row.example.ts`](../stories/row.example.ts) · [`stories/grid.example.ts`](../stories/grid.example.ts) · [`packages/angular/src/feedback.ts`](../packages/angular/src/feedback.ts) · [`packages/core/src/components/feedback.ts`](../packages/core/src/components/feedback.ts) · [`packages/core/src/feedback-contracts.ts`](../packages/core/src/feedback-contracts.ts) · [`packages/react/src/feedback.ts`](../packages/react/src/feedback.ts) · [`packages/svelte/src/Feedback.svelte`](../packages/svelte/src/Feedback.svelte) · [`packages/vue/src/feedback.ts`](../packages/vue/src/feedback.ts) · [`stories/feedback.example.ts`](../stories/feedback.example.ts) · [`tooling/compatibility.mjs`](../tooling/compatibility.mjs) · [`tooling/packaging.mjs`](../tooling/packaging.mjs) +- Core code: [`packages/core/src/contracts.ts`](../packages/core/src/contracts.ts) · [`tooling/coverage.mjs`](../tooling/coverage.mjs) · [`packages/core/src/components/tabs.ts`](../packages/core/src/components/tabs.ts) · [`packages/react/src/index.ts`](../packages/react/src/index.ts) · [`packages/vue/src/index.ts`](../packages/vue/src/index.ts) · [`packages/angular/src/index.ts`](../packages/angular/src/index.ts) · [`packages/svelte/src/index.ts`](../packages/svelte/src/index.ts) · [`packages/svelte/src/Tabs.svelte`](../packages/svelte/src/Tabs.svelte) · [`packages/svelte/src/types.ts`](../packages/svelte/src/types.ts) · [`packages/core/src/components/field.ts`](../packages/core/src/components/field.ts) · [`packages/core/src/field-contracts.ts`](../packages/core/src/field-contracts.ts) · [`packages/core/src/presentation-text.ts`](../packages/core/src/presentation-text.ts) · [`packages/react/src/text-field.ts`](../packages/react/src/text-field.ts) · [`packages/vue/src/text-field.ts`](../packages/vue/src/text-field.ts) · [`packages/angular/src/text-field.ts`](../packages/angular/src/text-field.ts) · [`packages/svelte/src/Field.svelte`](../packages/svelte/src/Field.svelte) · [`stories/catalog-state.ts`](../stories/catalog-state.ts) · [`stories/catalog-frame.ts`](../stories/catalog-frame.ts) · [`stories/catalog-example.ts`](../stories/catalog-example.ts) · [`stories/foundation.example.ts`](../stories/foundation.example.ts) · [`stories/panels.example.ts`](../stories/panels.example.ts) · [`stories/field.example.ts`](../stories/field.example.ts) · [`.storybook/preview.ts`](../.storybook/preview.ts) · [`packages/core/src/button-contracts.ts`](../packages/core/src/button-contracts.ts) · [`packages/core/src/components/button.ts`](../packages/core/src/components/button.ts) · [`stories/button.example.ts`](../stories/button.example.ts) · [`packages/react/src/button.ts`](../packages/react/src/button.ts) · [`packages/vue/src/button.ts`](../packages/vue/src/button.ts) · [`packages/angular/src/button.ts`](../packages/angular/src/button.ts) · [`packages/svelte/src/Button.svelte`](../packages/svelte/src/Button.svelte) · [`packages/core/src/select-contracts.ts`](../packages/core/src/select-contracts.ts) · [`packages/core/src/components/select.ts`](../packages/core/src/components/select.ts) · [`packages/react/src/select.ts`](../packages/react/src/select.ts) · [`packages/vue/src/select.ts`](../packages/vue/src/select.ts) · [`packages/angular/src/select.ts`](../packages/angular/src/select.ts) · [`packages/svelte/src/Select.svelte`](../packages/svelte/src/Select.svelte) · [`stories/select.example.ts`](../stories/select.example.ts) · [`packages/core/src/checkbox-contracts.ts`](../packages/core/src/checkbox-contracts.ts) · [`packages/core/src/components/checkbox.ts`](../packages/core/src/components/checkbox.ts) · [`packages/core/src/components/implicit-submit.ts`](../packages/core/src/components/implicit-submit.ts) · [`packages/react/src/checkbox.ts`](../packages/react/src/checkbox.ts) · [`packages/vue/src/checkbox.ts`](../packages/vue/src/checkbox.ts) · [`packages/angular/src/checkbox.ts`](../packages/angular/src/checkbox.ts) · [`packages/svelte/src/Checkbox.svelte`](../packages/svelte/src/Checkbox.svelte) · [`stories/checkbox.example.ts`](../stories/checkbox.example.ts) · [`stories/catalog-completion.ts`](../stories/catalog-completion.ts) · [`stories/navigation-icons.ts`](../stories/navigation-icons.ts) · [`packages/core/src/navigation-scroll.ts`](../packages/core/src/navigation-scroll.ts) · [`packages/core/src/components/navigation-overflow.ts`](../packages/core/src/components/navigation-overflow.ts) · [`packages/vue/src/native-host-attributes.ts`](../packages/vue/src/native-host-attributes.ts) · [`packages/core/src/components/dialog.ts`](../packages/core/src/components/dialog.ts) · [`packages/core/src/dialog-contracts.ts`](../packages/core/src/dialog-contracts.ts) · [`packages/core/src/components/card.ts`](../packages/core/src/components/card.ts) · [`packages/core/src/card-contracts.ts`](../packages/core/src/card-contracts.ts) · [`packages/react/src/dialog.ts`](../packages/react/src/dialog.ts) · [`packages/react/src/card.ts`](../packages/react/src/card.ts) · [`packages/vue/src/dialog.ts`](../packages/vue/src/dialog.ts) · [`packages/vue/src/card.ts`](../packages/vue/src/card.ts) · [`packages/angular/src/dialog.ts`](../packages/angular/src/dialog.ts) · [`packages/angular/src/card.ts`](../packages/angular/src/card.ts) · [`packages/svelte/src/Dialog.svelte`](../packages/svelte/src/Dialog.svelte) · [`packages/svelte/src/Card.svelte`](../packages/svelte/src/Card.svelte) · [`stories/dialog.example.ts`](../stories/dialog.example.ts) · [`stories/card.example.ts`](../stories/card.example.ts) · [`tooling/raster-archive.mjs`](../tooling/raster-archive.mjs) · [`packages/core/src/layout-contracts.ts`](../packages/core/src/layout-contracts.ts) · [`packages/core/src/components/layout.ts`](../packages/core/src/components/layout.ts) · [`packages/react/src/layout.ts`](../packages/react/src/layout.ts) · [`packages/vue/src/layout.ts`](../packages/vue/src/layout.ts) · [`packages/angular/src/layout.ts`](../packages/angular/src/layout.ts) · [`packages/svelte/src/Row.svelte`](../packages/svelte/src/Row.svelte) · [`packages/svelte/src/Grid.svelte`](../packages/svelte/src/Grid.svelte) · [`stories/row.example.ts`](../stories/row.example.ts) · [`stories/grid.example.ts`](../stories/grid.example.ts) · [`packages/angular/src/feedback.ts`](../packages/angular/src/feedback.ts) · [`packages/core/src/components/feedback.ts`](../packages/core/src/components/feedback.ts) · [`packages/core/src/feedback-contracts.ts`](../packages/core/src/feedback-contracts.ts) · [`packages/react/src/feedback.ts`](../packages/react/src/feedback.ts) · [`packages/svelte/src/Feedback.svelte`](../packages/svelte/src/Feedback.svelte) · [`packages/vue/src/feedback.ts`](../packages/vue/src/feedback.ts) · [`stories/feedback.example.ts`](../stories/feedback.example.ts) · [`tooling/compatibility.mjs`](../tooling/compatibility.mjs) · [`tooling/packaging.mjs`](../tooling/packaging.mjs) · [`tooling/npm-prerelease.mjs`](../tooling/npm-prerelease.mjs) - Tests: [`tests/contracts/core.test.ts`](../tests/contracts/core.test.ts) · [`tests/coverage.test.mjs`](../tests/coverage.test.mjs) · [`tests/architecture.test.mjs`](../tests/architecture.test.mjs) · [`tests/coverage-evidence.test.mjs`](../tests/coverage-evidence.test.mjs) · [`tests/consumers/packed.test.ts`](../tests/consumers/packed.test.ts) · [`tests/contracts/registration.test.ts`](../tests/contracts/registration.test.ts) · [`tests/browser/tabs.test.ts`](../tests/browser/tabs.test.ts) · [`tests/browser/react.test.ts`](../tests/browser/react.test.ts) · [`tests/browser/vue.test.ts`](../tests/browser/vue.test.ts) · [`tests/browser/angular.test.ts`](../tests/browser/angular.test.ts) · [`tests/browser/svelte.test.mjs`](../tests/browser/svelte.test.mjs) · [`tests/browser/framework-fixture.ts`](../tests/browser/framework-fixture.ts) · [`tests/browser/fixtures/SvelteTabs.svelte`](../tests/browser/fixtures/SvelteTabs.svelte) · [`tests/consumers/frameworks.ts`](../tests/consumers/frameworks.ts) · [`tests/consumers/fixtures/svelte/pnpm-lock.yaml`](../tests/consumers/fixtures/svelte/pnpm-lock.yaml) · [`tests/consumers/fixtures/svelte/index.html`](../tests/consumers/fixtures/svelte/index.html) · [`tests/consumers/fixtures/svelte/Consumer.svelte`](../tests/consumers/fixtures/svelte/Consumer.svelte) · [`tests/consumers/fixtures/svelte/model.ts`](../tests/consumers/fixtures/svelte/model.ts) · [`tests/consumers/fixtures/svelte/package.json`](../tests/consumers/fixtures/svelte/package.json) · [`tests/consumers/fixtures/svelte/client.ts`](../tests/consumers/fixtures/svelte/client.ts) · [`tests/consumers/fixtures/svelte/tsconfig.json`](../tests/consumers/fixtures/svelte/tsconfig.json) · [`tests/consumers/fixtures/svelte/pnpm-workspace.yaml`](../tests/consumers/fixtures/svelte/pnpm-workspace.yaml) · [`tests/consumers/fixtures/vue/pnpm-lock.yaml`](../tests/consumers/fixtures/vue/pnpm-lock.yaml) · [`tests/consumers/fixtures/vue/index.html`](../tests/consumers/fixtures/vue/index.html) · [`tests/consumers/fixtures/vue/model.ts`](../tests/consumers/fixtures/vue/model.ts) · [`tests/consumers/fixtures/vue/package.json`](../tests/consumers/fixtures/vue/package.json) · [`tests/consumers/fixtures/vue/client.ts`](../tests/consumers/fixtures/vue/client.ts) · [`tests/consumers/fixtures/vue/tsconfig.json`](../tests/consumers/fixtures/vue/tsconfig.json) · [`tests/consumers/fixtures/vue/pnpm-workspace.yaml`](../tests/consumers/fixtures/vue/pnpm-workspace.yaml) · [`tests/consumers/fixtures/angular/pnpm-lock.yaml`](../tests/consumers/fixtures/angular/pnpm-lock.yaml) · [`tests/consumers/fixtures/angular/index.html`](../tests/consumers/fixtures/angular/index.html) · [`tests/consumers/fixtures/angular/model.ts`](../tests/consumers/fixtures/angular/model.ts) · [`tests/consumers/fixtures/angular/package.json`](../tests/consumers/fixtures/angular/package.json) · [`tests/consumers/fixtures/angular/client.ts`](../tests/consumers/fixtures/angular/client.ts) · [`tests/consumers/fixtures/angular/tsconfig.json`](../tests/consumers/fixtures/angular/tsconfig.json) · [`tests/consumers/fixtures/angular/pnpm-workspace.yaml`](../tests/consumers/fixtures/angular/pnpm-workspace.yaml) · [`tests/consumers/fixtures/react/pnpm-lock.yaml`](../tests/consumers/fixtures/react/pnpm-lock.yaml) · [`tests/consumers/fixtures/react/index.html`](../tests/consumers/fixtures/react/index.html) · [`tests/consumers/fixtures/react/model.ts`](../tests/consumers/fixtures/react/model.ts) · [`tests/consumers/fixtures/react/package.json`](../tests/consumers/fixtures/react/package.json) · [`tests/consumers/fixtures/react/client.ts`](../tests/consumers/fixtures/react/client.ts) · [`tests/consumers/fixtures/react/tsconfig.json`](../tests/consumers/fixtures/react/tsconfig.json) · [`tests/consumers/fixtures/react/pnpm-workspace.yaml`](../tests/consumers/fixtures/react/pnpm-workspace.yaml) · [`tests/contracts/field.test.ts`](../tests/contracts/field.test.ts) · [`tests/browser/field.test.ts`](../tests/browser/field.test.ts) · [`tests/browser/react-field.test.ts`](../tests/browser/react-field.test.ts) · [`tests/browser/vue-field.test.ts`](../tests/browser/vue-field.test.ts) · [`tests/browser/angular-field.test.ts`](../tests/browser/angular-field.test.ts) · [`tests/browser/svelte-field.test.mjs`](../tests/browser/svelte-field.test.mjs) · [`tests/browser/field-framework-fixture.ts`](../tests/browser/field-framework-fixture.ts) · [`tests/browser/fixtures/SvelteField.svelte`](../tests/browser/fixtures/SvelteField.svelte) · [`tests/consumers/fields.ts`](../tests/consumers/fields.ts) · [`tests/catalog-config.test.mjs`](../tests/catalog-config.test.mjs) · [`tests/contracts/catalog.test.ts`](../tests/contracts/catalog.test.ts) · [`tests/typing/storybook.ts`](../tests/typing/storybook.ts) · [`tests/storybook/static.test.ts`](../tests/storybook/static.test.ts) · [`stories/foundation.stories.ts`](../stories/foundation.stories.ts) · [`stories/panels.stories.ts`](../stories/panels.stories.ts) · [`stories/field.stories.ts`](../stories/field.stories.ts) · [`tests/contracts/button.test.ts`](../tests/contracts/button.test.ts) · [`tests/browser/button.test.ts`](../tests/browser/button.test.ts) · [`tests/consumers/buttons.ts`](../tests/consumers/buttons.ts) · [`stories/button.stories.ts`](../stories/button.stories.ts) · [`tests/browser/button-framework-fixture.ts`](../tests/browser/button-framework-fixture.ts) · [`tests/browser/react-button.test.ts`](../tests/browser/react-button.test.ts) · [`tests/browser/vue-button.test.ts`](../tests/browser/vue-button.test.ts) · [`tests/browser/angular-button.test.ts`](../tests/browser/angular-button.test.ts) · [`tests/browser/svelte-button.test.mjs`](../tests/browser/svelte-button.test.mjs) · [`tests/browser/fixtures/SvelteButton.svelte`](../tests/browser/fixtures/SvelteButton.svelte) · [`tests/consumers/framework-buttons.ts`](../tests/consumers/framework-buttons.ts) · [`tests/contracts/select.test.ts`](../tests/contracts/select.test.ts) · [`tests/browser/select.test.ts`](../tests/browser/select.test.ts) · [`tests/browser/select-framework-fixture.ts`](../tests/browser/select-framework-fixture.ts) · [`tests/browser/react-select.test.ts`](../tests/browser/react-select.test.ts) · [`tests/browser/vue-select.test.ts`](../tests/browser/vue-select.test.ts) · [`tests/browser/angular-select.test.ts`](../tests/browser/angular-select.test.ts) · [`tests/browser/svelte-select.test.mjs`](../tests/browser/svelte-select.test.mjs) · [`tests/browser/fixtures/SvelteSelect.svelte`](../tests/browser/fixtures/SvelteSelect.svelte) · [`tests/consumers/selects.ts`](../tests/consumers/selects.ts) · [`stories/select.stories.ts`](../stories/select.stories.ts) · [`tests/contracts/checkbox.test.ts`](../tests/contracts/checkbox.test.ts) · [`tests/browser/checkbox.test.ts`](../tests/browser/checkbox.test.ts) · [`tests/browser/checkbox-framework-fixture.ts`](../tests/browser/checkbox-framework-fixture.ts) · [`tests/browser/react-checkbox.test.ts`](../tests/browser/react-checkbox.test.ts) · [`tests/browser/vue-checkbox.test.ts`](../tests/browser/vue-checkbox.test.ts) · [`tests/browser/angular-checkbox.test.ts`](../tests/browser/angular-checkbox.test.ts) · [`tests/browser/svelte-checkbox.test.mjs`](../tests/browser/svelte-checkbox.test.mjs) · [`tests/browser/fixtures/SvelteCheckbox.svelte`](../tests/browser/fixtures/SvelteCheckbox.svelte) · [`tests/consumers/checkboxes.ts`](../tests/consumers/checkboxes.ts) · [`tests/consumers/fonts.ts`](../tests/consumers/fonts.ts) · [`tests/storybook/checkbox-visual.ts`](../tests/storybook/checkbox-visual.ts) · [`tests/typing/catalog-assets.d.ts`](../tests/typing/catalog-assets.d.ts) · [`stories/checkbox.stories.ts`](../stories/checkbox.stories.ts) · [`tests/browser/catalog-lifecycle.test.ts`](../tests/browser/catalog-lifecycle.test.ts) · [`tests/storybook/lifecycle.ts`](../tests/storybook/lifecycle.ts) · [`tests/storybook/screenshot.ts`](../tests/storybook/screenshot.ts) · [`tests/browser/navigation-visual.test.ts`](../tests/browser/navigation-visual.test.ts) · [`tests/storybook/navigation-visual.ts`](../tests/storybook/navigation-visual.ts) · [`tests/browser/navigation-icons.test.ts`](../tests/browser/navigation-icons.test.ts) · [`tests/consumers/navigation-icons.ts`](../tests/consumers/navigation-icons.ts) · [`tests/storybook/navigation-icons-visual.ts`](../tests/storybook/navigation-icons-visual.ts) · [`tests/browser/navigation-overflow.test.ts`](../tests/browser/navigation-overflow.test.ts) · [`tests/browser/navigation-overflow-lifecycle.test.ts`](../tests/browser/navigation-overflow-lifecycle.test.ts) · [`tests/browser/navigation-overflow-visual.test.ts`](../tests/browser/navigation-overflow-visual.test.ts) · [`tests/contracts/navigation-scroll.test.ts`](../tests/contracts/navigation-scroll.test.ts) · [`tests/storybook/navigation-overflow-visual.ts`](../tests/storybook/navigation-overflow-visual.ts) · [`tests/consumers/navigation-overflow.ts`](../tests/consumers/navigation-overflow.ts) · [`tests/browser/button-presentation.test.ts`](../tests/browser/button-presentation.test.ts) · [`tests/browser/button-visual.test.ts`](../tests/browser/button-visual.test.ts) · [`tests/storybook/button-visual.ts`](../tests/storybook/button-visual.ts) · [`tests/browser/field-presentation.test.ts`](../tests/browser/field-presentation.test.ts) · [`tests/browser/field-visual.test.ts`](../tests/browser/field-visual.test.ts) · [`tests/browser/implicit-submit-native.test.ts`](../tests/browser/implicit-submit-native.test.ts) · [`tests/storybook/field-visual.ts`](../tests/storybook/field-visual.ts) · [`tests/consumers/implicit-submit.ts`](../tests/consumers/implicit-submit.ts) · [`tests/storybook/form-lifecycle.ts`](../tests/storybook/form-lifecycle.ts) · [`tests/browser/vue-host-attributes.test.ts`](../tests/browser/vue-host-attributes.test.ts) · [`tests/contracts/raster-archive.test.mjs`](../tests/contracts/raster-archive.test.mjs) · [`tests/storybook/reference-images.ts`](../tests/storybook/reference-images.ts) · [`tests/storybook/card-visual.ts`](../tests/storybook/card-visual.ts) · [`tests/storybook/dialog-visual.ts`](../tests/storybook/dialog-visual.ts) · [`tests/contracts/card.test.ts`](../tests/contracts/card.test.ts) · [`tests/contracts/dialog.test.ts`](../tests/contracts/dialog.test.ts) · [`tests/browser/card.test.ts`](../tests/browser/card.test.ts) · [`tests/browser/dialog.test.ts`](../tests/browser/dialog.test.ts) · [`tests/consumers/cards.ts`](../tests/consumers/cards.ts) · [`tests/consumers/dialogs.ts`](../tests/consumers/dialogs.ts) · [`tests/storybook/reference/ha-button/index.json`](../tests/storybook/reference/ha-button/index.json) · [`tests/storybook/reference/ha-button/originals.tar`](../tests/storybook/reference/ha-button/originals.tar) · [`tests/storybook/reference/ha-card/index.json`](../tests/storybook/reference/ha-card/index.json) · [`tests/storybook/reference/ha-card/originals.tar`](../tests/storybook/reference/ha-card/originals.tar) · [`tests/storybook/reference/ha-checkbox/index.json`](../tests/storybook/reference/ha-checkbox/index.json) · [`tests/storybook/reference/ha-checkbox/originals.tar`](../tests/storybook/reference/ha-checkbox/originals.tar) · [`tests/storybook/reference/ha-dialog/index.json`](../tests/storybook/reference/ha-dialog/index.json) · [`tests/storybook/reference/ha-dialog/originals.tar`](../tests/storybook/reference/ha-dialog/originals.tar) · [`tests/storybook/reference/ha-field/index.json`](../tests/storybook/reference/ha-field/index.json) · [`tests/storybook/reference/ha-field/originals.tar`](../tests/storybook/reference/ha-field/originals.tar) · [`tests/storybook/reference/ha-navigation-icons/index.json`](../tests/storybook/reference/ha-navigation-icons/index.json) · [`tests/storybook/reference/ha-navigation-icons/originals.tar`](../tests/storybook/reference/ha-navigation-icons/originals.tar) · [`tests/storybook/reference/ha-navigation-overflow/index.json`](../tests/storybook/reference/ha-navigation-overflow/index.json) · [`tests/storybook/reference/ha-navigation-overflow/originals.tar`](../tests/storybook/reference/ha-navigation-overflow/originals.tar) · [`tests/storybook/reference/ha-navigation/index.json`](../tests/storybook/reference/ha-navigation/index.json) · [`tests/storybook/reference/ha-navigation/originals.tar`](../tests/storybook/reference/ha-navigation/originals.tar) · [`tests/storybook/reference/ha-select-list/index.json`](../tests/storybook/reference/ha-select-list/index.json) · [`tests/storybook/reference/ha-select-list/originals.tar`](../tests/storybook/reference/ha-select-list/originals.tar) · [`tests/storybook/reference/ha-select-multiple/index.json`](../tests/storybook/reference/ha-select-multiple/index.json) · [`tests/storybook/reference/ha-select-multiple/originals.tar`](../tests/storybook/reference/ha-select-multiple/originals.tar) · [`tests/storybook/reference/ha-select/index.json`](../tests/storybook/reference/ha-select/index.json) · [`tests/storybook/reference/ha-select/originals.tar`](../tests/storybook/reference/ha-select/originals.tar) · [`tests/contracts/layout.test.ts`](../tests/contracts/layout.test.ts) · [`tests/browser/layout.test.ts`](../tests/browser/layout.test.ts) · [`tests/storybook/layout-visual.ts`](../tests/storybook/layout-visual.ts) · [`tests/consumers/layouts.ts`](../tests/consumers/layouts.ts) · [`tests/storybook/reference/ha-layout/index.json`](../tests/storybook/reference/ha-layout/index.json) · [`tests/storybook/reference/ha-layout/originals.tar`](../tests/storybook/reference/ha-layout/originals.tar) · [`stories/feedback.stories.ts`](../stories/feedback.stories.ts) · [`tests/browser/angular-feedback.test.ts`](../tests/browser/angular-feedback.test.ts) · [`tests/browser/feedback-framework-fixture.ts`](../tests/browser/feedback-framework-fixture.ts) · [`tests/browser/feedback.test.ts`](../tests/browser/feedback.test.ts) · [`tests/browser/fixtures/SvelteFeedback.svelte`](../tests/browser/fixtures/SvelteFeedback.svelte) · [`tests/browser/react-feedback.test.ts`](../tests/browser/react-feedback.test.ts) · [`tests/browser/svelte-feedback.test.mjs`](../tests/browser/svelte-feedback.test.mjs) · [`tests/browser/vue-feedback.test.ts`](../tests/browser/vue-feedback.test.ts) · [`tests/consumers/feedback.ts`](../tests/consumers/feedback.ts) · [`tests/contracts/feedback.test.ts`](../tests/contracts/feedback.test.ts) · [`tests/storybook/feedback-visual.ts`](../tests/storybook/feedback-visual.ts) · [`tests/storybook/reference/ha-feedback/index.json`](../tests/storybook/reference/ha-feedback/index.json) · [`tests/storybook/reference/ha-feedback/originals.tar`](../tests/storybook/reference/ha-feedback/originals.tar) · [`tests/consumers/ssr.test.mjs`](../tests/consumers/ssr.test.mjs) · [`tests/consumers/ssr/fixtures/next/app/boundary.jsx`](../tests/consumers/ssr/fixtures/next/app/boundary.jsx) · [`tests/consumers/ssr/fixtures/next/app/client-tabs.jsx`](../tests/consumers/ssr/fixtures/next/app/client-tabs.jsx) · [`tests/consumers/ssr/fixtures/next/app/layout.jsx`](../tests/consumers/ssr/fixtures/next/app/layout.jsx) · [`tests/consumers/ssr/fixtures/next/app/page.jsx`](../tests/consumers/ssr/fixtures/next/app/page.jsx) · [`tests/consumers/ssr/fixtures/next/package.json`](../tests/consumers/ssr/fixtures/next/package.json) · [`tests/consumers/ssr/fixtures/next/pnpm-lock.yaml`](../tests/consumers/ssr/fixtures/next/pnpm-lock.yaml) · [`tests/consumers/ssr/fixtures/next/pnpm-workspace.yaml`](../tests/consumers/ssr/fixtures/next/pnpm-workspace.yaml) · [`tests/consumers/ssr/fixtures/nuxt/app/app.vue`](../tests/consumers/ssr/fixtures/nuxt/app/app.vue) · [`tests/consumers/ssr/fixtures/nuxt/app/components/ClientTabs.client.vue`](../tests/consumers/ssr/fixtures/nuxt/app/components/ClientTabs.client.vue) · [`tests/consumers/ssr/fixtures/nuxt/nuxt.config.mjs`](../tests/consumers/ssr/fixtures/nuxt/nuxt.config.mjs) · [`tests/consumers/ssr/fixtures/nuxt/package.json`](../tests/consumers/ssr/fixtures/nuxt/package.json) · [`tests/consumers/ssr/fixtures/nuxt/pnpm-lock.yaml`](../tests/consumers/ssr/fixtures/nuxt/pnpm-lock.yaml) · [`tests/consumers/ssr/fixtures/nuxt/pnpm-workspace.yaml`](../tests/consumers/ssr/fixtures/nuxt/pnpm-workspace.yaml) · [`tests/consumers/ssr/fixtures/sveltekit/package.json`](../tests/consumers/ssr/fixtures/sveltekit/package.json) · [`tests/consumers/ssr/fixtures/sveltekit/pnpm-lock.yaml`](../tests/consumers/ssr/fixtures/sveltekit/pnpm-lock.yaml) · [`tests/consumers/ssr/fixtures/sveltekit/pnpm-workspace.yaml`](../tests/consumers/ssr/fixtures/sveltekit/pnpm-workspace.yaml) · [`tests/consumers/ssr/fixtures/sveltekit/src/app.html`](../tests/consumers/ssr/fixtures/sveltekit/src/app.html) · [`tests/consumers/ssr/fixtures/sveltekit/src/routes/+page.svelte`](../tests/consumers/ssr/fixtures/sveltekit/src/routes/%2Bpage.svelte) · [`tests/consumers/ssr/fixtures/sveltekit/src/routes/ClientTabs.svelte`](../tests/consumers/ssr/fixtures/sveltekit/src/routes/ClientTabs.svelte) · [`tests/consumers/ssr/fixtures/sveltekit/fixture.vite.config.js`](../tests/consumers/ssr/fixtures/sveltekit/fixture.vite.config.js) · [`tests/consumers/ssr/journey.mjs`](../tests/consumers/ssr/journey.mjs) · [`tests/delivery.test.mjs`](../tests/delivery.test.mjs) · [`tests/storybook/engines.test.ts`](../tests/storybook/engines.test.ts) - User documentation: [`README.md`](../README.md) · [`docs/component-library.md`](../docs/component-library.md) · [`docs/compatibility.md`](../docs/compatibility.md) · [`docs/provenance.md`](../docs/provenance.md) · [`docs/evidence/shared-ui-foundation.json`](../docs/evidence/shared-ui-foundation.json) · [`docs/framework-adapters.md`](../docs/framework-adapters.md) · [`docs/storybook.md`](../docs/storybook.md) · [`docs/button-contract.md`](../docs/button-contract.md) · [`docs/select-contract.md`](../docs/select-contract.md) · [`docs/checkbox-contract.md`](../docs/checkbox-contract.md) · [`docs/visual-parity.md`](../docs/visual-parity.md) · [`docs/evidence/ha-checkbox-reference.json`](../docs/evidence/ha-checkbox-reference.json) · [`packages/core/NOTICE`](../packages/core/NOTICE) · [`packages/core/LICENSES/Home-Assistant-Apache-2.0.txt`](../packages/core/LICENSES/Home-Assistant-Apache-2.0.txt) · [`packages/core/LICENSES/Font-Awesome-Free-7.0.0.txt`](../packages/core/LICENSES/Font-Awesome-Free-7.0.0.txt) · [`LICENSES/Roboto-Apache-2.0.txt`](../LICENSES/Roboto-Apache-2.0.txt) · [`docs/evidence/ha-navigation-reference.json`](../docs/evidence/ha-navigation-reference.json) · [`docs/evidence/ha-navigation-icons-reference.json`](../docs/evidence/ha-navigation-icons-reference.json) · [`packages/core/LICENSES/Web-Awesome-3.14.0-ha.2-MIT.txt`](../packages/core/LICENSES/Web-Awesome-3.14.0-ha.2-MIT.txt) · [`docs/evidence/ha-navigation-overflow-reference.json`](../docs/evidence/ha-navigation-overflow-reference.json) · [`docs/evidence/ha-button-reference.json`](../docs/evidence/ha-button-reference.json) · [`docs/evidence/ha-field-reference.json`](../docs/evidence/ha-field-reference.json) · [`docs/field-contract.md`](../docs/field-contract.md) · [`docs/dialog-contract.md`](../docs/dialog-contract.md) · [`docs/card-contract.md`](../docs/card-contract.md) · [`docs/evidence/ha-dialog-reference.json`](../docs/evidence/ha-dialog-reference.json) · [`docs/evidence/ha-card-reference.json`](../docs/evidence/ha-card-reference.json) · [`docs/layout-contract.md`](../docs/layout-contract.md) · [`docs/evidence/ha-layout-reference.json`](../docs/evidence/ha-layout-reference.json) · [`LICENSES/Noto-Sans-Arabic-OFL-1.1.txt`](../LICENSES/Noto-Sans-Arabic-OFL-1.1.txt) · [`docs/evidence/ha-feedback-reference.json`](../docs/evidence/ha-feedback-reference.json) · [`docs/feedback-contract.md`](../docs/feedback-contract.md) · [`docs/migration.md`](../docs/migration.md) · [`examples/consumers/README.md`](../examples/consumers/README.md) diff --git a/specs/catalog.json b/specs/catalog.json index 482a2af..90bdef0 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -15,6 +15,7 @@ "workflows": [ ".github/workflows/copilot_issue.yml", ".github/workflows/ci_check.yml", + ".github/workflows/npm_release.yml", ".github/workflows/storybook_pages.yml" ], "entrypoints": [ @@ -125,7 +126,8 @@ "packages/vue/src/feedback.ts", "stories/feedback.example.ts", "tooling/compatibility.mjs", - "tooling/packaging.mjs" + "tooling/packaging.mjs", + "tooling/npm-prerelease.mjs" ], "tests": [ "tests/contracts/core.test.ts", diff --git a/specs/shared-ui-framework-delivery.md b/specs/shared-ui-framework-delivery.md index 1f68cd3..cbd35b8 100644 --- a/specs/shared-ui-framework-delivery.md +++ b/specs/shared-ui-framework-delivery.md @@ -14,10 +14,10 @@ This companion supplements [the retained foundation specification](shared-ui-fou ## 1. Executive summary -Deliver the retained shared component capability through a reproducible, tested package cohort. First establish a pinned toolchain and real compiled consumer fixtures; then accept one immutable packed candidate, exercise it in fixtures and both applications, verify publication authority and artifact integrity, and publish and adopt that same cohort. The implementation stays within the existing owner's contract. The three requirements below refine delivery controls only. +Deliver the retained shared component capability through a reproducible, tested package cohort. First establish a pinned toolchain and real compiled consumer fixtures. Verify publication authority and artifact integrity for the prerelease cohort, then adopt those exact packages in the applications. Stable publication retains both installed application acceptance gates. The implementation stays within the existing owner's contract. The three requirements below refine delivery controls only. ```text -Pinned toolchain -> compiled fixtures and reviewed packed candidate -> Gateway and Domus checks -> verified publication -> installed consumer acceptance +Pinned toolchain -> compiled fixtures and reviewed packed candidate -> verified prerelease publication -> installed consumer acceptance -> stable release review ``` No framework support, publication authority, or completed adoption is inferred from candidate versions or package names. Planned acceptance cases in this draft have not been executed. @@ -88,17 +88,18 @@ Not applicable: this is prospective delivery work and reconstructs no implemente | Toolchain | No implementation evidence | Frozen, compatible lock and support evidence gate artifacts | Reproducible candidate and explicit failure reason | | Candidate | No accepted archive | Build and record source revision, archive digest, dependencies, licenses, and compatibility | Reviewers can identify exact bytes | | Consumer check | No real fixtures or app acceptance | Test packed bytes in framework fixtures and both admitted application workstreams | Adoption refers to one candidate | -| Publication | No publication evidence | Verify rights; publish exact accepted cohort; record per-coordinate result | Partial release is visible and recoverable | +| Publication | No publication evidence | Verify rights, publish the exact reviewed prerelease cohort and record each coordinate | Partial release is visible and recoverable | ```mermaid flowchart LR A[Freeze compatible toolchain] --> B[Build and digest candidate] - B --> C[Fixtures and both app checks] - C --> D[Verify rights and publish exact cohort] + B --> C[Compiled consumer fixtures] + C --> D[Verify rights and publish the prerelease] D --> E[Installed consumer and deployment acceptance] + E --> F[Stable release review] ``` -Text equivalent: freeze toolchain -> build candidate -> check fixtures and applications -> verify rights and publish -> accept installed consumer behavior. +Text equivalent: freeze the toolchain, build and test the candidate, verify rights and publish the prerelease, then complete installed consumer acceptance before stable release review. ## 6. Functional behavior and state model @@ -106,8 +107,8 @@ Text equivalent: freeze toolchain -> build candidate -> check fixtures and appli 1. CI validates exact direct and transitive locks, runtime/compiler compatibility, and the declared supported matrix. 2. The build produces reproducible packed artifacts and records source revision, package versions, integrity digests, dependencies, licenses, and compatibility. -3. Packed artifacts are installed in actual compiled direct-DOM and framework fixtures and exercised in both applications through their admitted work. -4. Review accepts the candidate only after the retained foundation gates and companion refinements pass. Publication rights and artifact digests are verified before publishing those exact bytes. +3. Packed artifacts are installed in actual compiled direct-DOM and framework fixtures. The prerelease publishes those reviewed bytes so owning application work can adopt them. +4. Verify publisher authority and artifact digests before publishing. A production tag identifies the prerelease commit and its successful CI artifact. Stable publication also requires both application receipts. 5. Consumer pins and deployed acceptance refer to the published version/digest cohort. Publication alone does not mark the capability implemented. ### 6.2 Alternative paths @@ -249,7 +250,7 @@ Errors state impact -> cause -> action -> retained state. A successfully publish ## 13. Compatibility, migration, rollout, and rollback -No in-flight runtime migration is introduced by this companion. Keep exact locks and the last reviewed support evidence until a new candidate passes. Establish compatibility from actual frozen builds and browser checks, then publish a reviewed compatible version cohort. Release may be staged; partial publication remains incomplete and is resumed by exact version/digest without overwrite. First test the same candidate bytes locally in fixtures and both consumer workstreams; after rights and integrity verification, publish those bytes, pin delivered versions in the actual repositories, then complete installed/deployment acceptance. Do not make an application depend on a private temporary path. Rollback consumer pins only through each application's owning admitted work and recovery contract; package publication itself is not treated as reversible. +No in-flight runtime migration is introduced by this companion. Keep exact locks and the last reviewed support evidence until a new candidate passes. Establish compatibility from actual frozen builds and browser checks, then publish a reviewed compatible version cohort. Release may be staged; partial publication remains incomplete and is resumed by exact version/digest without overwrite. Test the candidate in compiled fixtures and verify publisher rights and integrity. Publish the prerelease, pin the exact version in the consumer repositories, and complete installed and deployment acceptance before stable promotion. Do not make an application depend on a private temporary path. Rollback consumer pins only through each application's owning admitted work and recovery contract; package publication itself is not treated as reversible. ## 14. Testing strategy and numeric budget @@ -306,6 +307,7 @@ Prospective repository files (current documents are classified against the immut | `tests/coverage.test.mjs` | create | | `LICENSE` | create | | `.github/workflows/ci_check.yml` | create | +| `.github/workflows/npm_release.yml` | create | | `.github/workflows/storybook_pages.yml` | create | | `packages/core/package.json` | create | | `packages/core/src/index.ts` | create | @@ -363,6 +365,7 @@ Prospective repository files (current documents are classified against the immut | `examples/sveltekit/package.json` | create | | `examples/sveltekit/README.md` | create | | `tooling/packaging.mjs` | create | +| `tooling/npm-prerelease.mjs` | create | | `tooling/compatibility.mjs` | create | | `tests/delivery.test.mjs` | create | | `docs/framework-adapters.md` | create | @@ -733,7 +736,7 @@ The retained foundation's requirements and cases remain traced in `specs/shared- 3. Establish package contracts and dependency constraints, then compiled direct-DOM and four framework fixtures using actual packed artifacts. 4. Implement accepted-candidate digest and no-private-path promotion checks; retain the full foundation's tests-first, coverage, component, Storybook, and browser scope. 5. Exercise the immutable candidate in both admitted application workstreams and complete their separate UX, deployment, and Bugbot gates. -6. Verify publication rights and provenance, then publish the exact accepted compatible cohort. Record and recover partial outcomes by exact version/digest. +6. Verify publication rights and provenance, then publish the reviewed prerelease cohort for installed adoption. Stable publication retains both consumer evidence gates. Record and recover partial outcomes by exact version/digest. 7. Pin the delivered cohort in actual consumers and complete installed/deployment acceptance; update catalog evidence only for verified implementation. 8. Complete human UX review, documentation, original SDK catalog validation, and all Definition of Done gates. diff --git a/tests/delivery.test.mjs b/tests/delivery.test.mjs index d9e1537..7f6dc5a 100644 --- a/tests/delivery.test.mjs +++ b/tests/delivery.test.mjs @@ -3,6 +3,7 @@ import {gzipSync,gunzipSync} from 'node:zlib'; import {beforeEach,expect,test,vi} from 'vitest'; import {parse} from 'yaml'; import {inspectToolchain} from '../tooling/compatibility.mjs'; +import {observeNpmVersion,publishNpmPrerelease} from '../tooling/npm-prerelease.mjs'; import {captureCandidate,verifyCandidate,assertShippedManifest,resumePublication,packageCoordinates,sha256,canonicalPackageArchive} from '../tooling/packaging.mjs'; test('the gzip OS marker cannot change a candidate’s bytes across pack hosts',()=>{ @@ -69,7 +70,7 @@ test.each([ expect(()=>inspectToolchain(input)).toThrow(reason); }); -const version='0.1.0-next.1'; +const version='0.1.0'; let archives,record,registry,ports; beforeEach(()=>{ archives=new Map(packageCoordinates.map(name=>[name,Buffer.from(name+' reviewed archive')])); @@ -126,6 +127,18 @@ test('UIFD-C04 ships exact versions, rejecting local candidate paths in any depe expect(()=>assertShippedManifest({dependencies:null},record)).toThrow('unshippable_manifest'); expect(()=>assertShippedManifest(null,record)).toThrow('unshippable_manifest'); }); +test('publishes the reviewed prerelease without requiring installed app acceptance',async()=>{ + const preview=captureCandidate({sourceRevision,lockSha256,artifacts:packageCoordinates.map(coordinate=>({ + coordinate,version:'0.1.0-next.1',bytes:archives.get(coordinate), + }))}); + await expect(resumePublication(preview,review(),ports)).resolves.toMatchObject({state:'blocked',reason:'release_channel_mismatch'}); + await expect(resumePublication(record,{...review(),channel:'next'},ports)).resolves.toMatchObject({state:'blocked',reason:'release_channel_mismatch'}); + expect(ports.authorize).not.toHaveBeenCalled(); + await expect(resumePublication(preview,{...review(),channel:'next',gateway:null,domus:null},ports)).resolves.toMatchObject({ + state:'published',published:preview.artifacts, + }); + expect(ports.publish.mock.calls.map(([artifact])=>artifact.coordinate)).toEqual(packageCoordinates); +}); test('release retains verified archives when either application has not accepted them or rights are absent',async()=>{ await expect(resumePublication(record,{...review(),gateway:null},ports)).resolves.toMatchObject({state:'blocked',reason:'consumer_evidence_missing',published:[]}); await expect(resumePublication(record,{...review(),domus:null},ports)).resolves.toMatchObject({state:'blocked',reason:'consumer_evidence_missing'}); @@ -180,3 +193,62 @@ test('an unavailable read after a publish cannot cause an overwrite on the next await expect(resumePublication(record,review(),ports)).resolves.toMatchObject({state:'published',published:record.artifacts}); expect(ports.publish.mock.calls.slice(published).map(([entry])=>entry.coordinate)).toEqual(packageCoordinates.slice(1)); }); + +const npmName=packageCoordinates[0]; +const npmVersion='0.1.0-next.1'; +const npmTarball='https://registry.npmjs.org/@vypdev/ha-ui/-/ha-ui-0.1.0-next.1.tgz'; +const npmMetadata=()=>Response.json({name:npmName,version:npmVersion,dist:{tarball:npmTarball}}); + +test('npm observation downloads only the exact version from the public registry and hashes the bytes',async()=>{ + const bytes=Buffer.from('immutable package'); + const request=vi.fn().mockResolvedValueOnce(npmMetadata()).mockResolvedValueOnce(new Response(bytes)); + vi.stubGlobal('fetch',request); + try{ + await expect(observeNpmVersion(npmName,npmVersion)).resolves.toEqual({version:npmVersion,sha256:sha256(bytes)}); + expect(request.mock.calls.map(([url])=>String(url))).toEqual([ + 'https://registry.npmjs.org/%40vypdev%2Fha-ui/0.1.0-next.1',npmTarball, + ]); + expect(request.mock.calls.every(([,options])=>options.redirect==='error')).toBe(true); + }finally{vi.unstubAllGlobals();} +}); + +test('only an npm metadata 404 permits an initial publish',async()=>{ + const request=vi.fn().mockResolvedValueOnce(new Response(null,{status:404})) + .mockResolvedValueOnce(new Response(null,{status:503})); + await expect(observeNpmVersion(npmName,npmVersion,request)).resolves.toBeNull(); + await expect(observeNpmVersion(npmName,npmVersion,request)).rejects.toThrow('npm_metadata_unavailable'); +}); + +test.each([ + ['different package','npm_version_identity_mismatch',metadata=>{metadata.name='@other/ha-ui';}], + ['different version','npm_version_identity_mismatch',metadata=>{metadata.version='0.1.0-next.2';}], + ['foreign host','npm_tarball_origin_mismatch',metadata=>{metadata.dist.tarball='https://example.com/package.tgz';}], + ['username','npm_tarball_origin_mismatch',metadata=>{metadata.dist.tarball=npmTarball.replace('https://','https://name@');}], + ['password','npm_tarball_origin_mismatch',metadata=>{metadata.dist.tarball=npmTarball.replace('https://','https://:secret@');}], +])('refuses to follow metadata with a %s',async(_scenario,message,change)=>{ + const metadata=await npmMetadata().json(); + change(metadata); + const request=vi.fn().mockResolvedValue(Response.json(metadata)); + await expect(observeNpmVersion(npmName,npmVersion,request)).rejects.toThrow(message); + expect(request).toHaveBeenCalledTimes(1); +}); + +test('a missing tarball for a published version is a registry error',async()=>{ + const request=vi.fn().mockResolvedValueOnce(npmMetadata()).mockResolvedValueOnce(new Response(null,{status:404})); + await expect(observeNpmVersion(npmName,npmVersion,request)).rejects.toThrow('npm_tarball_unavailable'); +}); + +test('npm prerelease rejects stale tags and an absent workflow identity before writing',async()=>{ + const preview=captureCandidate({sourceRevision,lockSha256,artifacts:packageCoordinates.map(coordinate=>({ + coordinate,version:npmVersion,bytes:archives.get(coordinate), + }))}); + const input={record:preview,sourceRevision,lockSha256,tag:'v'+npmVersion,oidc:true,archives,...ports}; + await expect(publishNpmPrerelease({...input,tag:'v0.1.0'})).rejects.toThrow('npm_release_tag_mismatch'); + await expect(publishNpmPrerelease({...input,tag:'v0.1.0-next.2'})).rejects.toThrow('npm_release_tag_mismatch'); + await expect(publishNpmPrerelease({...input,record:{...preview,artifacts:[]}})).rejects.toThrow('npm_release_tag_mismatch'); + await expect(publishNpmPrerelease({...input,oidc:false})).resolves.toMatchObject({ + state:'blocked',reason:'publisher_rights_unverified', + }); + expect(ports.publish).not.toHaveBeenCalled(); + await expect(publishNpmPrerelease(input)).resolves.toMatchObject({state:'published',published:preview.artifacts}); +}); diff --git a/tooling/npm-prerelease.mjs b/tooling/npm-prerelease.mjs new file mode 100644 index 0000000..ea2e978 --- /dev/null +++ b/tooling/npm-prerelease.mjs @@ -0,0 +1,44 @@ +import {sha256,resumePublication} from './packaging.mjs'; + +const registry='https://registry.npmjs.org'; + +/** @typedef {import('./packaging.mjs').CandidateRecord} CandidateRecord */ + +/** + * Read the immutable version and hash its downloaded tarball. A registry outage or + * invalid response cannot be mistaken for an unpublished version. + * @param {string} coordinate + * @param {string} version + * @param {typeof fetch} request + */ +export async function observeNpmVersion(coordinate,version,request=fetch){ + /** @type {RequestInit} */ + const options={redirect:'error',signal:AbortSignal.timeout(30_000)}; + const metadata=await request(`${registry}/${encodeURIComponent(coordinate)}/${version}`,options); + if(metadata.status===404)return null; + if(!metadata.ok)throw new Error('npm_metadata_unavailable'); + const published=await metadata.json(); + if(published.name!==coordinate||published.version!==version)throw new Error('npm_version_identity_mismatch'); + const tarball=new URL(published.dist.tarball); + if(tarball.origin!==registry||tarball.username||tarball.password)throw new Error('npm_tarball_origin_mismatch'); + const response=await request(tarball,options); + if(!response.ok)throw new Error('npm_tarball_unavailable'); + return {version,sha256:sha256(new Uint8Array(await response.arrayBuffer()))}; +} + +/** + * The caller supplies the approved GitHub Actions context and authenticated npm command. + * npm itself authorizes each write through the configured trusted publisher. + * @param {{record:CandidateRecord,sourceRevision:string,lockSha256:string,tag:string,oidc:boolean, + * archives:Map,observe:import('./packaging.mjs').ReleasePorts['observe'], + * publish:import('./packaging.mjs').ReleasePorts['publish'],append:import('./packaging.mjs').ReleasePorts['append']}} input + */ +export async function publishNpmPrerelease({record,sourceRevision,lockSha256,tag,oidc,archives,observe,publish,append}){ + if(!/^v\d+\.\d+\.\d+-next\.\d+$/u.test(tag)||record.artifacts[0]?.version!==tag.slice(1)) + throw new Error('npm_release_tag_mismatch'); + return resumePublication(record,{sourceRevision,lockSha256,channel:'next',gateway:null,domus:null},{ + readArchive:coordinate=>archives.get(coordinate), + authorize:async()=>oidc, + observe,publish,append, + }); +} diff --git a/tooling/packaging.mjs b/tooling/packaging.mjs index c4b5db0..820f148 100644 --- a/tooling/packaging.mjs +++ b/tooling/packaging.mjs @@ -5,7 +5,7 @@ import {createHash} from 'node:crypto'; /** @typedef {{sourceRevision:string,lockSha256:string}} SourceIdentity */ /** @typedef {SourceIdentity & {artifacts:readonly CandidateArtifact[]}} CandidateData */ /** @typedef {CandidateData & {id:string}} CandidateRecord */ -/** @typedef {SourceIdentity & {gateway:string|null,domus:string|null}} ReleaseReview */ +/** @typedef {SourceIdentity & {gateway:string|null,domus:string|null,channel?:'next'}} ReleaseReview */ /** @typedef {(coordinate:string)=>Uint8Array|undefined} ReadArchive */ /** @typedef {{version:string,sha256:string}} RegistryArtifact */ /** @typedef {{readArchive:ReadArchive,authorize:(id:string)=>Promise,observe:(coordinate:string,version:string)=>Promise,publish:(entry:CandidateArtifact,bytes:Uint8Array)=>Promise,append:(entry:CandidateArtifact,id:string)=>Promise}} ReleasePorts */ @@ -123,7 +123,10 @@ export async function resumePublication(record,review,ports){ const bytes=verifyCandidate(record,review,ports.readArchive); /** @type {CandidateArtifact[]} */ const published=[]; - if(review.gateway!==record.id || review.domus!==record.id)return outcome('blocked','consumer_evidence_missing',published); + const preview=review.channel==='next'; + if(preview!==record.artifacts[0].version.includes('-'))return outcome('blocked','release_channel_mismatch',published); + if(!preview&&(review.gateway!==record.id || review.domus!==record.id)) + return outcome('blocked','consumer_evidence_missing',published); let authorized; try{authorized=await ports.authorize(record.id);} catch{return outcome('blocked','publisher_rights_unverified',published);} From 56656b594cb09851f1fc730a557ea246b352e9f8 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 5 Nov 2026 12:28:40 +0100 Subject: [PATCH 2/2] fix: reject invalid numeric npm release tags --- .github/workflows/npm_release.yml | 2 +- tests/delivery.test.mjs | 6 ++++++ tooling/npm-prerelease.mjs | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index 521eeff..f864a9f 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -26,7 +26,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-next\.[0-9]+$ ]] + [[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-next\.(0|[1-9][0-9]*)$ ]] run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci_check.yml/runs?event=push&branch=master&head_sha=$GITHUB_SHA&status=success&per_page=10" \ --jq '.workflow_runs[0].id // empty') [[ "$run_id" =~ ^[1-9][0-9]*$ ]] diff --git a/tests/delivery.test.mjs b/tests/delivery.test.mjs index 7f6dc5a..1bb8818 100644 --- a/tests/delivery.test.mjs +++ b/tests/delivery.test.mjs @@ -246,6 +246,12 @@ test('npm prerelease rejects stale tags and an absent workflow identity before w await expect(publishNpmPrerelease({...input,tag:'v0.1.0'})).rejects.toThrow('npm_release_tag_mismatch'); await expect(publishNpmPrerelease({...input,tag:'v0.1.0-next.2'})).rejects.toThrow('npm_release_tag_mismatch'); await expect(publishNpmPrerelease({...input,record:{...preview,artifacts:[]}})).rejects.toThrow('npm_release_tag_mismatch'); + for(const invalid of ['00.1.0-next.1','0.01.0-next.1','0.1.00-next.1','0.1.0-next.01']){ + const invalidPreview=captureCandidate({sourceRevision,lockSha256,artifacts:packageCoordinates.map(coordinate=>({ + coordinate,version:invalid,bytes:archives.get(coordinate), + }))}); + await expect(publishNpmPrerelease({...input,record:invalidPreview,tag:'v'+invalid})).rejects.toThrow('npm_release_tag_mismatch'); + } await expect(publishNpmPrerelease({...input,oidc:false})).resolves.toMatchObject({ state:'blocked',reason:'publisher_rights_unverified', }); diff --git a/tooling/npm-prerelease.mjs b/tooling/npm-prerelease.mjs index ea2e978..2d53956 100644 --- a/tooling/npm-prerelease.mjs +++ b/tooling/npm-prerelease.mjs @@ -34,7 +34,7 @@ export async function observeNpmVersion(coordinate,version,request=fetch){ * publish:import('./packaging.mjs').ReleasePorts['publish'],append:import('./packaging.mjs').ReleasePorts['append']}} input */ export async function publishNpmPrerelease({record,sourceRevision,lockSha256,tag,oidc,archives,observe,publish,append}){ - if(!/^v\d+\.\d+\.\d+-next\.\d+$/u.test(tag)||record.artifacts[0]?.version!==tag.slice(1)) + if(!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-next\.(?:0|[1-9]\d*)$/u.test(tag)||record.artifacts[0]?.version!==tag.slice(1)) throw new Error('npm_release_tag_mismatch'); return resumePublication(record,{sourceRevision,lockSha256,channel:'next',gateway:null,domus:null},{ readArchive:coordinate=>archives.get(coordinate),