feat: add readable Storybook branch bookmarks (#13231)

## Thinking Path

> - Paperclip helps people manage AI agents for work.
> - Maintainers use Storybook previews to review the board UI.
> - Branch previews need stable bookmarks that people can read.
> - The current publisher only provides a hashed branch path.
> - This pull request adds a readable branch bookmark after each
successful upload.
> - Existing branch and build links keep working.

## Linked Issues or Issue Description

Refs #13226.

**What existing behavior does this improve?**

Manual Storybook publication for repository branches.

**Current behavior**

The stable branch path contains a hash. The expected
`/storybook/branches/master/` URL does not exist.

**Proposed behavior**

Each publication updates a readable bookmark. The action summary and
Markdown artifact link it. Master uses `/storybook/branches/master/`.
Other names use a safe path segment that preserves case and escapes
special characters.

**Reason and benefit**

Maintainers can save and share a readable URL that opens the latest
published branch build.

**Breaking changes**

None. Existing hashed branch entries still update. Existing build URLs
remain valid.

**Additional context**

This follows the publisher in #13226. A duplicate search found no
related bookmark change. It does not overlap planned core work in
ROADMAP.md.

## What Changed

- Generate readable branch bookmarks without collisions with existing
build directories.
- Upload the bookmark only after the full build and compatibility entry
uploads succeed.
- Link the bookmark in the existing summary and Markdown artifact.
- Document branch-name escaping and test path isolation, stable links,
and upload order.

## Verification

- `node --test scripts/__tests__/storybook-deploy.test.mjs`: 20 tests
pass.
- `actionlint .github/workflows/storybook-deploy.yml
.github/workflows/storybook-visual.yml`: passes.
- `git diff --check`: passes.
- [Master bookmark
publication](https://github.com/paperclipai/paperclip/actions/runs/34613344758):
passed. Opened `/storybook/branches/master/` in the browser and
confirmed a story renders. Downloaded the Markdown report and verified
its bookmark link.
- [Feature branch bookmark
publication](https://github.com/paperclipai/paperclip/actions/runs/34613449034):
passed. Its separate bookmark uses `codex~2Fstorybook-bookmarks`.
- Greptile: 5/5 on `dccaf10413ecf447cb34e622b6b3c505791abb51`, with no
unresolved review threads. All current-head Paperclip CI gates pass,
including typecheck, tests, build, browser suites, and the canary dry
run.
- Full local repository checks were not repeated for this focused
publisher change. The preceding run passed typecheck but encountered
unrelated native-session test failures.

## Risks

- Special characters in branch names use `~HH` byte escapes. For
example, `feature/foo` becomes `feature~2Ffoo`.
- Names that could overlap an existing hashed build directory escape the
final hyphen. Very long names retain a hash suffix.
- The two branch entries update separately. If the final upload fails,
the workflow fails and a rerun can repair the bookmark.

## Model Used

OpenAI GPT-6 via Codex, with reasoning, shell tools, and live deployment
verification. The exact runtime model ID and context-window size are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-11 10:19:36 -05:00
1 parent 3b03c4b9eb
commit 96bba78fba
5 files changed
+54 -7

No files matched your search

+20 -2
View File
@@ -106,7 +106,23 @@ test('different branches have distinct stable URLs, including names that sanitiz
const branches = ['feature/foo', 'feature-foo', 'Feature/foo', 'master', 'feature_foo', 'a'.repeat(100), 'a'.repeat(101)];
const urls = branches.map(branch => storybookDestination({ ...input, branch }).url);
assert.equal(new Set(urls).size, branches.length);
assert.ok(urls.every(url => /^https:\/\/example.cloudfront.net\/storybook\/branches\/[a-z0-9-]+\/index.html$/.test(url)));
assert.ok(urls.every(url => /^https:\/\/example.cloudfront.net\/storybook\/branches\/[A-Za-z0-9_~\-]+\/$/.test(url)));
assert.equal(storybookDestination({ ...input, branch: 'master' }).url,
'https://example.cloudfront.net/storybook/branches/master/');
assert.equal(storybookDestination(input).url,
'https://example.cloudfront.net/storybook/branches/feature~2Ffoo/');
});
test('bookmark paths cannot collide with other branches or existing immutable build directories', () => {
const branches = ['feature/foo', 'feature~2Ffoo', '../master', 'master/index.html',
'master', storybookDestination({ ...input, branch: 'master' }).branchKey,
'a'.repeat(1000), 'a'.repeat(1001), 'café', 'caf~C3~A9'];
const destinations = branches.map(branch => storybookDestination({ ...input, branch }));
assert.equal(new Set(destinations.map(d => d.url)).size, branches.length);
for (const d of destinations) {
assert.doesNotMatch(d.bookmarkPrefix.slice('storybook/branches/'.length), /[/.]/);
assert.ok(Buffer.byteLength(`${d.bookmarkPrefix}/index.html`) <= 1024);
assert.ok(destinations.every(other => d.bookmarkPrefix !== other.prefix));
}
});
test('redeploying a branch preserves its entry URL and creates a new build URL', () => {
const a = storybookDestination(input);
@@ -166,10 +182,12 @@ function publishFixture(options = {}) {
test('publisher uploads a complete build then updates only that branch entry', () => {
const { result, uploads } = publishFixture();
assert.equal(result.status, 0, result.stderr);
assert.equal(uploads.length, 2);
assert.equal(uploads.length, 3);
const d = storybookDestination(input);
assert.ok(uploads[0].includes(`s3://${input.bucket}/${d.buildPrefix}/`));
assert.ok(uploads[1].includes(`s3://${input.bucket}/${d.prefix}/index.html`));
assert.ok(uploads[2].includes(`s3://${input.bucket}/${d.bookmarkPrefix}/index.html`));
assert.ok(uploads[2].includes('no-cache,max-age=0,must-revalidate'));
assert.ok(uploads[0].includes('--no-follow-symlinks'));
assert.doesNotMatch(JSON.stringify(uploads), /--delete/);
});