From 2d721866c55e6ff4b3f79c89c8bd0b402d297e6b Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 10 Aug 2026 16:11:35 +0200 Subject: [PATCH] Add a reusable website build workflow Moves the FlowFuse/website build out of that repository's test.yml so the website's own CI and FlowFuse/flowfuse documentation pull requests share one definition. Adds an optional Netlify upload, which is what gives documentation pull requests a preview URL. --- .github/workflows/build_website.yml | 219 ++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 .github/workflows/build_website.yml diff --git a/.github/workflows/build_website.yml b/.github/workflows/build_website.yml new file mode 100644 index 0000000..8841b75 --- /dev/null +++ b/.github/workflows/build_website.yml @@ -0,0 +1,219 @@ +name: Build FlowFuse website + +# The single definition of a website build. Called by FlowFuse/website's own CI and by +# FlowFuse/flowfuse documentation pull requests, so a change to the build, the link +# check or the preview upload is picked up by both callers without being copied. +on: + workflow_call: + inputs: + website_repository: + description: 'Repository holding the website source.' + type: string + default: 'FlowFuse/website' + website_ref: + description: 'Ref of the website source to build.' + type: string + default: 'main' + docs_repository: + description: 'Repository providing the docs/ tree. Empty lets docs-sync clone FlowFuse/flowfuse main itself.' + type: string + default: '' + docs_ref: + description: 'Ref or commit to check out from docs_repository.' + type: string + default: '' + run_unit_tests: + description: 'Run the website unit tests.' + type: boolean + default: false + deploy_preview: + description: 'Build with images and upload the result to Netlify as an aliased, unpublished deploy.' + type: boolean + default: false + preview_alias: + description: 'Netlify deploy alias. Required when deploy_preview is true.' + type: string + default: '' + pr_number: + description: 'Pull request to comment on with the preview URL. 0 disables the comment.' + type: number + default: 0 + secrets: + ci_app_id: + description: 'GitHub App id able to read the private blueprint-library repository.' + required: true + ci_app_key: + description: 'Private key for ci_app_id.' + required: true + netlify_auth_token: + description: 'Required when deploy_preview is true.' + required: false + netlify_site_id: + description: 'Required when deploy_preview is true.' + required: false + outputs: + preview_url: + description: 'URL of the uploaded preview. Empty when deploy_preview is false.' + value: ${{ jobs.build.outputs.preview_url }} + +jobs: + build: + name: Build and check + runs-on: ubuntu-latest + outputs: + preview_url: ${{ steps.deploy.outputs.preview_url }} + steps: + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.ci_app_id }} + private-key: ${{ secrets.ci_app_key }} + owner: ${{ github.repository_owner }} + # Only the blueprint-library checkout below consumes this token. The website and + # documentation checkouts are public and use the caller's default GITHUB_TOKEN. + repositories: blueprint-library + - name: Check out the website repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.website_repository }} + ref: ${{ inputs.website_ref }} + path: 'website' + # nuxt/lib/docs-sync.mjs prefers a checkout sitting next to the website over cloning + # main, so this is what makes the build render the caller's docs rather than main's. + - name: Check out the documentation source + if: inputs.docs_repository != '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.docs_repository }} + ref: ${{ inputs.docs_ref }} + path: 'flowfuse' + - name: Check out FlowFuse/blueprint-library repository (to access the blueprints) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: 'FlowFuse/blueprint-library' + ref: main + path: 'blueprint-library' + token: ${{ steps.generate_token.outputs.token }} + # Only a preview processes images. Every other build sets SKIP_IMAGES, which drops + # nuxt.config.ts's image provider to none, so there is nothing to cache or restore. + - name: Cache image pipeline output + if: inputs.deploy_preview + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Keyed on the source images, so a run that changes no image hits exactly and + # writes nothing back. These are the two directories netlify.toml's cache plugin + # keeps warm on Netlify's own builds. + key: img-pipeline-${{ hashFiles('website/src/**/*.png', 'website/src/**/*.jpg', 'website/src/**/*.jpeg', 'website/src/**/*.gif', 'website/src/**/*.webp', 'website/src/**/*.svg') }} + restore-keys: img-pipeline- + path: | + website/nuxt/public/img + website/.cache/images + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: './website/package-lock.json' + - run: npm run blueprints + working-directory: 'website' + - name: Install Dependencies + run: npm install + working-directory: 'website' + - name: Run unit tests + if: inputs.run_unit_tests + run: npm test + working-directory: 'website' + # A preview needs real images, and nuxt.config.ts drops the image provider to none + # when SKIP_IMAGES is set. This matches netlify.toml's deploy-preview context. + - name: Build the forge + run: npm run ${{ inputs.deploy_preview && 'build:nuxt' || 'build:nuxt:skip-images' }} + working-directory: 'website' + - name: Check links + uses: untitaker/hyperlink@9375bc4063712ad490d5eb3d54df0b6aade15e54 # 0.3.2 + with: + args: website/nuxt/dist/ --check-anchors --sources website/src + - name: Upload an unpublished preview to Netlify + id: deploy + if: inputs.deploy_preview + working-directory: 'website' + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.netlify_auth_token }} + NETLIFY_SITE_ID: ${{ secrets.netlify_site_id }} + NETLIFY_TELEMETRY_DISABLED: '1' + PREVIEW_ALIAS: ${{ inputs.preview_alias }} + run: | + if [ -z "${NETLIFY_AUTH_TOKEN}" ] || [ -z "${NETLIFY_SITE_ID}" ]; then + echo "deploy_preview is set but netlify_auth_token or netlify_site_id was not passed." >&2 + exit 1 + fi + # Deliberately no --prod and no --build. netlify.toml supplies the publish and + # functions directories, and skipping the build means its build command, which + # reindexes Algolia, never runs against a preview. + npx --yes netlify-cli@27.1.0 deploy \ + --alias "${PREVIEW_ALIAS}" \ + --message "Preview ${PREVIEW_ALIAS}. Do not publish: built outside the production context." \ + --json > deploy.json + preview_url="$(jq -r '.deploy_url // empty' deploy.json)" + production_url="$(jq -r '.url // empty' deploy.json)" + if [ -z "${preview_url}" ] || [ "${preview_url}" = "${production_url}" ]; then + echo "Netlify reported the production URL; refusing to publish it as a preview." >&2 + exit 1 + fi + echo "preview_url=${preview_url}" >> "$GITHUB_OUTPUT" + - name: Record the preview as a deployment + if: inputs.deploy_preview + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} + with: + script: | + const { owner, repo } = context.repo + const deployment = await github.rest.repos.createDeployment({ + owner, + repo, + ref: context.payload.pull_request?.head.sha || context.sha, + environment: 'Preview', + description: 'Website preview of this pull request', + auto_merge: false, + required_contexts: [], + transient_environment: true, + production_environment: false + }) + await github.rest.repos.createDeploymentStatus({ + owner, + repo, + deployment_id: deployment.data.id, + state: 'success', + environment_url: process.env.PREVIEW_URL, + description: 'Uploaded to Netlify', + auto_inactive: true + }) + - name: Comment the preview URL on the pull request + if: inputs.deploy_preview && inputs.pr_number > 0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} + PR_NUMBER: ${{ inputs.pr_number }} + WEBSITE_SOURCE: ${{ inputs.website_repository }}@${{ inputs.website_ref }} + with: + script: | + // One comment per pull request, rewritten in place, so a busy branch does not + // accumulate a comment per push. + const marker = '' + const sha = context.payload.pull_request?.head.sha || context.sha + const body = [ + marker, + `Website preview: ${process.env.PREVIEW_URL}`, + '', + `Built from ${sha} against ${process.env.WEBSITE_SOURCE}.`, + 'This is a preview build, so scheduled blog posts are visible and the deploy is never published.' + ].join('\n') + const { owner, repo } = context.repo + const issue_number = Number(process.env.PR_NUMBER) + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }) + const previous = comments.find(comment => comment.body?.includes(marker)) + if (previous) { + await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body }) + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }) + }