feat(storybook): upload storybooks to github pages

Because:

* we want to upload storybooks to github pages

This commit:

* sets up github actions for uploading storybooks to github pages

Closes FXA-12782
This commit is contained in:
MagentaManifold 2025-12-16 15:35:19 -05:00
parent cf8b195a16
commit 4562f7ab8e
No known key found for this signature in database
GPG Key ID: 17E8134F8EF82646
4 changed files with 490 additions and 0 deletions

View File

@ -0,0 +1,61 @@
name: Cleanup Storybooks
on:
pull_request:
types: [closed]
permissions:
contents: write
concurrency:
group: gh-pages-deploy
cancel-in-progress: false
jobs:
cleanup:
name: Remove closed PR storybooks
runs-on: ubuntu-latest
steps:
- name: Checkout gh-pages branch
uses: actions/checkout@v6
with:
ref: gh-pages
path: gh-pages
fetch-depth: 1
- name: Remove PR directory
run: |
PR_DIR="storybooks/pr-${{ github.event.pull_request.number }}"
if [ -d "gh-pages/$PR_DIR" ]; then
rm -rf "gh-pages/$PR_DIR"
echo "Removed $PR_DIR"
else
echo "Directory $PR_DIR not found, nothing to clean up"
fi
- name: Checkout main branch for scripts
uses: actions/checkout@v6
with:
path: main-repo
- name: Regenerate root index.html
run: node main-repo/_scripts/generate-storybook-index.js
env:
GH_PAGES_DIR: gh-pages
REPO_NAME: ${{ github.event.repository.name }}
REPO_OWNER: ${{ github.repository_owner }}
- name: Commit and push to gh-pages
run: |
cd gh-pages
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Create orphan branch (no history) with current content
git checkout --orphan gh-pages-new
git add -A
git commit -m "Remove storybooks for closed PR ${{ github.event.pull_request.number }}"
# Replace gh-pages with the new orphan branch
git push --force origin gh-pages-new:gh-pages

142
.github/workflows/deploy-storybooks.yml vendored Normal file
View File

@ -0,0 +1,142 @@
name: Deploy Storybooks to GitHub Pages
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
statuses: write
jobs:
build:
name: Build Storybooks
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'yarn'
- name: Install dependencies
run: yarn install
- name: Clone l10n repository
run: _scripts/l10n/clone.sh
- name: Build all Storybooks
run: npx nx run-many -t build-storybook
- name: Organize Storybooks for deployment
run: node _scripts/organize-storybooks.js
env:
DEPLOY_DIR: deploy/
- name: Upload storybooks artifact
uses: actions/upload-artifact@v4
with:
name: storybooks-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'main' }}
path: deploy/
retention-days: 1
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: build
# Single concurrency group to prevent conflicts
concurrency:
group: gh-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout repository for scripts
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
path: repo
- name: Checkout gh-pages branch
uses: actions/checkout@v6
with:
ref: gh-pages
path: gh-pages
fetch-depth: 1
- name: Set deployment directory
id: deploy-dir
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
DEPLOY_DIR="storybooks/pr-${{ github.event.pull_request.number }}"
else
DEPLOY_DIR="storybooks/main"
fi
echo "path=$DEPLOY_DIR" >> $GITHUB_OUTPUT
echo "DEPLOY_DIR=$DEPLOY_DIR" >> $GITHUB_ENV
- name: Remove old storybook directory
run: rm -rf "gh-pages/${{ steps.deploy-dir.outputs.path }}"
- name: Download storybooks artifact
uses: actions/download-artifact@v4
with:
name: storybooks-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'main' }}
path: gh-pages/${{ steps.deploy-dir.outputs.path }}
- name: Generate root index.html
run: node repo/_scripts/generate-storybook-index.js
env:
STORYBOOKS_DIR: gh-pages/storybooks
REPO_NAME: ${{ github.event.repository.name }}
REPO_OWNER: ${{ github.repository_owner }}
- name: Commit and push to gh-pages
run: |
cd gh-pages
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [ "${{ github.event_name }}" == "pull_request" ]; then
COMMIT_MSG="Deploy storybooks for PR ${{ github.event.pull_request.number }}"
else
COMMIT_MSG="Deploy storybooks from main branch"
fi
# Create orphan branch (no history) with current content
git checkout --orphan gh-pages-new
git add -A
git commit -m "$COMMIT_MSG"
# Replace gh-pages with the new orphan branch
git push --force origin gh-pages-new:gh-pages
- name: Create GitHub status check
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
with:
script: |
const deployDir = process.env.DEPLOY_DIR;
const deployUrl = `https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${deployDir}/`;
const sha = context.payload.pull_request.head.sha;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: sha,
state: 'success',
context: 'Link to Storybooks',
description: 'Storybooks deployed',
target_url: deployUrl
});
console.log(`Created status check for ${sha} with URL: ${deployUrl}`);

