diff --git a/.buildkite/package.json b/.buildkite/package.json index b304ea9bef1..8b31778e910 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "build:bk:types": "ts-node scripts/get_buildkite_types.ts", + "build:release-site": "ts-node scripts/build_release_site.ts", "postinstall": "yarn build:bk:types", "build:pipeline": "ts-node pipelines/pull_request/pipeline.ts", "print:pipeline": "yarn -s build:bk:types && TEST_BK_PIPELINE=true ts-node -r dotenv/config pipelines/pull_request/pipeline.ts", diff --git a/.buildkite/scripts/build_release_site.ts b/.buildkite/scripts/build_release_site.ts new file mode 100644 index 00000000000..547642b5075 --- /dev/null +++ b/.buildkite/scripts/build_release_site.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'path'; + +import { assembleSite, buildDocsSite, buildStorybookSite, buildTypeDocs } from '../utils/site'; + +const releaseSiteDir = path.resolve(__dirname, '../../.release-site'); + +/** + * Builds the release-ready static site consumed by the GitHub release workflow. + * The assembled output matches the published GitHub Pages layout with docs at + * the root and Storybook under `/storybook`. + */ +console.log('Building release-ready docs'); +buildTypeDocs(); +buildDocsSite({ + docsUrl: 'https://elastic.github.io', + baseUrl: '/elastic-charts', + nodeEnv: 'production', +}); + +console.log('Building release-ready Storybook'); +buildStorybookSite({ + nodeEnv: 'production', +}); + +console.log('Assembling release site'); +assembleSite({ + outDir: releaseSiteDir, +}); + +console.log(`Release site assembled at ${path.relative(path.resolve(__dirname, '../..'), releaseSiteDir)}`); diff --git a/.buildkite/scripts/steps/docs.ts b/.buildkite/scripts/steps/docs.ts index 888f11ccffb..bba72953dd4 100644 --- a/.buildkite/scripts/steps/docs.ts +++ b/.buildkite/scripts/steps/docs.ts @@ -6,7 +6,16 @@ * Side Public License, v 1. */ -import { bkEnv, compress, exec, getOrCreateDeploymentUrl, startGroup, yarnInstall } from '../../utils'; +import { + bkEnv, + buildDocsSite, + buildTypeDocs, + compress, + docsOutDir, + getOrCreateDeploymentUrl, + startGroup, + yarnInstall, +} from '../../utils'; import { createDeploymentStatus, createOrUpdateDeploymentComment } from '../../utils/deployment'; void (async () => { @@ -23,38 +32,14 @@ void (async () => { startGroup('Building docs - firebase'); const firebaseChannelUrl = await getOrCreateDeploymentUrl(); - await exec('yarn typedoc'); - await exec('yarn build', { - cwd: 'docs', - env: { - DOCUSAURUS_URL: firebaseChannelUrl, - NODE_ENV: bkEnv.isMainBranch ? 'production' : 'development', - NODE_OPTIONS: '--openssl-legacy-provider', - }, + buildTypeDocs(); + buildDocsSite({ + docsUrl: firebaseChannelUrl, + nodeEnv: bkEnv.isMainBranch ? 'production' : 'development', }); - const outDir = `docs/build`; - await compress({ - src: outDir, + src: docsOutDir, dest: '.buildkite/artifacts/docs/firebase.gz', }); - - if (bkEnv.isMainBranch) { - startGroup('Building docs - github pages'); - await exec('yarn build', { - cwd: 'docs', - env: { - DOCUSAURUS_URL: 'https://elastic.github.io', - DOCUSAURUS_BASE_URL: '/elastic-charts', - NODE_ENV: 'production', - NODE_OPTIONS: '--openssl-legacy-provider', - }, - }); - - await compress({ - src: outDir, - dest: '.buildkite/artifacts/docs/github.gz', - }); - } })(); diff --git a/.buildkite/scripts/steps/storybook.ts b/.buildkite/scripts/steps/storybook.ts index 86350b9fb2d..ce2e30a7a52 100644 --- a/.buildkite/scripts/steps/storybook.ts +++ b/.buildkite/scripts/steps/storybook.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { bkEnv, compress, exec, startGroup, yarnInstall } from '../../utils'; +import { bkEnv, buildStorybookSite, compress, startGroup, storybookOutDir, yarnInstall } from '../../utils'; import { createDeploymentStatus, createOrUpdateDeploymentComment } from '../../utils/deployment'; void (async () => { @@ -21,17 +21,12 @@ void (async () => { } startGroup('Building storybook'); - await exec('yarn build', { - cwd: 'storybook', - env: { - NODE_ENV: bkEnv.isMainBranch ? 'production' : 'development', - NODE_OPTIONS: '--openssl-legacy-provider', - }, + buildStorybookSite({ + nodeEnv: bkEnv.isMainBranch ? 'production' : 'development', }); - const outDir = `.out`; await compress({ - src: outDir, + src: storybookOutDir, dest: '.buildkite/artifacts/storybook.gz', }); })(); diff --git a/.buildkite/steps/ghp_deploy.ts b/.buildkite/steps/ghp_deploy.ts index 86a228bcbfd..e8b20e5fce8 100644 --- a/.buildkite/steps/ghp_deploy.ts +++ b/.buildkite/steps/ghp_deploy.ts @@ -7,22 +7,16 @@ */ import type { CustomCommandStep } from '../utils'; -import { createStep, commandStepDefaults, bkEnv } from '../utils'; +import { createStep, commandStepDefaults } from '../utils'; export const ghpDeployStep = createStep(() => { - const isMainBranch = bkEnv.isMainBranch; - return { ...commandStepDefaults, label: ':github: Deploy - GitHub Pages', key: 'deploy_ghp', ignoreForced: true, - skip: isMainBranch ? false : 'Not target branch', + skip: 'Public GitHub Pages publishing is handled by the release workflow', depends_on: ['build_docs', 'build_storybook'], commands: ['npx ts-node .buildkite/scripts/steps/ghp_deploy.ts'], - env: { - // ignore check run reporting when not main - ECH_CHECK_ID: isMainBranch ? 'deploy_ghp' : undefined, - }, }; }); diff --git a/.buildkite/utils/index.ts b/.buildkite/utils/index.ts index 6776787a061..9b19388ae0f 100644 --- a/.buildkite/utils/index.ts +++ b/.buildkite/utils/index.ts @@ -12,3 +12,4 @@ export * from './exec'; export * from './pipeline'; export * from './firebase'; export * from './common'; +export * from './site'; diff --git a/.buildkite/utils/site.ts b/.buildkite/utils/site.ts new file mode 100644 index 00000000000..ba6d914acca --- /dev/null +++ b/.buildkite/utils/site.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +const repoRoot = path.resolve(__dirname, '../..'); +const docsDir = path.join(repoRoot, 'docs'); +const storybookDir = path.join(repoRoot, 'storybook'); + +export const docsOutDir = path.join(docsDir, 'build'); +export const storybookOutDir = path.join(repoRoot, '.out'); + +interface CommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; +} + +interface DocsBuildOptions { + docsUrl: string; + nodeEnv: 'production' | 'development'; + baseUrl?: string; +} + +interface StorybookBuildOptions { + nodeEnv: 'production' | 'development'; +} + +interface AssembleSiteOptions { + outDir: string; + docsSourceDir?: string; + storybookSourceDir?: string; +} + +/** + * Generates the TypeDoc content consumed by the Docusaurus docs build. + */ +export function buildTypeDocs() { + run('yarn', ['typedoc']); +} + +/** + * Builds the Docusaurus site for a specific public base URL. + */ +export function buildDocsSite({ docsUrl, nodeEnv, baseUrl }: DocsBuildOptions) { + run('yarn', ['build'], { + cwd: docsDir, + env: { + DOCUSAURUS_URL: docsUrl, + ...(baseUrl ? { DOCUSAURUS_BASE_URL: baseUrl } : {}), + NODE_ENV: nodeEnv, + NODE_OPTIONS: toNodeOptions(), + }, + }); +} + +/** + * Builds the static Storybook bundle used by preview and release publishing flows. + */ +export function buildStorybookSite({ nodeEnv }: StorybookBuildOptions) { + run('yarn', ['build'], { + cwd: storybookDir, + env: { + NODE_ENV: nodeEnv, + NODE_OPTIONS: toNodeOptions(), + }, + }); +} + +/** + * Assembles the final static site tree with docs at the root and Storybook under `/storybook`. + */ +export function assembleSite({ + outDir, + docsSourceDir = docsOutDir, + storybookSourceDir = storybookOutDir, +}: AssembleSiteOptions) { + fs.rmSync(outDir, { force: true, recursive: true }); + fs.cpSync(docsSourceDir, outDir, { recursive: true }); + fs.cpSync(storybookSourceDir, path.join(outDir, 'storybook'), { recursive: true }); + fs.writeFileSync(path.join(outDir, '.nojekyll'), ''); + + ensureFileExists(path.join(outDir, 'index.html')); + ensureFileExists(path.join(outDir, 'storybook', 'index.html')); +} + +function ensureFileExists(filePath: string) { + if (!fs.existsSync(filePath)) { + throw new Error(`Expected build output at ${path.relative(repoRoot, filePath)}`); + } +} + +function run(command: string, args: string[], { cwd = repoRoot, env = {} }: CommandOptions = {}) { + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...env }, + shell: process.platform === 'win32', + stdio: 'inherit', + }); + + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(' ')}`); + } +} + +function toNodeOptions() { + return [process.env.NODE_OPTIONS, '--openssl-legacy-provider'].filter(Boolean).join(' '); +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9197e0eef0..65050727fbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,13 +3,30 @@ name: Publish a Release on: - workflow_dispatch + workflow_dispatch: + inputs: + dry_run: + description: "--dry-run - Run workflow without publishing a release?" + required: true + default: false + type: boolean + publish_pages: + description: "Also publish `elastic.github.io/elastic-charts` site?" + required: true + default: true + type: boolean + pages_target_branch: + description: "github.io branch, only change when testing" + required: true + default: gh-pages + type: string permissions: contents: read # for checkout env: ECH_NODE_VERSION: '22.22.0' + RELEASE_SITE_DIR: .release-site jobs: checks: @@ -40,7 +57,7 @@ jobs: with: working-directory: e2e useRollingCache: true - - name: Install e2e node_modules + - name: Install buildkite node_modules uses: bahmutov/npm-install@v1.10.9 with: working-directory: .buildkite @@ -84,15 +101,60 @@ jobs: uses: bahmutov/npm-install@v1.10.9 with: useRollingCache: true + - name: Install docs node_modules + uses: bahmutov/npm-install@v1.10.9 + with: + working-directory: docs + useRollingCache: true + - name: Install Buildkite node_modules + uses: bahmutov/npm-install@v1.10.9 + with: + working-directory: .buildkite + useRollingCache: true + + - name: Validate Pages publish parameters + if: ${{ inputs.dry_run && inputs.publish_pages && inputs.pages_target_branch == 'gh-pages' }} + run: | + echo "Dry-run releases may only publish to a non-production Pages branch." >&2 + exit 1 - name: Build library run: yarn build + - name: Build release site + run: yarn --cwd .buildkite build:release-site + + - name: Upload release site artifact + uses: actions/upload-artifact@v4 + with: + name: release-site + path: ${{ env.RELEASE_SITE_DIR }} + retention-days: 5 + include-hidden-files: true + - name: Upgrade npm for trusted publishing (OIDC) support + if: ${{ !inputs.dry_run }} run: npm install -g npm@11.5.1 - name: Release env: GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN_GH }} SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - run: yarn semantic-release + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + yarn semantic-release --dry-run + else + yarn semantic-release + fi + + - name: Publish GitHub Pages branch + if: ${{ inputs.publish_pages }} + uses: peaceiris/actions-gh-pages@v4 + with: + personal_token: ${{ secrets.ADMIN_TOKEN_GH }} + publish_branch: ${{ inputs.pages_target_branch }} + publish_dir: ${{ env.RELEASE_SITE_DIR }} + force_orphan: true + user_name: elastic-datavis[bot] + user_email: 98618603+elastic-datavis[bot]@users.noreply.github.com + full_commit_message: Deploy release site for ${{ github.sha }}