diff --git a/.github/workflows/deploy-fabric.yaml b/.github/workflows/deploy-fabric.yaml index d41092949..6824aa170 100644 --- a/.github/workflows/deploy-fabric.yaml +++ b/.github/workflows/deploy-fabric.yaml @@ -56,7 +56,7 @@ jobs: run: npm ci # No DOCUSAURUS_BASE_URL override: the site is served from the fabric - # instance's root path, unlike PR previews which live under /pr-. + # instance's root path, and each PR preview from its own host's root. - name: Build Docusaurus site env: # The assembler reads HarperFast/harper-pro releases; unauthenticated calls share a per-IP limit. diff --git a/.github/workflows/pr-preview.yaml b/.github/workflows/pr-preview.yaml deleted file mode 100644 index 023761225..000000000 --- a/.github/workflows/pr-preview.yaml +++ /dev/null @@ -1,259 +0,0 @@ -name: Deploy PR Preview - -on: - pull_request_target: - types: [opened, synchronize, reopened, closed] - paths: - - 'reference_versioned_docs/**' - - 'reference_versioned_sidebars/**' - - 'reference_versions.json' - - 'docs/**' - - 'fabric/**' - - 'learn/**' - - 'release-notes/**' - - 'src/**' - - 'static/**' - - 'versioned_docs/**' - - 'versioned_sidebars/**' - - 'docusaurus.config.ts' - - 'package-lock.json' - - 'package.json' - - 'historic-redirects.ts' - - 'redirects.ts' - - 'sidebars*.ts' - - 'versions.json' - - 'scripts/assemble-content.mjs' - -# Cancel previous runs when new commits are pushed to the PR -concurrency: - group: pr-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - deploy-preview: - name: Deploy PR Preview - runs-on: ubuntu-latest - if: github.event.action != 'closed' - environment: ${{ github.event.pull_request.head.repo.full_name != github.repository && 'preview' || null }} - permissions: - pull-requests: write - deployments: write - contents: read - - steps: - - name: Checkout PR code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - # checkout v7 blocks fork-PR checkout under pull_request_target by - # default. We opt in deliberately: fork PRs are gated behind the - # 'preview' environment (see the job's `environment:` above), which - # requires manual approval before secrets are exposed. - allow-unsafe-pr-checkout: true - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - - name: Install dependencies - run: | - echo "Installing dependencies..." - npm ci - - - name: Build Docusaurus site - env: - DOCUSAURUS_BASE_URL: /pr-${{ github.event.pull_request.number }} - run: | - echo "Building site with base URL: $DOCUSAURUS_BASE_URL" - npm run build - - - name: Upload build artifact for local testing - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr-${{ github.event.pull_request.number }}-build - path: build/ - retention-days: 30 - - - name: Prepare deployment directory - run: | - PR_DIR="pr-${{ github.event.pull_request.number }}" - echo "Creating directory: $PR_DIR" - mkdir -p "$PR_DIR" - - # Create config.yaml - cat > "$PR_DIR/config.yaml" << 'EOF' - static: - files: 'build/**' - urlPath: 'pr-${{ github.event.pull_request.number }}' - extensions: ['html'] - index: true - EOF - - # Copy build directory - cp -r build "$PR_DIR/" - - echo "Contents of $PR_DIR:" - ls -la "$PR_DIR" - - - name: Install Harper CLI - run: | - npm install -g harper - - - name: Deploy to HarperDB - env: - HARPER_PREVIEW_TARGET: ${{ secrets.HARPER_PREVIEW_TARGET }} - HARPER_PREVIEW_USERNAME: ${{ secrets.HARPER_PREVIEW_USERNAME }} - HARPER_PREVIEW_PASSWORD: ${{ secrets.HARPER_PREVIEW_PASSWORD }} - run: | - cd "pr-${{ github.event.pull_request.number }}" - # Add your additional arguments here - harper deploy \ - target=${HARPER_PREVIEW_TARGET}:9925 \ - username=$HARPER_PREVIEW_USERNAME \ - password=$HARPER_PREVIEW_PASSWORD \ - project=pr-${{ github.event.pull_request.number }} \ - restart=true \ - replicated=true - - - name: Create deployment - env: - HARPER_PREVIEW_TARGET: ${{ secrets.HARPER_PREVIEW_TARGET }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const prNumber = context.payload.pull_request.number; - const target = process.env.HARPER_PREVIEW_TARGET; - const deploymentUrl = `${target}/pr-${prNumber}`; - - // Create deployment - const deployment = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: context.payload.pull_request.head.sha, - environment: `pr-${prNumber}`, - description: `Preview deployment for PR #${prNumber}`, - auto_merge: false, - required_contexts: [] - }); - - // Create deployment status - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.data.id, - state: 'success', - environment_url: deploymentUrl, - description: 'Preview deployment successful' - }); - - // Also add a comment to the PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `## ๐Ÿš€ Preview Deployment\n\nYour preview deployment is ready!\n\n๐Ÿ”— **Preview URL:** ${deploymentUrl}\n\nThis preview will update automatically when you push new commits.` - }); - - cleanup-preview: - name: Cleanup PR Preview - runs-on: ubuntu-latest - if: github.event.action == 'closed' - permissions: - pull-requests: write - deployments: write - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - - name: Install Harper CLI - run: | - npm install -g harper - - - name: Remove preview deployment - env: - HARPER_PREVIEW_TARGET: ${{ secrets.HARPER_PREVIEW_TARGET }} - HARPER_PREVIEW_USERNAME: ${{ secrets.HARPER_PREVIEW_USERNAME }} - HARPER_PREVIEW_PASSWORD: ${{ secrets.HARPER_PREVIEW_PASSWORD }} - run: | - # Add your cleanup command here (e.g., harper undeploy or similar) - harper drop_component \ - target=${HARPER_PREVIEW_TARGET}:9925 \ - username=$HARPER_PREVIEW_USERNAME \ - password=$HARPER_PREVIEW_PASSWORD \ - project=pr-${{ github.event.pull_request.number }} \ - replicated=true \ - restart=true - echo "Cleaning up preview for PR #${{ github.event.pull_request.number }}" - - - name: Update deployment status - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const prNumber = context.payload.pull_request.number; - const environmentName = `pr-${prNumber}`; - - // Mark deployment as inactive - const deployments = await github.rest.repos.listDeployments({ - owner: context.repo.owner, - repo: context.repo.repo, - environment: environmentName - }); - - for (const deployment of deployments.data) { - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.id, - state: 'inactive', - description: 'Preview deployment removed' - }); - } - - // Add cleanup comment to PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `## ๐Ÿงน Preview Cleanup\n\nThe preview deployment for this PR has been removed.` - }); - - # Deleting an environment requires repository *administration* permission, - # which can never be granted to the workflow's GITHUB_TOKEN (it is not an - # available scope in the `permissions:` block). These steps mint an - # installation token for an org-owned GitHub App that has - # "Administration: read and write" on this repository. If the App isn't - # configured (no PREVIEW_CLEANUP_APP_ID variable), both steps are skipped. - - name: Generate environment-cleanup token - id: cleanup-token - if: ${{ vars.PREVIEW_CLEANUP_APP_ID != '' }} - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ vars.PREVIEW_CLEANUP_APP_ID }} - private-key: ${{ secrets.PREVIEW_CLEANUP_APP_PRIVATE_KEY }} - - - name: Delete GitHub environment - if: ${{ vars.PREVIEW_CLEANUP_APP_ID != '' }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.cleanup-token.outputs.token }} - script: | - const environmentName = `pr-${context.payload.pull_request.number}`; - - await github.rest.repos.deleteAnEnvironment({ - owner: context.repo.owner, - repo: context.repo.repo, - environment_name: environmentName - }); - console.log(`Deleted environment: ${environmentName}`); diff --git a/.github/workflows/preview-build.yaml b/.github/workflows/preview-build.yaml new file mode 100644 index 000000000..d61137472 --- /dev/null +++ b/.github/workflows/preview-build.yaml @@ -0,0 +1,83 @@ +name: Build PR Preview + +# Builds each pull request's head commit and uploads the site as the +# `pr--build` artifact. This workflow runs the PR's code, so it has a +# read-only token, no secrets and no identity token. +# +# preview.yaml waits for this run, downloads the artifact and deploys it as the +# PR's preview. `npm run preview:pr ` serves the same artifact locally. +# +# Keep `branches` and `paths` the same as in preview.yaml: its deploy job waits +# for a run of this workflow, and fails if none starts. + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + paths: + - 'reference_versioned_docs/**' + - 'reference_versioned_sidebars/**' + - 'reference_versions.json' + - 'docs/**' + - 'fabric/**' + - 'learn/**' + - 'release-notes/**' + - 'src/**' + - 'static/**' + - 'versioned_docs/**' + - 'versioned_sidebars/**' + - 'docusaurus.config.ts' + - 'package-lock.json' + - 'package.json' + - 'historic-redirects.ts' + - 'redirects.ts' + - 'sidebars*.ts' + - 'versions.json' + - 'scripts/assemble-content.mjs' + +permissions: + contents: read + +# A newer push cancels the build of the commit it replaces; that commit's +# preview run then stops waiting and deploys nothing. +concurrency: + group: preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + name: Build PR Preview + runs-on: ubuntu-latest + steps: + - name: Checkout PR code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The head commit, not the merge commit: the preview records this SHA. + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: 'package-lock.json' + + - name: Install dependencies + run: npm ci + + # The preview serves the site from its own host's root, so the default + # base URL (/) applies. + - name: Build Docusaurus site + env: + # The assembler reads HarperFast/harper-pro releases; unauthenticated calls share a per-IP limit. + GH_TOKEN: ${{ github.token }} + run: npm run build + + - name: Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-${{ github.event.pull_request.number }}-build + path: build/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml new file mode 100644 index 000000000..44e064b40 --- /dev/null +++ b/.github/workflows/preview.yaml @@ -0,0 +1,152 @@ +name: Preview + +# Deploys each pull request from this repository as its own Harper preview, at +# https://.app./, with HarperFast/deploy-action, and +# drops the preview when the PR closes. Fork PRs get no preview. +# +# GitHub runs pull_request_target from main, so a PR cannot change what runs +# with the preview identity, and nothing here checks out or runs a PR's code. +# preview-build.yaml builds the site in an unprivileged pull_request run; the +# deploy job waits for that run and deploys its artifact. +# +# The cluster trusts this file by name: `harper preview setup app=docs-site`, +# run from a checkout of main, wrote the trust policies and set the +# HARPER_PREVIEW_TARGET variable. Authentication is OIDC only. See +# https://github.com/HarperFast/deploy-action#pull-request-previews + +on: + pull_request_target: + types: [opened, reopened, synchronize, closed] + branches: [main] + # Keep the same as in preview-build.yaml. + paths: + - 'reference_versioned_docs/**' + - 'reference_versioned_sidebars/**' + - 'reference_versions.json' + - 'docs/**' + - 'fabric/**' + - 'learn/**' + - 'release-notes/**' + - 'src/**' + - 'static/**' + - 'versioned_docs/**' + - 'versioned_sidebars/**' + - 'docusaurus.config.ts' + - 'package-lock.json' + - 'package.json' + - 'historic-redirects.ts' + - 'redirects.ts' + - 'sidebars*.ts' + - 'versions.json' + - 'scripts/assemble-content.mjs' + +permissions: {} + +jobs: + deploy: + if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: preview + permissions: + id-token: write + pull-requests: write + deployments: write + # Reading preview-build.yaml's runs and downloading their artifacts. + actions: read + concurrency: + group: harper-preview-${{ github.event.pull_request.number }} + steps: + # GitHub starts both workflows at once, and the trust policy matches only + # pull_request_target runs, so this job waits for the build itself. Only a + # run from a branch of this repository counts. + - name: Wait for the build of this commit + id: build + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + BUILD_WORKFLOW: preview-build.yaml + run: | + short=${SHA:0:7} + superseded() { + local pr state head + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '.state + " " + .head.sha') || return 1 + read -r state head <<<"$pr" + [ "$state" = open ] && [ "$head" = "$SHA" ] && return 1 + echo "::notice::Not deploying $short: PR #$PR is $state at ${head:0:7}." + } + start=$SECONDS + while :; do + if superseded; then exit 0; fi + run=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$BUILD_WORKFLOW/runs?event=pull_request&head_sha=$SHA" \ + --jq '[.workflow_runs[] | select(.head_repository.full_name == env.GITHUB_REPOSITORY)] | max_by(.run_number) | select(.) | "\(.id) \(.status) \(.conclusion)"') || run= + elapsed=$((SECONDS - start)) + if [ -n "$run" ]; then + read -r id status conclusion <<<"$run" + if [ "$status" = completed ]; then + if [ "$conclusion" = success ]; then + echo "Deploying the build from $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id" + echo "run-id=$id" >>"$GITHUB_OUTPUT" + exit 0 + fi + if superseded; then exit 0; fi + echo "::error::The build of $short ended $conclusion: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id" + exit 1 + fi + elif [ "$elapsed" -ge 600 ]; then + echo "::error::No $BUILD_WORKFLOW run for $short started in 10 minutes. GitHub starts no pull_request run while a PR has merge conflicts." + exit 1 + fi + if [ "$elapsed" -ge 1800 ]; then + echo "::error::The build of $short did not finish in 30 minutes." + exit 1 + fi + sleep 20 + done + + - name: Download the build + if: steps.build.outputs.run-id != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: pr-${{ github.event.pull_request.number }}-build + path: site/build + run-id: ${{ steps.build.outputs.run-id }} + github-token: ${{ github.token }} + + # The configuration comes from this file, not from the PR: the artifact + # lands in site/build, and only the static plugin reads it. + - name: Serve the build as a Harper application + if: steps.build.outputs.run-id != '' + run: | + cat > site/config.yaml << 'EOF' + static: + files: 'build/**' + urlPath: '/' + extensions: ['html'] + index: true + EOF + + - if: steps.build.outputs.run-id != '' + uses: HarperFast/deploy-action@45fa59b6157eabb72b7a74922309238671f2a37d # v1.0.0 + with: + target: ${{ vars.HARPER_PREVIEW_TARGET }} + app: docs-site + preview: true + by_ref: false + path: site + + cleanup: + if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + environment: preview-cleanup + permissions: + id-token: write + pull-requests: write + deployments: write + steps: + - uses: HarperFast/deploy-action@45fa59b6157eabb72b7a74922309238671f2a37d # v1.0.0 + with: + target: ${{ vars.HARPER_PREVIEW_TARGET }} + app: docs-site + preview: true diff --git a/scripts/preview-pr.mjs b/scripts/preview-pr.mjs index 8e9b3f6a7..2a2abc794 100644 --- a/scripts/preview-pr.mjs +++ b/scripts/preview-pr.mjs @@ -95,10 +95,11 @@ async function main() { console.log(`\nโœ“ PR branch: ${prInfo.headRefName}`); - // Get the workflow run for this PR (using sanitized branch name) + // The newest successful build of this PR's branch (using sanitized branch name). The API lists a + // workflow's runs newest first, so one run is enough; `{}` when there is none. const runs = JSON.parse( execSync( - `gh api repos/HarperFast/documentation/actions/runs --paginate -X GET -f branch=${sanitizedBranch} --jq '.workflow_runs | map(select(.conclusion == "success" and .name == "Deploy PR Preview")) | sort_by(.created_at) | reverse | .[0]'`, + `gh api repos/HarperFast/documentation/actions/workflows/preview-build.yaml/runs -X GET -f branch=${sanitizedBranch} -f status=success -f per_page=1 --jq '.workflow_runs[0] // {}'`, { encoding: 'utf-8' } ) ); @@ -200,9 +201,10 @@ async function main() { console.log(`\n๐Ÿš€ Starting preview server...\n`); // Start the server with quoted path to prevent injection + // Previews build with the default base URL (/), so serve with it too. execSync(`npm run serve -- --dir "${BUILD_DIR}"`, { stdio: 'inherit', - env: { ...process.env, DOCUSAURUS_BASE_URL: `pr-${PR_NUMBER}` }, + env: { ...process.env, DOCUSAURUS_BASE_URL: '/' }, }); } catch (error) { console.error('\nโŒ Error:', error.message);