From 9e79b6f4529d58b306c0867c7960ed27a696383b Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Tue, 7 Jul 2026 22:54:38 +0000 Subject: [PATCH 1/5] fix(CatalogDetail): sanitize snapshot URLs and guard non-array technologies - Add `sanitizeCatalogImageUrl` and apply it in `CatalogCardDesignLogo`: legacy catalog records stored the design snapshot URL wrapped in stray '%' delimiters, which rendered as a path relative to the app origin and 400'd. Strip the stray delimiters (preserving genuine %XX encoding), require an absolute http(s) URL, and otherwise fall back to the placeholder icon so no broken request is issued. - Guard `TechnologySection` against a non-array `compatibility` so a legacy scalar can never reach `.map` and crash the design detail page. Exports `sanitizeCatalogImageUrl` from the custom barrel and adds a unit test. Signed-off-by: Lee Calcote --- .../sanitizeCatalogImageUrl.test.ts | 34 ++++++++ .../CatalogDetail/TechnologySection.tsx | 6 +- .../CustomCatalog/CatalogCardDesignLogo.tsx | 82 +++++++++---------- src/custom/CustomCatalog/Helper.ts | 22 +++++ src/custom/CustomCatalog/index.tsx | 3 +- src/custom/index.tsx | 8 +- 6 files changed, 111 insertions(+), 44 deletions(-) create mode 100644 src/__testing__/sanitizeCatalogImageUrl.test.ts diff --git a/src/__testing__/sanitizeCatalogImageUrl.test.ts b/src/__testing__/sanitizeCatalogImageUrl.test.ts new file mode 100644 index 000000000..376e9f808 --- /dev/null +++ b/src/__testing__/sanitizeCatalogImageUrl.test.ts @@ -0,0 +1,34 @@ +import { sanitizeCatalogImageUrl } from '../custom/CustomCatalog/Helper'; + +describe('sanitizeCatalogImageUrl', () => { + it('passes through a well-formed absolute https URL', () => { + const url = + 'https://raw.githubusercontent.com/layer5labs/meshery-extensions-packages/master/action-assets/design-assets/34fe3846-4b90-4558-914c-8e57caefd52f-light.png'; + expect(sanitizeCatalogImageUrl(url)).toBe(url); + }); + + it("strips stray '%' delimiters from a legacy snapshot URL", () => { + // Legacy catalog records stored the snapshot URL wrapped in '%' delimiters. + // Rendered verbatim as an the leading '%' makes the browser + // request it relative to the app origin, producing HTTP 400. + const malformed = + '%https://raw.githubusercontent.com/meshery-extensions/meshery-extensions-packages%/master/action-assets/design-assets/34fe3846-4b90-4558-914c-8e57caefd52f-light.png'; + expect(sanitizeCatalogImageUrl(malformed)).toBe( + 'https://raw.githubusercontent.com/meshery-extensions/meshery-extensions-packages/master/action-assets/design-assets/34fe3846-4b90-4558-914c-8e57caefd52f-light.png' + ); + }); + + it('preserves genuine %XX percent-encoding', () => { + expect(sanitizeCatalogImageUrl('https://example.com/a%20b.png')).toBe( + 'https://example.com/a%20b.png' + ); + }); + + it('returns undefined for non-absolute or non-string values', () => { + expect(sanitizeCatalogImageUrl('/relative/path.png')).toBeUndefined(); + expect(sanitizeCatalogImageUrl('%relative%/thing.png')).toBeUndefined(); + expect(sanitizeCatalogImageUrl('')).toBeUndefined(); + expect(sanitizeCatalogImageUrl(undefined)).toBeUndefined(); + expect(sanitizeCatalogImageUrl(null as unknown as string)).toBeUndefined(); + }); +}); diff --git a/src/custom/CatalogDetail/TechnologySection.tsx b/src/custom/CatalogDetail/TechnologySection.tsx index 4018c6255..772e16ccf 100644 --- a/src/custom/CatalogDetail/TechnologySection.tsx +++ b/src/custom/CatalogDetail/TechnologySection.tsx @@ -20,10 +20,14 @@ const TechnologySection: React.FC = ({ const theme = useTheme(); useEffect(() => { + // `compatibility` is an array per the @meshery/schemas contract, but legacy + // records can surface a scalar; guard so a non-array never reaches `.map` + // (which would crash the design detail page). + const technologyList = Array.isArray(technologies) ? technologies : []; // Function to check if SVG exists const validateTechnologies = async () => { const validTechs = await Promise.all( - technologies.map(async (tech) => { + technologyList.map(async (tech) => { const svg_path = `/${technologySVGPath}/${tech.toLowerCase()}/${technologySVGSubpath}/${tech.toLowerCase()}-color.svg`; try { const response = await fetch(svg_path); diff --git a/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx b/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx index 185dcd9bb..380abd703 100644 --- a/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx +++ b/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Dialog } from '../../base'; import { DesignIcon, MesheryFilterIcon } from '../../icons'; +import { sanitizeCatalogImageUrl } from './Helper'; interface CatalogCardDesignLogoProps { zoomEffect?: boolean; @@ -34,6 +35,11 @@ const CatalogCardDesignLogo: React.FC = ({ const [imgError, setImgError] = useState(false); const [isZoomed, setIsZoomed] = useState(false); + // Guard against legacy/malformed snapshot URLs (e.g. stray '%' delimiters or + // non-absolute values) so we never issue a broken request against the app + // origin — fall back to the placeholder icon instead. + const resolvedSrc = sanitizeCatalogImageUrl(imgURL?.[0]); + const handleZoomClick = () => { if (zoomEffect) { setIsZoomed(true); @@ -46,48 +52,42 @@ const CatalogCardDesignLogo: React.FC = ({ return ( <> - {imgURL && imgURL.length > 0 ? ( + {resolvedSrc && !imgError ? (
- {!imgError ? ( - <> - Design SnapShot setImgError(true)} - style={{ - cursor: 'pointer', - width: '100%', - height: '100%', - objectFit: 'cover' - }} - /> - - Zoomed Design SnapShot - - - ) : ( - - )} + Design SnapShot setImgError(true)} + style={{ + cursor: 'pointer', + width: '100%', + height: '100%', + objectFit: 'cover' + }} + /> + + Zoomed Design SnapShot +
) : ( diff --git a/src/custom/CustomCatalog/Helper.ts b/src/custom/CustomCatalog/Helper.ts index acc73feb3..76ba4cb5b 100644 --- a/src/custom/CustomCatalog/Helper.ts +++ b/src/custom/CustomCatalog/Helper.ts @@ -70,6 +70,28 @@ export const handleImage = async ({ const validSvgPaths = await getValidSvgPaths(technologies, basePath, subBasePath); setAvailableTechnologies(validSvgPaths); }; + +/** + * Normalizes a catalog snapshot/image URL for safe rendering. + * + * Legacy catalog records stored the design snapshot URL wrapped in stray '%' + * delimiters, e.g. "%https://raw.githubusercontent.com/org/repo%/master/x.png". + * Rendered verbatim in an ``, the leading '%' makes the browser treat + * the value as a path relative to the current page and issue a broken request + * against the app origin (HTTP 400). This strips the stray delimiters (while + * preserving genuine `%XX` percent-encoding) and returns the result only when + * it is an absolute http(s) URL; otherwise it returns `undefined` so callers + * can fall back to a placeholder instead of requesting a malformed URL. + */ +export const sanitizeCatalogImageUrl = (rawUrl?: string): string | undefined => { + if (typeof rawUrl !== 'string') { + return undefined; + } + // Remove only stray '%' that are not part of a valid %XX escape sequence. + const cleaned = rawUrl.trim().replace(/%(?![0-9a-fA-F]{2})/g, ''); + return /^https?:\/\//i.test(cleaned) ? cleaned : undefined; +}; + export const DEFAULT_DESIGN_VERSION = '0.0.0'; // Returns the version of a design based on its visibility. diff --git a/src/custom/CustomCatalog/index.tsx b/src/custom/CustomCatalog/index.tsx index be7c47128..c23c7608a 100644 --- a/src/custom/CustomCatalog/index.tsx +++ b/src/custom/CustomCatalog/index.tsx @@ -1,5 +1,6 @@ import CatalogCardDesignLogo from './CatalogCardDesignLogo'; import CustomCatalogCard from './CustomCard'; import EmptyStateCard from './EmptyStateCard'; +import { sanitizeCatalogImageUrl } from './Helper'; -export { CatalogCardDesignLogo, CustomCatalogCard, EmptyStateCard }; +export { CatalogCardDesignLogo, CustomCatalogCard, EmptyStateCard, sanitizeCatalogImageUrl }; diff --git a/src/custom/index.tsx b/src/custom/index.tsx index ba6916b0b..608c8f233 100644 --- a/src/custom/index.tsx +++ b/src/custom/index.tsx @@ -6,7 +6,12 @@ import CatalogFilter, { CatalogFilterProps } from './CatalogFilter/CatalogFilter import { ChapterCard } from './ChapterCard'; import { CollaboratorAvatarGroup } from './CollaboratorAvatarGroup'; import { ConnectionChip } from './ConnectionChip'; -import { CatalogCardDesignLogo, CustomCatalogCard, EmptyStateCard } from './CustomCatalog'; +import { + CatalogCardDesignLogo, + CustomCatalogCard, + EmptyStateCard, + sanitizeCatalogImageUrl +} from './CustomCatalog'; import { CustomColumn, CustomColumnVisibilityControl, @@ -112,6 +117,7 @@ export { ModalCard, PopperListener, ResponsiveDataTable, + sanitizeCatalogImageUrl, SearchBar, StyledDialogActions, StyledDialogContent, From 68841e8168ecb14054f6d2f5a3161e58eb530862 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Tue, 7 Jul 2026 23:04:59 +0000 Subject: [PATCH 2/5] fix(CatalogDetail): preserve legacy scalar technology and reset image error state Addresses review feedback: - TechnologySection: wrap a legacy scalar `technologies` value in an array (rather than discarding it) and keep only non-empty strings, so a scalar, null, or non-string value can never reach `.map`/`tech.toLowerCase()`. Also corrects the inline comment to reference the `technologies` prop. - CatalogCardDesignLogo: reset `imgError` when the resolved snapshot src changes, so a new valid image is not hidden by a prior load failure. Signed-off-by: Lee Calcote --- src/custom/CatalogDetail/TechnologySection.tsx | 12 ++++++++---- src/custom/CustomCatalog/CatalogCardDesignLogo.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/custom/CatalogDetail/TechnologySection.tsx b/src/custom/CatalogDetail/TechnologySection.tsx index 772e16ccf..db993abd7 100644 --- a/src/custom/CatalogDetail/TechnologySection.tsx +++ b/src/custom/CatalogDetail/TechnologySection.tsx @@ -20,10 +20,14 @@ const TechnologySection: React.FC = ({ const theme = useTheme(); useEffect(() => { - // `compatibility` is an array per the @meshery/schemas contract, but legacy - // records can surface a scalar; guard so a non-array never reaches `.map` - // (which would crash the design detail page). - const technologyList = Array.isArray(technologies) ? technologies : []; + // `technologies` (the design's `compatibility` upstream) is an array per the + // @meshery/schemas contract, but legacy records can surface a scalar. Wrap a + // scalar so it is preserved rather than discarded, then keep only non-empty + // strings so a non-array/`null`/non-string value can never reach `.map` or + // `tech.toLowerCase()` and crash the design detail page. + const technologyList = (Array.isArray(technologies) ? technologies : [technologies]).filter( + (tech): tech is string => typeof tech === 'string' && tech.trim() !== '' + ); // Function to check if SVG exists const validateTechnologies = async () => { const validTechs = await Promise.all( diff --git a/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx b/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx index 380abd703..9a9038e44 100644 --- a/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx +++ b/src/custom/CustomCatalog/CatalogCardDesignLogo.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Dialog } from '../../base'; import { DesignIcon, MesheryFilterIcon } from '../../icons'; import { sanitizeCatalogImageUrl } from './Helper'; @@ -40,6 +40,12 @@ const CatalogCardDesignLogo: React.FC = ({ // origin — fall back to the placeholder icon instead. const resolvedSrc = sanitizeCatalogImageUrl(imgURL?.[0]); + // Reset the error state when the resolved source changes so a new, valid + // snapshot isn't hidden by a previous image's load failure. + useEffect(() => { + setImgError(false); + }, [resolvedSrc]); + const handleZoomClick = () => { if (zoomEffect) { setIsZoomed(true); From 09d79762cd3e7682301964a6440f35d996826845 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Wed, 8 Jul 2026 22:44:56 +0000 Subject: [PATCH 3/5] docs(skill): add cut-release runbook for @sistent/sistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the release-drafter → publish-GitHub-release → npm-publish (OIDC provenance) → version-bump-back flow as a reusable skill, plus the workflow_dispatch fallback, preconditions, verification, and the protected-branch/permission caveats. Signed-off-by: Lee Calcote --- .claude/skills/cut-release/SKILL.md | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .claude/skills/cut-release/SKILL.md diff --git a/.claude/skills/cut-release/SKILL.md b/.claude/skills/cut-release/SKILL.md new file mode 100644 index 000000000..0be2a472c --- /dev/null +++ b/.claude/skills/cut-release/SKILL.md @@ -0,0 +1,75 @@ +--- +name: cut-release +description: Runbook for cutting a release of @sistent/sistent to npm. Use when asked to "cut a release", "release Sistent", "publish a new version", or "ship the next @sistent/sistent". Covers the release-drafter → publish-GitHub-release → npm publish (OIDC provenance) → version-bump-back flow, the workflow_dispatch fallback, preconditions, and verification. +user-invocable: true +--- + +# Cut a Sistent Release + +Publishes a new version of the `@sistent/sistent` npm package. Releasing is **automation-driven**: publishing a GitHub Release (or dispatching the publish workflow) is the trigger — you do **not** run `npm publish` by hand, hand-edit `package.json`'s version, or create a git tag manually. This skill's job is to drive that automation correctly and verify the result. + +- **Package:** `@sistent/sistent` (public, scope `@sistent`), published with npm **provenance** via OIDC trusted publishing. +- **Release branch:** `master` (protected — requires PR approval; `github-actions[bot]` is **not** in the bypass list). +- **Workflows:** [`.github/workflows/release-drafter.yml`](../../workflows/release-drafter.yml) drafts notes as PRs merge; [`.github/workflows/release.yml`](../../workflows/release.yml) ("Publish Node.js Package") does the actual `npm publish`; `notify-dependents.yml` updates downstream consumers after a successful publish. + +## The release chain (what actually happens) + +1. **PRs merge to `master`** → `release-drafter.yml` runs and keeps a **draft GitHub Release** up to date, computing the next version from PR labels (major / minor / patch) and accumulating the changelog. +2. **A maintainer publishes that draft Release** (sets it non-draft). The tag (e.g. `v0.21.31`) is the version source of truth. +3. Publishing emits the `release: published` event → **`release.yml`** runs and, on `master`: + - Sets `package.json#version` from the tag (`v0.21.31` → `0.21.31`, `npm version --no-git-tag-version --allow-same-version`). + - `npm ci --legacy-peer-deps` → `npm run build` → `npm publish --provenance --access public`. + - Opens a **version-bump-back PR** (`release/version-bump/vX.Y.Z`) via `peter-evans/create-pull-request` so `master`'s `package.json` / `package-lock.json` track what was published (a direct push is impossible — `master` is protected and the bot isn't a bypass actor). +4. `notify-dependents.yml` fires on the publish workflow's success and bumps downstream consumers (e.g. meshery, meshery-cloud) to the new version. + +## Preconditions (verify before publishing) + +- **Everything you want in the release is already merged to `master`.** The release ships `master`'s current state, not any open PR. An unmerged PR (e.g. a fix under review) will **not** be in the release — merge it first. +- **`master` CI is green** on the commit you're releasing. +- The **draft Release** reflects the intended version and changelog. If the version bump is wrong, it's driven by PR labels — fix labels and let release-drafter re-draft, rather than hand-editing the tag. + +## Procedure + +### Option A — publish the drafted release (normal path) + +1. Confirm the draft and its target commit: + ```bash + gh release list --repo layer5io/sistent + gh release view --repo layer5io/sistent + ``` +2. Publish it (this is the trigger — it starts `npm publish`): + ```bash + gh release edit --repo layer5io/sistent --draft=false --latest + ``` + +### Option B — manual dispatch (no draft, or re-run a publish) + +`release.yml` also accepts `workflow_dispatch` with a `tag_name` input: +```bash +gh workflow run release.yml --repo layer5io/sistent -f tag_name= +``` +Use this only when you deliberately want to (re)publish a specific version without going through a drafted GitHub Release. + +### Verify + +1. Watch the publish workflow to success: + ```bash + gh run watch --repo layer5io/sistent $(gh run list --repo layer5io/sistent --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId') + ``` +2. Confirm the version is live on npm: + ```bash + npm view @sistent/sistent version + ``` +3. **Merge the auto-opened `release/version-bump/vX.Y.Z` PR** so `master` stops drifting behind npm. It needs an approving review like any PR (branch protection). If the automated step was skipped, open the bump manually. +4. Confirm `notify-dependents` opened/updated the downstream consumer bump PRs. + +## What NOT to do + +- ❌ Don't `npm publish` locally or `npm version`-tag by hand — the workflow owns publishing (with provenance) and the version is derived from the release tag. +- ❌ Don't push a `package.json` version bump directly to `master` — it's protected; use the auto-generated bump-back PR. +- ❌ Don't publish a release expecting it to contain an unmerged PR — merge to `master` first. +- ❌ Don't delete/republish an already-published npm version — npm publishes are effectively permanent; cut a new patch instead. + +## Permissions note + +Cutting a release requires publishing a GitHub Release (or dispatching a workflow) with write access, and it deploys to the public npm registry — an irreversible, outward-facing action. Automated/CI or restricted agent sessions may be blocked from merging protected branches and publishing releases (e.g. `403 "not permitted for this session type"`); in that case, hand these steps to a maintainer with release rights rather than attempting to force them. From 20a0655fc4b39408d4b22ff79d57e7c2ed2eb989 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Wed, 8 Jul 2026 23:16:52 +0000 Subject: [PATCH 4/5] ci(release): auto-merge the version-bump-back PR The post-publish PR that syncs package.json/package-lock.json to the just-published npm version only carries [skip ci] boilerplate and needs no review, yet it sat idle awaiting maintainer approval. Add a step that admin-merges it right after creation (guarded to created/updated operations, continue-on-error so npm-publish success stays the source of truth). Requires GH_ACCESS_TOKEN to carry admin/bypass rights, since github-actions[bot] is not a branch-protection bypass actor. Signed-off-by: Lee Calcote --- .github/workflows/release.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88092403e..fa5d7debd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,7 @@ jobs: # downstream consumers. A maintainer can always open the bump-back PR # manually if the automated step is skipped. - name: Open PR with package.json version bump + id: bump_pr if: ${{ success() }} continue-on-error: true uses: peter-evans/create-pull-request@v8 @@ -155,6 +156,29 @@ jobs: release draft: false + # Auto-merge the version-bump-back PR so it lands immediately instead of + # sitting idle awaiting maintainer review. The bump only rewrites + # package.json / package-lock.json to the version just published to npm and + # carries [skip ci], so there is nothing to review or re-test — the content + # was already CI-gated by the PR that merged into the release tag. + # + # `--admin` bypasses the branch-protection "require 1 approval" rule + # (github-actions[bot] is not a bypass actor and cannot approve its own PR), + # so this needs GH_ACCESS_TOKEN to carry admin/bypass rights on the repo; + # the default GITHUB_TOKEN cannot. As with the PR-creation step, + # continue-on-error keeps npm-publish success the source of truth: if the + # token lacks rights or the merge races, the PR is simply left for a + # maintainer to merge — no worse than before this step existed. Only run + # when a PR was actually created or updated (not a no-op). + - name: Auto-merge the version-bump PR + if: ${{ steps.bump_pr.outputs.pull-request-number != '' && (steps.bump_pr.outputs.pull-request-operation == 'created' || steps.bump_pr.outputs.pull-request-operation == 'updated') }} + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.bump_pr.outputs.pull-request-number }} + run: | + gh pr merge "$PR_NUMBER" --repo "${{ github.repository }}" --merge --admin + notify-dependents: needs: publish runs-on: ubuntu-24.04 From 330f487c2320679f3450c3657dc8cbd695bfe905 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Wed, 8 Jul 2026 23:17:26 +0000 Subject: [PATCH 5/5] docs(skill): consolidate cut-release to a single publish-the-draft flow Drop the workflow_dispatch alternative and preconditions ceremony: the only human step is publishing the Release Drafter draft (which already owns the version and notes). Also reflect that release.yml now auto-merges the version-bump-back PR. Signed-off-by: Lee Calcote --- .claude/skills/cut-release/SKILL.md | 62 +++++++++++------------------ 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/.claude/skills/cut-release/SKILL.md b/.claude/skills/cut-release/SKILL.md index 0be2a472c..49ef2b0c3 100644 --- a/.claude/skills/cut-release/SKILL.md +++ b/.claude/skills/cut-release/SKILL.md @@ -1,75 +1,61 @@ --- name: cut-release -description: Runbook for cutting a release of @sistent/sistent to npm. Use when asked to "cut a release", "release Sistent", "publish a new version", or "ship the next @sistent/sistent". Covers the release-drafter → publish-GitHub-release → npm publish (OIDC provenance) → version-bump-back flow, the workflow_dispatch fallback, preconditions, and verification. +description: Runbook for cutting a release of @sistent/sistent to npm. Use when asked to "cut a release", "release Sistent", "publish a new version", or "ship the next @sistent/sistent". The flow is: wait for Release Drafter to fold the merged PR into the draft release, then publish that draft — Release Drafter has already set the version and notes, and release.yml handles the npm publish (OIDC provenance) and the auto-merged version-bump-back. user-invocable: true --- # Cut a Sistent Release -Publishes a new version of the `@sistent/sistent` npm package. Releasing is **automation-driven**: publishing a GitHub Release (or dispatching the publish workflow) is the trigger — you do **not** run `npm publish` by hand, hand-edit `package.json`'s version, or create a git tag manually. This skill's job is to drive that automation correctly and verify the result. +Publishes a new version of the `@sistent/sistent` npm package. Releasing is **automation-driven** and requires **no manual preparation**: Release Drafter already computes the next version and writes the release notes, and the publish workflow does the rest. You do **not** run `npm publish`, bump `package.json`, write release notes, or create a git tag by hand. - **Package:** `@sistent/sistent` (public, scope `@sistent`), published with npm **provenance** via OIDC trusted publishing. -- **Release branch:** `master` (protected — requires PR approval; `github-actions[bot]` is **not** in the bypass list). -- **Workflows:** [`.github/workflows/release-drafter.yml`](../../workflows/release-drafter.yml) drafts notes as PRs merge; [`.github/workflows/release.yml`](../../workflows/release.yml) ("Publish Node.js Package") does the actual `npm publish`; `notify-dependents.yml` updates downstream consumers after a successful publish. +- **Release branch:** `master` (protected — requires PR approval). +- **Workflows:** [`.github/workflows/release-drafter.yml`](../../workflows/release-drafter.yml) maintains the draft Release; [`.github/workflows/release.yml`](../../workflows/release.yml) ("Publish Node.js Package") does the `npm publish`, then opens **and auto-merges** the version-bump-back PR; `notify-dependents.yml` updates downstream consumers after a successful publish. ## The release chain (what actually happens) -1. **PRs merge to `master`** → `release-drafter.yml` runs and keeps a **draft GitHub Release** up to date, computing the next version from PR labels (major / minor / patch) and accumulating the changelog. -2. **A maintainer publishes that draft Release** (sets it non-draft). The tag (e.g. `v0.21.31`) is the version source of truth. -3. Publishing emits the `release: published` event → **`release.yml`** runs and, on `master`: - - Sets `package.json#version` from the tag (`v0.21.31` → `0.21.31`, `npm version --no-git-tag-version --allow-same-version`). - - `npm ci --legacy-peer-deps` → `npm run build` → `npm publish --provenance --access public`. - - Opens a **version-bump-back PR** (`release/version-bump/vX.Y.Z`) via `peter-evans/create-pull-request` so `master`'s `package.json` / `package-lock.json` track what was published (a direct push is impossible — `master` is protected and the bot isn't a bypass actor). +1. **A PR merges to `master`** → `release-drafter.yml` runs and updates the **draft GitHub Release**: it folds that PR into the changelog and computes the next version from the PR's labels (major / minor / patch). +2. **You publish the draft Release.** The tag (e.g. `v0.21.31`) becomes the version source of truth. +3. Publishing emits `release: published` → **`release.yml`**: sets `package.json#version` from the tag, `npm ci` → `npm run build` → `npm publish --provenance --access public`, then opens the `release/version-bump/vX.Y.Z` PR and **auto-merges it** so `master`'s `package.json`/`package-lock.json` track the published version without sitting idle. 4. `notify-dependents.yml` fires on the publish workflow's success and bumps downstream consumers (e.g. meshery, meshery-cloud) to the new version. -## Preconditions (verify before publishing) - -- **Everything you want in the release is already merged to `master`.** The release ships `master`'s current state, not any open PR. An unmerged PR (e.g. a fix under review) will **not** be in the release — merge it first. -- **`master` CI is green** on the commit you're releasing. -- The **draft Release** reflects the intended version and changelog. If the version bump is wrong, it's driven by PR labels — fix labels and let release-drafter re-draft, rather than hand-editing the tag. - ## Procedure -### Option A — publish the drafted release (normal path) +The only human step is publishing the drafted release — Release Drafter has already done the versioning and notes. -1. Confirm the draft and its target commit: +1. **Wait for Release Drafter to fold your merged PR into the draft.** After the PR merges to `master`, `release-drafter.yml` runs (a few seconds). Confirm the draft now reflects the merged PR and shows the intended next version: ```bash - gh release list --repo layer5io/sistent - gh release view --repo layer5io/sistent + gh release list --repo layer5io/sistent # find the draft (isDraft = true) + gh release view --repo layer5io/sistent # confirm version + notes ``` -2. Publish it (this is the trigger — it starts `npm publish`): + If the version bump is wrong, it's driven by the merged PR's labels — relabel and let Release Drafter re-draft; do not hand-edit the tag. + +2. **Publish the draft Release. That's it.** No version editing, no notes, no tagging: ```bash gh release edit --repo layer5io/sistent --draft=false --latest ``` + Publishing triggers `release.yml`, which publishes to npm and auto-merges the version-bump-back PR. -### Option B — manual dispatch (no draft, or re-run a publish) - -`release.yml` also accepts `workflow_dispatch` with a `tag_name` input: -```bash -gh workflow run release.yml --repo layer5io/sistent -f tag_name= -``` -Use this only when you deliberately want to (re)publish a specific version without going through a drafted GitHub Release. - -### Verify +## Verify 1. Watch the publish workflow to success: ```bash - gh run watch --repo layer5io/sistent $(gh run list --repo layer5io/sistent --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId') + gh run watch --repo layer5io/sistent \ + "$(gh run list --repo layer5io/sistent --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId')" ``` 2. Confirm the version is live on npm: ```bash npm view @sistent/sistent version ``` -3. **Merge the auto-opened `release/version-bump/vX.Y.Z` PR** so `master` stops drifting behind npm. It needs an approving review like any PR (branch protection). If the automated step was skipped, open the bump manually. -4. Confirm `notify-dependents` opened/updated the downstream consumer bump PRs. +3. Confirm the `release/version-bump/vX.Y.Z` PR auto-merged (it should close on its own) and that `notify-dependents` opened the downstream consumer bump PRs. ## What NOT to do -- ❌ Don't `npm publish` locally or `npm version`-tag by hand — the workflow owns publishing (with provenance) and the version is derived from the release tag. -- ❌ Don't push a `package.json` version bump directly to `master` — it's protected; use the auto-generated bump-back PR. -- ❌ Don't publish a release expecting it to contain an unmerged PR — merge to `master` first. -- ❌ Don't delete/republish an already-published npm version — npm publishes are effectively permanent; cut a new patch instead. +- ❌ Don't `npm publish` locally or `npm version`-tag by hand — the workflow owns publishing (with provenance) and the version comes from the release tag. +- ❌ Don't edit the draft's version or release notes — Release Drafter owns both; fix PR labels instead. +- ❌ Don't publish expecting an unmerged PR to be included — the release ships `master`'s current state; merge first, then let Release Drafter update the draft. +- ❌ Don't republish an already-published npm version — npm publishes are effectively permanent; cut a new patch instead. ## Permissions note -Cutting a release requires publishing a GitHub Release (or dispatching a workflow) with write access, and it deploys to the public npm registry — an irreversible, outward-facing action. Automated/CI or restricted agent sessions may be blocked from merging protected branches and publishing releases (e.g. `403 "not permitted for this session type"`); in that case, hand these steps to a maintainer with release rights rather than attempting to force them. +Publishing a release deploys to the public npm registry — an irreversible, outward-facing action, and it runs against a protected branch. Restricted agent / CI sessions can be blocked from publishing releases and merging protected branches (e.g. `403 "not permitted for this session type"`); in that case, hand the publish step to a maintainer with release rights rather than attempting to force it.