ci: publish the storybook through our own Sites plane; delete three foreign ones

Three site deploys in this repo published to planes that are not ours.

deploy-storybook.yml handed src/frontend/storybook-static to
actions/deploy-pages. It is a real static site, so it moves:
.hanzo/workflows/storybook.yml builds the export, POSTs
/v1/projects/flow-storybook/deploy, syncs the bytes straight to the S3 prefix
cloud names in the 202, then flips the deployment live — and marks it error if
the run dies after enqueueing, so cloud never records a deployment as "queued"
forever. Modelled on hanzoai/hips. It runs on hanzo-build-linux-amd64, a label
the forge runners actually advertise; a label nothing answers does not fail, it
queues silently until the 24h timeout.

This is a SEPARATE file from .hanzo/workflows/deploy.yml on purpose. That one
builds and pushes ghcr.io/hanzoai/flow — the App the operator runs. An App is a
container the cluster executes, a Site is bytes S3 serves; folding a docs
publish into an image build would couple it to a deployable release.

deploy_gh-pages.yml pushed docs/ to a gh-pages branch via
peaceiris/actions-gh-pages, and deploy-docs-draft.yml published per-PR docs
previews to an AWS S3 bucket behind CloudFront on upstream's account
(DOCS_AWS_ACCESS_KEY_ID, DOCS_DRAFT_CLOUD_FRONT_DISTRIBUTION_ID). Both are gone
with no native replacement, because this repo is not where these docs live:
hanzoai/docs carries them at apps/flow, and standing a second site up here would
be a second source of truth for one set of pages. docs/static/CNAME
(docs.flow.org) and docs/static/.nojekyll go with them — Pages artifacts that
mean nothing to S3.

The docs tree also shipped three third-party trackers: a Google gtag
(G-SLQFLQ3KPT), the consent-mode + TrustArc scripts feeding it, and a Segment
plugin reading SEGMENT_PUBLIC_WRITE_KEY. All removed, along with
@docusaurus/plugin-google-tag-manager, @mendable/search and
docs/src/plugins/segment. Telemetry goes to our own bus or nowhere.

Analytics on the storybook is the ONE hosted tag (cloud apps/analytics/tag.go)
posting to POST /v1/event. Its key is read from the project this run deploys, so
pk- rotation is one server-side change rather than five repo edits, and no key
means no tag: a keyless beacon is accepted 200 into $public, a tenant this org
cannot read.

