Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .claude/skills/cut-release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +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". 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** 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).
- **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. **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.

## Procedure

The only human step is publishing the drafted release — Release Drafter has already done the versioning and notes.

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 # find the draft (isDraft = true)
gh release view <vX.Y.Z> --repo layer5io/sistent # confirm version + notes
```
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 <vX.Y.Z> --repo layer5io/sistent --draft=false --latest
```
Publishing triggers `release.yml`, which publishes to npm and auto-merges the version-bump-back PR.

## 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. 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 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

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.
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/__testing__/sanitizeCatalogImageUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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 <img src> 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();
});
});
10 changes: 9 additions & 1 deletion src/custom/CatalogDetail/TechnologySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ const TechnologySection: React.FC<TechnologySectionProps> = ({
const theme = useTheme();

useEffect(() => {
// `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(
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);
Expand Down
90 changes: 48 additions & 42 deletions src/custom/CustomCatalog/CatalogCardDesignLogo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Dialog } from '../../base';
import { DesignIcon, MesheryFilterIcon } from '../../icons';
import { sanitizeCatalogImageUrl } from './Helper';

interface CatalogCardDesignLogoProps {
zoomEffect?: boolean;
Expand Down Expand Up @@ -34,6 +35,17 @@ const CatalogCardDesignLogo: React.FC<CatalogCardDesignLogoProps> = ({
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]);

// 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);
Expand All @@ -46,48 +58,42 @@ const CatalogCardDesignLogo: React.FC<CatalogCardDesignLogoProps> = ({

return (
<>
{imgURL && imgURL.length > 0 ? (
{resolvedSrc && !imgError ? (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
{!imgError ? (
<>
<img
src={imgURL[0]}
alt="Design SnapShot"
loading="lazy"
onClick={handleZoomClick}
onError={() => setImgError(true)}
style={{
cursor: 'pointer',
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
<Dialog
open={isZoomed}
onClose={handleZoomClose}
style={{
backgroundColor: 'rgba(0, 0, 0, 0.8)'
}}
PaperProps={{
style: {
background: 'transparent',
boxShadow: 'none',
overflow: 'hidden',
maxWidth: '60vw'
}
}}
>
<img
src={imgURL[0]}
alt="Zoomed Design SnapShot"
style={{ objectFit: 'contain', maxWidth: '100%', maxHeight: '100%' }}
/>
</Dialog>
</>
) : (
<SvgComponent type={type} width={width} height={height} style={style} />
)}
<img
src={resolvedSrc}
alt="Design SnapShot"
loading="lazy"
onClick={handleZoomClick}
onError={() => setImgError(true)}
style={{
cursor: 'pointer',
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
<Dialog
open={isZoomed}
onClose={handleZoomClose}
style={{
backgroundColor: 'rgba(0, 0, 0, 0.8)'
}}
PaperProps={{
style: {
background: 'transparent',
boxShadow: 'none',
overflow: 'hidden',
maxWidth: '60vw'
}
}}
>
<img
src={resolvedSrc}
alt="Zoomed Design SnapShot"
style={{ objectFit: 'contain', maxWidth: '100%', maxHeight: '100%' }}
/>
</Dialog>
</div>
) : (
<SvgComponent type={type} width={width} height={height} style={style} />
Expand Down
22 changes: 22 additions & 0 deletions src/custom/CustomCatalog/Helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>`, 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.
Expand Down
3 changes: 2 additions & 1 deletion src/custom/CustomCatalog/index.tsx
Original file line number Diff line number Diff line change
@@ -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 };
8 changes: 7 additions & 1 deletion src/custom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -112,6 +117,7 @@ export {
ModalCard,
PopperListener,
ResponsiveDataTable,
sanitizeCatalogImageUrl,
SearchBar,
StyledDialogActions,
StyledDialogContent,
Expand Down
Loading