View File

@ -0,0 +1,134 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const fs = require('fs');
const path = require('path');
const storybooksDir = process.env.STORYBOOKS_DIR || 'gh-pages/storybooks';
const repoName = process.env.REPO_NAME;
const repoOwner = process.env.REPO_OWNER;
if (!repoName || !repoOwner) {
console.error(
'Error: REPO_NAME and REPO_OWNER environment variables are required'
);
process.exit(1);
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function getDirectories(dir) {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir)
.filter((item) => fs.statSync(path.join(dir, item)).isDirectory())
.sort();
}
function getMetadata(dir) {
const metadataPath = path.join(dir, 'metadata.json');
if (!fs.existsSync(metadataPath)) {
return null;
}
try {
const content = fs.readFileSync(metadataPath, 'utf-8');
return JSON.parse(content);
} catch (error) {
console.error(
`Failed to parse metadata from ${metadataPath}:`,
error.message
);
return null;
}
}
function generatePrItem(pr) {
const title = `#${escapeHtml(pr.number)}${pr.metadata?.summary ? ` ${escapeHtml(pr.metadata.summary)}` : ''}`;
const meta = pr.metadata
? `<div class="meta">${escapeHtml(pr.metadata.commit.substring(0, 7))}${pr.metadata.timestamp ? new Date(pr.metadata.timestamp).toLocaleString('en-US', { timeZoneName: 'short' }) : ''} • <a href="https://github.com/${escapeHtml(repoOwner)}/${escapeHtml(repoName)}/pull/${escapeHtml(pr.number)}" target="_blank">View PR</a></div>`
: '';
return `
<li>
<a href="./${escapeHtml(pr.dir)}/">${title}</a>
${meta}
</li>
`;
}
const directories = getDirectories(storybooksDir);
const prs = directories
.filter((dir) => dir.startsWith('pr-'))
.map((dir) => {
const prNumber = dir.replace('pr-', '');
const metadata = getMetadata(path.join(storybooksDir, dir));
return {
number: prNumber,
dir,
metadata,
};
})
.sort((a, b) => parseInt(b.number) - parseInt(a.number));
const mainMetadata = getMetadata(path.join(storybooksDir, 'main'));
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Storybooks for ${escapeHtml(repoName)}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.2rem; margin-top: 2rem; }
ul { list-style: none; padding: 0; }
li { margin: 0.75rem 0; }
a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { color: #666; font-size: 0.875rem; }
</style>
</head>
<body>
<h1>Storybooks for ${escapeHtml(repoName)}</h1>
<h2>Latest Main Branch</h2>
<ul>
<li>
<a href="./main/">${mainMetadata?.summary ? escapeHtml(mainMetadata.summary) : 'Main Branch Storybooks'}</a>
${mainMetadata ? `<div class="meta">${escapeHtml(mainMetadata.commit.substring(0, 7))}${mainMetadata.timestamp ? new Date(mainMetadata.timestamp).toLocaleString('en-US', { timeZoneName: 'short' }) : ''}</div>` : ''}
</li>
</ul>
${
prs.length > 0
? `
<h2>Pull Requests (${prs.length})</h2>
<ul>
${prs.map(generatePrItem).join('\n')}
</ul>
`
: '<p style="color: #666;">No open pull requests with storybooks deployments.</p>'
}
</body>
</html>`;
const outputPath = path.join(storybooksDir, 'index.html');
fs.writeFileSync(outputPath, html);
console.log(`Generated storybooks/index.html with main + ${prs.length} PRs`);

View File

@ -0,0 +1,153 @@
/* ThisSource Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const deployDir = process.env.DEPLOY_DIR;
if (!deployDir) {
console.error('Error: DEPLOY_DIR environment variable is required');
process.exit(1);
}
fs.mkdirSync(deployDir, { recursive: true });
function getCommitMetadata() {
try {
const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
const summary = execSync('git log -1 --pretty=format:%s', {
encoding: 'utf-8',
}).trim();
const description = execSync('git log -1 --pretty=format:%b', {
encoding: 'utf-8',
}).trim();
const timestamp = execSync('git log -1 --pretty=format:%ct', {
encoding: 'utf-8',
}).trim();
return {
commit,
summary,
description,
timestamp: parseInt(timestamp, 10) * 1000, // Convert to milliseconds
};
} catch (error) {
console.error('Failed to get commit metadata:', error.message);
return {
commit: 'unknown',
summary: '',
description: '',
timestamp: Date.now(),
};
}
}
const commitMetadata = getCommitMetadata();
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function findStorybookDirs(dir, results = []) {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
if (item === 'storybook-static' && fs.statSync(fullPath).isDirectory()) {
results.push(fullPath);
} else if (
fs.statSync(fullPath).isDirectory() &&
item !== 'node_modules' &&
item !== 'deploy'
) {
findStorybookDirs(fullPath, results);
}
}
return results;
}
const storybookDirs = findStorybookDirs('.');
for (const storybookDir of storybookDirs) {
const packageName = path.basename(path.dirname(storybookDir));
const targetDir = path.join(deployDir, packageName);
console.log(`Copying ${packageName} storybook...`);
fs.cpSync(storybookDir, targetDir, { recursive: true });
}
const storybooks = fs
.readdirSync(deployDir)
.filter((item) => fs.statSync(path.join(deployDir, item)).isDirectory())
.sort();
const title = `Storybooks for commit ${escapeHtml(commitMetadata.commit)}`;
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
}
h1 { font-size: 1.5rem; }
ul { list-style: none; padding: 0; }
li { margin: 0.75rem 0; }
a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
dl { margin-top: 2rem; }
dt { font-weight: 600; margin-top: 1rem; color: #333; }
dd { margin: 0.25rem 0 0 0; color: #666; }
pre { margin: 0; white-space: pre-wrap; font-family: inherit; }
</style>
</head>
<body>
<h1>${title}</h1>
<ul>
${storybooks.map((name) => `<li><a href="./${encodeURIComponent(name)}/">${escapeHtml(name)}</a></li>`).join('\n')}
</ul>
<dl>
<dt>Date</dt>
<dd>${escapeHtml(new Date(commitMetadata.timestamp).toLocaleString('en-US', { timeZoneName: 'short' }))}</dd>
<dt>Summary</dt>
<dd><pre>${escapeHtml(commitMetadata.summary)}</pre></dd>${
commitMetadata.description
? `
<dt>Description</dt>
<dd><pre>${escapeHtml(commitMetadata.description)}</pre></dd>`
: ''
}
</dl>
</body>
</html>`;
fs.writeFileSync(path.join(deployDir, 'index.html'), html);
// Write metadata as JSON for easier consumption by site index
const metadata = {
...commitMetadata,
storybooks,
deployDir,
};
fs.writeFileSync(
path.join(deployDir, 'metadata.json'),
JSON.stringify(metadata, null, 2)
);
console.log(
`Created index.html and metadata.json with ${storybooks.length} storybooks`
);
console.log('\nDeployment structure:');
console.log(storybooks.join('\n'));