Every other workflow stays. They are release, image-build, lint and test lanes,
not deploy planes.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-04 18:25:55 -07:00
co-authored by Hanzo Dev
parent c67f7d26e6
commit 5f360f4132
12 changed files with 186 additions and 761 deletions
-270
View File
@@ -1,270 +0,0 @@
name: Pull Request Docs Draft
on:
pull_request:
branches:
- '**'
paths:
- 'docs/**'
- '.github/workflows/deploy-docs-draft.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
if: "! github.event.pull_request.head.repo.fork"
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: ./docs/package-lock.json
- name: Validate Branch Names
env:
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
run: |
# Check if branch names contain invalid characters. Only alphanumeric, _, -, ., and / are allowed.
validate_branch_name() {
local branch_name="$1"
if [[ ! "$branch_name" =~ ^[a-zA-Z0-9/_\.-]+$ ]]; then
echo "Error: Branch name contains invalid characters. Only alphanumeric, _, -, ., and / are allowed."
exit 1
fi
}
validate_branch_name "$BRANCH_REF"
- name: Extract Branch Names
id: extract_branch
env:
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
run: |
# Extract and transform branch names
extract_branch() {
local input_branch="$1"
# Check if input_branch starts with "refs/heads/"
if [[ "$input_branch" == refs/heads/* ]]; then
# Remove "refs/heads/" prefix safely using parameter expansion
branch_name="${input_branch#refs/heads/}"
echo "$branch_name"
else
echo "$input_branch"
fi
}
# Transform branch names in form of `refs/heads/main` to `main`
draft_branch=$(extract_branch "$BRANCH_REF")
# Replace / with - in the draft branch name to use as a directory name
draft_directory=$(echo "$draft_branch" | tr / -)
# Safe echo to $GITHUB_OUTPUT
{
echo "draft_branch=$draft_branch"
echo "draft_directory=$draft_directory"
} >> "$GITHUB_OUTPUT"
- name: Set Draft URL
id: draft_url
if: success()
run: |
echo "url=${{ vars.DOCS_DRAFT_BASE_URL }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/index.html" >> $GITHUB_OUTPUT
- name: Install dependencies
run: cd docs && npm install
- name: Build website
if: success()
run: |
set -o pipefail
cd docs
npm run build |& tee $GITHUB_WORKSPACE/build.log
env:
BASE_URL: /flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}
FORCE_COLOR: 0 # Disable color output
SEGMENT_PUBLIC_WRITE_KEY: ${{ vars.DOCS_DRAFT_IBM_SEGMENT_PUBLIC_WRITE_KEY }}
- name: Check Build Result
id: buildLogFail
if: failure()
run: |
MULTILINE_LOG=$(cat $GITHUB_WORKSPACE/build.log)
echo "BUILD_FAILURE<<EOF" >> $GITHUB_ENV
echo $MULTILINE_LOG >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Hide Previous Build Comments
if: ${{ github.event.pull_request.number && (success() || failure()) }}
run: |
set -e
# Get all comments on the PR that match our build comments
comments=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
--jq '.[] | select(.body | test("Build failure! :x:|Build successful! :white_check_mark:")) | .node_id')
# Minimize each matching comment using GraphQL API
if [[ -n "$comments" ]]; then
echo "Found previous build comments to hide"
while IFS= read -r comment_id; do
if [[ -n "$comment_id" ]]; then
echo "Minimizing comment: $comment_id"
gh api graphql \
--field id="$comment_id" \
--field classifier="OUTDATED" \
--raw-field query='
mutation($id: ID!, $classifier: ReportedContentClassifiers!) {
minimizeComment(input: { subjectId: $id, classifier: $classifier }) {
minimizedComment {
isMinimized
}
}
}' || echo "Failed to minimize comment $comment_id, continuing..."
echo
fi
done <<< "$comments"
else
echo "No previous build comments found to hide"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Assemble Build Success Comment
if: success()
run: |
build_success_comment="Build successful! :white_check_mark:"
build_success_comment+="\nDeploying docs draft."
echo "BUILD_SUCCESS_COMMENT<<EOF" >> $GITHUB_ENV
echo -e "$build_success_comment" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Build Success Comment
if: success()
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: "${{ env.BUILD_SUCCESS_COMMENT }}"
reactions: rocket
- name: Create Build Failure Comment
if: failure()
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
Build failure! :x:
> ${{ env.BUILD_FAILURE }}
reactions: confused
- name: Find Comment
id: fc
if: success()
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: Build successful!
direction: last
- name: Configure AWS CLI
if: success()
run: |
aws configure set aws_access_key_id ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }}
aws configure set aws_secret_access_key ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }}
aws configure set region us-west-2
- name: Check for New Assets
run: |
set -o pipefail
echo "Checking for new assets." |& tee -a $GITHUB_WORKSPACE/deploy.log
echo "aws s3 sync docs/build/assets/ s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/assets/ --size-only --dryrun --no-progress" | tee -a $GITHUB_WORKSPACE/deploy.log
aws s3 sync docs/build/assets/ "s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/assets/" --size-only --dryrun --no-progress | tee $GITHUB_WORKSPACE/assets.log
- name: Determine Standard or Full Publish
id: check_full_publish
run: |
# Determine if a full publish is required because of new assets.
if grep -qE '(upload:|delete:)' "$GITHUB_WORKSPACE/assets.log"; then
echo "New assets. Perform full publish: true" | tee -a "$GITHUB_WORKSPACE/deploy.log"
echo "perform_full_publish=true" >> "$GITHUB_OUTPUT"
else
echo "No new assets. Perform full publish: false" | tee -a "$GITHUB_WORKSPACE/deploy.log"
echo "perform_full_publish=false" >> "$GITHUB_OUTPUT"
fi
- name: Deploy to S3
if: success()
run: |
set -o pipefail
cd docs
mkdir flow-drafts
mv build flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}
cd flow-drafts
# Records the repository that originally triggered the build so we can post back
# comments upon clean up of a stale draft if it still has an open pull request.
echo "${{ github.event.repository.full_name }}" > ${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository
s3_params=(
# Hide upload progress for a cleaner sync log
--no-progress
--delete
--exclude "*"
--include "${{ steps.extract_branch.outputs.draft_directory }}/*"
)
if [[ "${{ steps.check_full_publish.outputs.perform_full_publish }}" == "false" ]]; then
s3_params+=(--size-only)
fi
echo "Deploying draft to S3." |& tee -a $GITHUB_WORKSPACE/deploy.log
echo "aws s3 sync . s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts ${s3_params[@]}" |& tee -a $GITHUB_WORKSPACE/deploy.log
aws s3 sync . "s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts" "${s3_params[@]}" |& tee -a $GITHUB_WORKSPACE/deploy.log
# Update .github_source_repository file metadata to mark last modified time of the draft.
# This will allow us to later determine if a draft is stale and needs to be cleaned up.
echo "Marking last modified time of the draft." |& tee -a $GITHUB_WORKSPACE/deploy.log
echo "aws s3 cp --metadata '{\"touched\": \"now\"}' \
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository" \
|& tee -a $GITHUB_WORKSPACE/deploy.log
aws s3 cp --metadata '{ "touched": "now" }' \
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \
|& tee -a $GITHUB_WORKSPACE/deploy.log
- name: Invalidate CloudFront Cache
if: success()
run: |
invalidation_batch="{ \"Paths\": { \"Quantity\": 1, \"Items\": [\"/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/*\"] }, \"CallerReference\": \"flow-docs-draft-files-$(date +%s)\" }"
echo $invalidation_batch | jq . |& tee -a "$GITHUB_WORKSPACE/deploy.log"
echo "Creating invalidation." |& tee -a "$GITHUB_WORKSPACE/deploy.log"
invalidation_id=$(aws cloudfront create-invalidation --distribution-id "${{ vars.DOCS_DRAFT_CLOUD_FRONT_DISTRIBUTION_ID }}" --invalidation-batch "$invalidation_batch" --query 'Invalidation.Id' --output text |& tee -a "$GITHUB_WORKSPACE/deploy.log")
echo "Awaiting invalidation." |& tee -a "$GITHUB_WORKSPACE/deploy.log"
aws cloudfront wait invalidation-completed --distribution-id "${{ vars.DOCS_DRAFT_CLOUD_FRONT_DISTRIBUTION_ID }}" --id "$invalidation_id" |& tee -a "$GITHUB_WORKSPACE/deploy.log"
echo "Invalidation complete." |& tee -a "$GITHUB_WORKSPACE/deploy.log"
- name: Update Comment
if: ${{ steps.fc.outputs.comment-id != '' }}
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
Deploy successful! [View draft](${{ steps.draft_url.outputs.url }})
reactions: hooray
- name: Upload Deploy Log
uses: actions/upload-artifact@v6
if: always()
with:
name: deploy.log
path: ${{ github.workspace }}/deploy.log
-49
View File
@@ -1,49 +0,0 @@
name: Deploy Storybook to GitHub Pages
on:
push:
branches:
- main
paths:
- 'src/frontend/**/*.stories.*'
- 'src/frontend/.storybook/**'
- 'src/frontend/package.json'
- '.github/workflows/deploy-storybook.yml'
workflow_dispatch: # Allow manual trigger
jobs:
deploy:
name: Deploy Storybook
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: cd src/frontend && npm ci
- name: Build Storybook
run: cd src/frontend && npm run build-storybook
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: src/frontend/storybook-static
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-45
View File
@@ -1,45 +0,0 @@
name: Deploy to GitHub Pages
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to deploy docs from"
required: false
default: "main"
type: string
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.branch }}
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: ./docs/package-lock.json
- name: Install dependencies
run: cd docs && npm install
- name: Build website
run: cd docs && npm run build
env:
SEGMENT_PUBLIC_WRITE_KEY: ${{ vars.DOCS_PROD_IBM_SEGMENT_PUBLIC_WRITE_KEY }}
# Popular action to deploy to GitHub Pages:
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Build output to publish to the `gh-pages` branch:
publish_dir: ./docs/build
# The following lines assign commit authorship to the official
# GH-Actions bot for deploys to `gh-pages` branch:
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
# The GH actions bot is used by default if you didn't specify the two fields.
# You can swap them out with your own user credentials.
+186
View File
@@ -0,0 +1,186 @@
name: storybook
# The frontend storybook is a SITE, not an App.
#
# It used to publish through actions/deploy-pages
# (.github/workflows/deploy-storybook.yml). That is a second deploy plane living
# outside our own CI/CD, outside the Sites plane, and outside our analytics, so
# it is gone.
#
# npm run build-storybook -> src/frontend/storybook-static
# -> POST /v1/projects/flow-storybook/deploy {"source":"git"} (202, queued)
# -> aws s3 sync storybook-static s3://<bucket>/<prefix> (bytes skip the API)
# -> POST .../deployments/<id>/complete {"status":"live"}
#
# Modelled on hanzoai/hips .hanzo/workflows/deploy.yml, which is the canonical
# shape for this. The bytes go straight to S3 rather than through an artifact
# POST because the door has a 16 MiB BodyLimit that fasthttp enforces BEFORE any
# handler runs, and a storybook bundle clears that easily. cloud names the bucket
# and prefix in the 202 so CI writes exactly where the site is served from.
#
# THIS IS A SEPARATE FILE FROM .hanzo/workflows/deploy.yml ON PURPOSE. That one
# builds and pushes ghcr.io/hanzoai/flow — the App the operator runs. An App is
# a container the cluster executes; a Site is bytes S3 serves. They have
# different outputs, different triggers and different failure modes, and folding
# a storybook export into an image build would couple a docs publish to a
# deployable release.
on:
push:
branches: [main]
paths:
- 'src/frontend/**/*.stories.*'
- 'src/frontend/.storybook/**'
- 'src/frontend/package.json'
- 'src/frontend/package-lock.json'
- '.hanzo/workflows/storybook.yml'
workflow_dispatch:
concurrency:
group: storybook-${{ github.ref }}
cancel-in-progress: true
jobs:
site:
# NOT ubuntu-latest. These run on the in-cluster act_runner, whose fleet
# advertises hanzo-build-linux-amd64. A label no runner answers does not
# fail — it queues until the 24h timeout, which is silence, not an error.
runs-on: hanzo-build-linux-amd64
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: build the static export
working-directory: src/frontend
run: |
set -euo pipefail
npm ci
npm run build-storybook
test -s storybook-static/index.html || {
echo "::error::storybook produced no storybook-static/index.html"; exit 1; }
# A storybook shell renders with zero stories and still writes a
# non-empty index.html, so assert the story index has entries rather
# than trusting the file's existence.
test -s storybook-static/index.json || {
echo "::error::storybook wrote no index.json — nothing was indexed"; exit 1; }
echo "exported $(find storybook-static -type f | wc -l) files"
# The ONE analytics wire: cloud's hosted tag (cloud apps/analytics/tag.go),
# which posts the canonical batch to POST /v1/event. There is no Segment,
# no Google Tag Manager, no second tracker, and no vendored copy of the
# tag — it is served from the origin that eats it so a page allowlists ONE
# host.
#
# The key is the project's OWN publishable key, read from the project this
# run is about to deploy. It is never a checked-in constant: pk- is
# write-only and publishable (same trust class as a Sentry DSN), so it
# belongs in the page, but its authority is the project record, which means
# rotation is one server-side change and not five repo edits.
#
# NO KEY => NO TAG, deliberately. A keyless beacon is accepted 200 into
# $public, a reserved tenant this org cannot read, so silence is the honest
# failure and we pick it over reporting into a tenant nobody reads.
#
# This is storybook's generated HTML, which no template of ours can reach,
# so the tag is stamped into the export. Same step, same shape, in every
# doc site in this fleet.
- name: wire the analytics tag
env:
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
run: |
set -euo pipefail
KEY=$(curl -sS -H "Authorization: Bearer ${DEPLOY_TOKEN}" \
"https://api.hanzo.ai/v1/projects/flow-storybook" | jq -r '.key // empty')
if [ -z "$KEY" ]; then
echo "::warning::project flow-storybook exposes no publishable key — publishing untagged rather than beaconing into \$public"
exit 0
fi
TAG="<script defer src=\"https://api.hanzo.ai/v1/event.js\" data-key=\"${KEY}\" data-product=\"flow-storybook\"></script>"
find src/frontend/storybook-static -name '*.html' -print0 \
| xargs -0 -r sed -i "s#</head>#${TAG}</head>#"
echo "tagged $(grep -rl 'v1/event.js' src/frontend/storybook-static | wc -l) pages"
- name: enqueue the deployment
id: enqueue
env:
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_TOKEN:-}" ]; then
echo "::error::HANZO_DEPLOY_TOKEN is unset. It is an org secret on the git.hanzo.ai forge."
exit 1
fi
CODE=$(curl -sS -o /tmp/enq.json -w '%{http_code}' \
-X POST "https://api.hanzo.ai/v1/projects/flow-storybook/deploy" \
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"source\":\"git\",\"commit\":\"${GITHUB_SHA}\",\"branch\":\"main\"}")
# 202 Accepted is the success code — the deployment is queued, not live.
if [ "$CODE" != "202" ]; then
echo "::error::enqueue returned HTTP $CODE"; head -c 600 /tmp/enq.json; exit 1
fi
# bucket and prefix come FROM cloud. Never hardcode them: prefix is
# sitePrefix(org, slug) server-side, so a local guess drifts the moment
# the org or slug changes and the sync lands bytes nobody serves.
echo "id=$(jq -r .id /tmp/enq.json)" >> "$GITHUB_OUTPUT"
echo "bucket=$(jq -r .bucket /tmp/enq.json)" >> "$GITHUB_OUTPUT"
echo "prefix=$(jq -r .prefix /tmp/enq.json)" >> "$GITHUB_OUTPUT"
jq -c '{id,version,status,bucket,prefix}' /tmp/enq.json
- name: sync the export to S3
id: sync
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SITES_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SITES_S3_SECRET_ACCESS_KEY }}
BUCKET: ${{ steps.enqueue.outputs.bucket }}
PREFIX: ${{ steps.enqueue.outputs.prefix }}
run: |
set -euo pipefail
if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
echo "::error::SITES_S3_* secrets unset — cannot sync. These are the hanzo-sites bucket credentials."
exit 1
fi
# --delete so a removed story actually disappears: without it the
# prefix only ever grows and deleted stories keep serving.
aws s3 sync src/frontend/storybook-static "s3://${BUCKET}/${PREFIX}" \
--endpoint-url https://s3.hanzo.ai --delete --only-show-errors
echo "files=$(find src/frontend/storybook-static -type f | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT"
echo "bytes=$(du -sk src/frontend/storybook-static | awk '{print $1*1024}')" >> "$GITHUB_OUTPUT"
- name: flip it live
env:
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
DEP: ${{ steps.enqueue.outputs.id }}
FILES: ${{ steps.sync.outputs.files }}
BYTES: ${{ steps.sync.outputs.bytes }}
run: |
set -euo pipefail
CODE=$(curl -sS -o /tmp/done.json -w '%{http_code}' \
-X POST "https://api.hanzo.ai/v1/projects/flow-storybook/deployments/${DEP}/complete" \
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"status\":\"live\",\"commit\":\"${GITHUB_SHA}\",\"files\":${FILES},\"bytes\":${BYTES}}")
if [ "$CODE" != "200" ]; then
echo "::error::complete returned HTTP $CODE"; head -c 600 /tmp/done.json; exit 1
fi
jq -c '{status,liveUrl,version,files,bytes}' /tmp/done.json
# A run that dies after the enqueue would leave the deployment "queued" and
# the project stuck "building" forever. Report the failure so cloud records
# an honest terminal state instead of a lie by omission.
- name: report a failed build
if: failure() && steps.enqueue.outputs.id != ''
env:
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
DEP: ${{ steps.enqueue.outputs.id }}
run: |
curl -sS -X POST "https://api.hanzo.ai/v1/projects/flow-storybook/deployments/${DEP}/complete" \
-H "Authorization: Bearer ${DEPLOY_TOKEN}" -H 'Content-Type: application/json' \
-d '{"status":"error","message":"CI build or sync failed"}' >/dev/null || true
echo "marked deployment ${DEP} as error"
-67
View File
@@ -6,8 +6,6 @@ const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
const { remarkCodeHike } = require("@code-hike/mdx");
const isProduction = process.env.NODE_ENV === "production";
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "HanzoFlow Documentation",
@@ -39,66 +37,6 @@ const config = {
href: "https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&family=Sora:wght@550;600&display=swap",
},
},
...(isProduction
? [
// Google Consent Mode - Set defaults before Google tags load
{
tagName: "script",
attributes: {},
innerHTML: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
// Set default consent to denied
gtag('consent', 'default', {
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'analytics_storage': 'denied'
});
`,
},
// TrustArc Consent Update Listener
{
tagName: "script",
attributes: {},
innerHTML: `
(function() {
function updateGoogleConsent() {
if (typeof window.truste !== 'undefined' && window.truste.cma) {
var consent = window.truste.cma.callApi('getConsent', window.location.href) || {};
// Map TrustArc categories to Google consent types
// Category 0 = Required, 1 = Functional, 2 = Advertising, 3 = Analytics
var hasAdvertising = consent[2] === 1;
var hasAnalytics = consent[3] === 1;
gtag('consent', 'update', {
'ad_storage': hasAdvertising ? 'granted' : 'denied',
'ad_user_data': hasAdvertising ? 'granted' : 'denied',
'ad_personalization': hasAdvertising ? 'granted' : 'denied',
'analytics_storage': hasAnalytics ? 'granted' : 'denied'
});
}
}
// Listen for consent changes
if (window.addEventListener) {
window.addEventListener('cm_data_subject_consent_changed', updateGoogleConsent);
window.addEventListener('cm_consent_preferences_set', updateGoogleConsent);
}
// Initial check after TrustArc loads
if (document.readyState === 'complete') {
updateGoogleConsent();
} else {
window.addEventListener('load', updateGoogleConsent);
}
})();
`,
},
]
: []),
],
presets: [
@@ -145,9 +83,6 @@ const config = {
priority: null,
ignorePatterns: [],
},
gtag: {
trackingID: "G-SLQFLQ3KPT",
},
blog: false,
theme: {
customCss: [
@@ -200,7 +135,6 @@ const config = {
},
["docusaurus-node-polyfills", { excludeAliases: ["console"] }],
"docusaurus-plugin-image-zoom",
["./src/plugins/segment", { segmentPublicWriteKey: process.env.SEGMENT_PUBLIC_WRITE_KEY, allowedInDev: true }],
[
"@docusaurus/plugin-client-redirects",
{
@@ -524,7 +458,6 @@ const config = {
{
html: `<div class="footer-links">
<span>© ${new Date().getFullYear()} Flow</span>
<span id="preferenceCenterContainer"> ·&nbsp; <a href="#" onclick="if(typeof window !== 'undefined' && window.truste && window.truste.eu && window.truste.eu.clickListener) { window.truste.eu.clickListener(); } return false;" style="cursor: pointer;">Manage Privacy Choices</a></span>
</div>`,
},
],
-2
View File
@@ -15,11 +15,9 @@
"@code-hike/mdx": "^0.9.0",
"@docusaurus/core": "3.9.2",
"@docusaurus/plugin-client-redirects": "^3.9.2",
"@docusaurus/plugin-google-tag-manager": "^3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@easyops-cn/docusaurus-search-local": "^0.45.0",
"@mdx-js/react": "^3.0.1",
"@mendable/search": "^0.0.206",
"clsx": "^1.2.1",
"docusaurus-node-polyfills": "^1.0.0",
"docusaurus-plugin-image-zoom": "^2.0.0",
@@ -1,73 +0,0 @@
/**
* Get common properties that should be included in all events
*/
export function getCommonProperties() {
if (typeof window === 'undefined' || !window.digitalData?.commonProperties) {
return {};
}
return {
productTitle: window.digitalData.commonProperties.productTitle,
productCode: window.digitalData.commonProperties.productCode,
productCodeType: window.digitalData.commonProperties.productCodeType,
UT30: window.digitalData.commonProperties.UT30,
instanceId: window.digitalData.commonProperties.instanceId,
subscriptionId: window.digitalData.commonProperties.subscriptionId,
productPlanName: window.digitalData.commonProperties.productPlanName,
productPlanType: window.digitalData.commonProperties.productPlanType,
};
}
/**
* Get user ID for identify calls
*/
export function getUserId() {
if (typeof window === 'undefined' || !window.digitalData?.commonProperties) {
return 'IBMid-ANONYMOUS';
}
return window.digitalData.commonProperties.userId || 'IBMid-ANONYMOUS';
}
/**
* Call identify with required properties
* Should be called before any track events
*/
export function identifyUser() {
if (typeof window === 'undefined' || !window.analytics?.identify) {
return;
}
const userId = getUserId();
const traits = getCommonProperties();
window.analytics.identify(userId, traits);
}
/**
* Enhanced track function that includes common properties
*/
export function trackEvent(eventName, properties = {}) {
if (typeof window === 'undefined' || !window.analytics?.track) {
return;
}
const commonProps = getCommonProperties();
const mergedProperties = { ...commonProps, ...properties };
window.analytics.track(eventName, mergedProperties);
}
/**
* Enhanced page function that includes common properties
*/
export function trackPage(name, properties = {}) {
if (typeof window === 'undefined' || !window.analytics?.page) {
return;
}
const commonProps = getCommonProperties();
const mergedProperties = { ...commonProps, ...properties };
window.analytics.page(name, mergedProperties);
}
@@ -1,70 +0,0 @@
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import { identifyUser, trackPage } from './analytics-helpers';
/**
* Get friendly page name from pathname
*/
function getFriendlyPageName(pathname) {
// Remove leading/trailing slashes
const cleanPath = pathname.replace(/^\/+|\/+$/g, '');
// Handle root/home page
if (!cleanPath) {
return 'Home';
}
// Split by slash and capitalize each segment
const segments = cleanPath.split('/');
const formattedSegments = segments.map(segment => {
// Replace hyphens with spaces and capitalize
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
});
return formattedSegments.join(' > ');
}
// Client module for tracking page views on route changes
export function onRouteDidUpdate({location, previousLocation}) {
// Only track page views in the browser and when the path actually changes
if (
ExecutionEnvironment.canUseDOM &&
previousLocation &&
location.pathname !== previousLocation.pathname &&
window.analytics &&
window.analytics.page
) {
// Identify user before tracking page
identifyUser();
// Track page with friendly name
const pageName = getFriendlyPageName(location.pathname);
trackPage(pageName, {
path: location.pathname,
url: location.href,
search: location.search,
title: document.title,
});
}
}
// Initial page view on load
if (ExecutionEnvironment.canUseDOM) {
window.addEventListener('load', () => {
if (window.analytics && window.analytics.page) {
// Identify user on initial page load
identifyUser();
// Track initial page view
const pageName = getFriendlyPageName(window.location.pathname);
trackPage(pageName, {
path: window.location.pathname,
url: window.location.href,
search: window.location.search,
title: document.title,
});
}
});
}
@@ -1,98 +0,0 @@
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import { identifyUser, trackEvent } from './analytics-helpers';
let isDataAttributeTrackingInitialized = false;
/**
* Automatic click tracking using data attributes for Docusaurus
*
* Usage: Add data-event and other data-* attributes to any clickable element.
* The tracker will automatically send events to Segment when clicked.
*
* Example - UI Interaction:
* {
* href: "https://github.com/hanzoai/flow",
* 'data-event': 'UI Interaction',
* 'data-action': 'clicked',
* 'data-channel': 'docs',
* 'data-element-id': 'social-github',
* 'data-namespace': 'header',
* 'data-platform-title': 'Flow'
* }
*
* Example - CTA Clicked:
* {
* 'data-event': 'CTA Clicked',
* 'data-cta': 'Get Started',
* 'data-channel': 'docs',
* 'data-text': 'Get Started for Free'
* }
*
* Note:
* - data-event is required (becomes the event name)
* - All other data-* attributes become event properties
* - Follows IBM Segment Common Schema standards
* - No onClick handler needed for tracking
*/
function initializeDataAttributeTracking() {
// Only run on client side and prevent duplicate initialization
if (!ExecutionEnvironment.canUseDOM || isDataAttributeTrackingInitialized) return;
const handleClick = (event) => {
const target = event.target;
const trackingElement = target.closest('[data-event]');
if (!trackingElement) return;
const eventName = trackingElement.dataset.event;
if (!eventName) return;
// Extract all data-* attributes (except data-event itself)
const properties = {};
Object.keys(trackingElement.dataset).forEach(key => {
if (key !== 'event') {
// Map to IBM Segment property names (preserve exact casing per schema)
let propertyKey = key;
// Handle special IBM property mappings
if (key === 'cta') propertyKey = 'CTA';
else if (key === 'elementId') propertyKey = 'elementId';
else if (key === 'topLevel') propertyKey = 'topLevel';
else if (key === 'subLevel') propertyKey = 'subLevel';
else if (key === 'platformTitle') propertyKey = 'platformTitle';
properties[propertyKey] = trackingElement.dataset[key];
}
});
// Identify user before tracking
identifyUser();
// Track the event with common properties
trackEvent(eventName, properties);
};
// Remove existing listener if it exists
document.removeEventListener('click', handleClick);
// Add the new listener
document.addEventListener('click', handleClick);
// Mark as initialized
isDataAttributeTrackingInitialized = true;
}
// Initialize on DOM ready
if (ExecutionEnvironment.canUseDOM) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDataAttributeTracking);
} else {
initializeDataAttributeTracking();
}
// Re-initialize on route changes for SPA navigation
window.addEventListener('popstate', () => {
setTimeout(initializeDataAttributeTracking, 100);
});
}
-86
View File
@@ -1,86 +0,0 @@
// Custom Docusaurus plugin to inject Segment analytics
function pluginSegment(context, options = {}) {
const isProd = process.env.NODE_ENV === "production" || options.allowedInDev;
const segmentPublicWriteKey = options.segmentPublicWriteKey;
if (!segmentPublicWriteKey) {
console.warn('Segment plugin: No write key provided. Analytics will not be initialized.');
return { name: 'docusaurus-plugin-segment' };
}
return {
name: 'docusaurus-plugin-segment',
getClientModules() {
return isProd ? [require.resolve('./analytics-page'), require.resolve('./data-attribute-tracking')] : [];
},
injectHtmlTags() {
if (!isProd) {
return {};
}
return {
headTags: [
// IBM Analytics and TrustArc Configuration
{
tagName: 'script',
attributes: {},
innerHTML: `
window._ibmAnalytics = {
"settings": {
"name": "DataStax",
"tealiumProfileName": "ibm-subsidiary",
},
"trustarc": {
"privacyPolicyLink": "https://ibm.com/privacy"
},
"digitalData.page.services.google.enabled": true
};
window.digitalData = {
"page": {
"pageInfo": {
"ibm": {
"siteId": "IBM_" + _ibmAnalytics.settings.name,
},
segment: {
enabled: true,
env: 'prod',
key: '${segmentPublicWriteKey}',
coremetrics: false,
carbonComponentEvents: false
}
},
"category": {
"primaryCategory": "PC230"
}
},
"commonProperties": {
"productTitle": "IBM Elite Support for Flow",
"productCode": "5900BUB",
"productCodeType": "WWPC",
"UT30": "30AS5",
"instanceId": "docs-site",
"subscriptionId": "public-access",
"productPlanName": "Public",
"productPlanType": "freemium",
"userId": "IBMid-ANONYMOUS"
}
};
`,
},
// IBM Common Stats Script - handles Segment initialization
{
tagName: 'script',
attributes: {
src: '//1.www.s81c.com/common/stats/ibm-common.js',
async: 'true',
},
},
],
};
},
};
}
module.exports = pluginSegment;
View File
-1
View File
@@ -1 +0,0 @@
docs.flow.org