From 70a126e44fa45b15e55b1ecbadbf6f056a07199c Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Wed, 5 Aug 2026 13:12:27 +0200 Subject: [PATCH 1/3] Extract the site build into a reusable workflow test.yml and FlowFuse/flowfuse's docs.yml both carried their own copy of the same pipeline: app token, website and blueprint-library checkouts, Node 24, blueprints, install, build, hyperlink. Two copies of one build, already drifting, and the flowfuse copy threw its output away after the link check. site-preview.yml is now the single definition, called from both sides. The flowfuse caller passes its PR as docs_repository/docs_ref, which lands the checkout next to the website so docs-sync.mjs prefers it over cloning main, and sets deploy_preview so the build is uploaded to Netlify under a per-PR alias instead of being discarded. Two behaviour notes: - Previews build with `build:nuxt` rather than `build:nuxt:skip-images`, because nuxt.config.ts drops the image provider to none when SKIP_IMAGES is set. This matches netlify.toml's deploy-preview context. - The image cache key was static, so the entry was written once and never refreshed, and it pointed at the Eleventy-era `_site/img`. It is now a rolling key over the two directories netlify.toml's cache plugin keeps warm. The upload never passes --prod and never passes --build, so netlify.toml's build command (which reindexes Algolia) does not run against a preview, and a step fails the job if Netlify hands back the production URL. test_website is kept as a gate job because a reusable workflow reports its checks as "caller job / called job", which would rename the context the "protect main" ruleset requires. --- .github/workflows/site-preview.yml | 212 +++++++++++++++++++++++++++++ .github/workflows/test.yml | 62 +++------ 2 files changed, 232 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/site-preview.yml diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml new file mode 100644 index 0000000000..5bf3e3bc40 --- /dev/null +++ b/.github/workflows/site-preview.yml @@ -0,0 +1,212 @@ +name: Build site + +# The single definition of a website build. Called by this repository'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: + # Accepts either the App ID or the Client ID as the JWT issuer, so both callers + # can pass whichever of the two their own secret holds. + client-id: ${{ secrets.ci_app_id }} + private-key: ${{ secrets.ci_app_key }} + owner: ${{ github.repository_owner }} + 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 }} + - name: Cache image pipeline output + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # Rolling key. A static key is only ever written once, so the entry went stale and + # never picked up newly processed images. These are the two directories that + # netlify.toml's cache plugin keeps warm on Netlify's own builds. + key: img-pipeline-${{ github.run_id }} + 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: | + # 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 }) + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62c333b126..12d655cb99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,48 +3,26 @@ name: Tests on: push jobs: + build_site: + name: Build and check the site + uses: ./.github/workflows/site-preview.yml + with: + website_repository: ${{ github.repository }} + website_ref: ${{ github.sha }} + run_unit_tests: true + secrets: + ci_app_id: ${{ secrets.GH_CI_READONLY_APP_ID }} + ci_app_key: ${{ secrets.GH_CI_READONLY_APP_KEY }} + + # A reusable workflow reports its checks as "caller job / called job", which would rename + # the test_website context that the "protect main" ruleset requires. This gate keeps that + # name and fails whenever the build above did not succeed. test_website: + needs: build_site + if: always() runs-on: ubuntu-latest steps: - - name: Generate a token - id: generate_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.GH_CI_READONLY_APP_ID }} - private-key: ${{ secrets.GH_CI_READONLY_APP_KEY }} - owner: ${{ github.repository_owner }} - repositories: | - website - blueprint-library - - name: Check out website repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - path: 'website' - token: ${{ steps.generate_token.outputs.token }} - - 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 }} - - 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 - run: npm test - working-directory: 'website' - - name: Build the forge - run: npm run 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: Require the site build to have succeeded + run: | + echo "build_site result: ${{ needs.build_site.result }}" + [ "${{ needs.build_site.result }}" = "success" ] From 9df633fd1a33ffa96a120361bed17bf5fe09cdba Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Wed, 5 Aug 2026 13:13:44 +0200 Subject: [PATCH 2/3] Fail the preview upload with a clear message when Netlify secrets are absent A caller that sets deploy_preview without passing the token or site id would otherwise get an opaque netlify-cli error. --- .github/workflows/site-preview.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 5bf3e3bc40..6acecf4c8e 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -139,6 +139,10 @@ jobs: 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. From 3a87e281b5ec1375a76d2aebbc01e8f2b3181061 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 10 Aug 2026 16:16:19 +0200 Subject: [PATCH 3/3] Call the shared website build workflow The build now lives in FlowFuse/github-actions-workflows, so this repository and FlowFuse/flowfuse documentation pull requests share one definition. Review feedback also drops the separate gate job: the calling job is named test_website, so the required context becomes test_website / Build and check and the ruleset moves to that name. --- .github/workflows/site-preview.yml | 216 ----------------------------- .github/workflows/test.yml | 21 +-- 2 files changed, 5 insertions(+), 232 deletions(-) delete mode 100644 .github/workflows/site-preview.yml diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml deleted file mode 100644 index 6acecf4c8e..0000000000 --- a/.github/workflows/site-preview.yml +++ /dev/null @@ -1,216 +0,0 @@ -name: Build site - -# The single definition of a website build. Called by this repository'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: - # Accepts either the App ID or the Client ID as the JWT issuer, so both callers - # can pass whichever of the two their own secret holds. - client-id: ${{ secrets.ci_app_id }} - private-key: ${{ secrets.ci_app_key }} - owner: ${{ github.repository_owner }} - 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 }} - - name: Cache image pipeline output - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # Rolling key. A static key is only ever written once, so the entry went stale and - # never picked up newly processed images. These are the two directories that - # netlify.toml's cache plugin keeps warm on Netlify's own builds. - key: img-pipeline-${{ github.run_id }} - 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 }) - } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12d655cb99..0ba2c0e2e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,9 +3,11 @@ name: Tests on: push jobs: - build_site: - name: Build and check the site - uses: ./.github/workflows/site-preview.yml + # The build itself lives in FlowFuse/github-actions-workflows so this repository and + # FlowFuse/flowfuse documentation pull requests share one definition. The check this + # reports is "test_website / Build and check", which the "protect main" ruleset requires. + test_website: + uses: FlowFuse/github-actions-workflows/.github/workflows/build_website.yml@add-website-build-workflow with: website_repository: ${{ github.repository }} website_ref: ${{ github.sha }} @@ -13,16 +15,3 @@ jobs: secrets: ci_app_id: ${{ secrets.GH_CI_READONLY_APP_ID }} ci_app_key: ${{ secrets.GH_CI_READONLY_APP_KEY }} - - # A reusable workflow reports its checks as "caller job / called job", which would rename - # the test_website context that the "protect main" ruleset requires. This gate keeps that - # name and fails whenever the build above did not succeed. - test_website: - needs: build_site - if: always() - runs-on: ubuntu-latest - steps: - - name: Require the site build to have succeeded - run: | - echo "build_site result: ${{ needs.build_site.result }}" - [ "${{ needs.build_site.result }}" = "success" ]