Skip to content

ci: refactor pipelines, harden security, add bundle size report - #1949

Merged
pedrolamas merged 11 commits into
fluidd-core:developfrom
pedrolamas:pedrolamas/ci-pipeline-refactor2
Aug 29, 2026
Merged

ci: refactor pipelines, harden security, add bundle size report#1949
pedrolamas merged 11 commits into
fluidd-core:developfrom
pedrolamas:pedrolamas/ci-pipeline-refactor2

Conversation

@pedrolamas

Copy link
Copy Markdown
Member

Summary

  • Extract shared build/publish logic into reusable workflows (_build.yml, _publish-docker.yml, _publish-web.yml), removing the duplicated pipeline that ran twice on every tag push
  • Replace the release path's three archived/unmaintained actions with softprops/action-gh-release, and the stale reggionick/s3-deploy action with plain AWS CLI calls
  • Add least-privilege permissions throughout, harden-runner on every job, and SHA-pin all third-party actions
  • Add a PR bundle-size report: gzip size diff vs. the true merge-base (cached per-baseline), posted as a sticky comment via a fork-safe workflow_run split (no privileged job ever runs PR code)
  • docs.yml now also builds/lints on develop pushes (no deploy), and enforces the codespell/markdownlint checks that were documented but never run in CI
  • Consistent Title Case naming across all workflows

Extract shared build/publish steps into reusable workflows, replace
archived release actions, least-privilege permissions everywhere,
SHA-pin third-party actions, and report gzip bundle size diffs on PRs.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Simplify the bundle size report to the size diff only.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
@pedrolamas pedrolamas added this to the 1.37.5 milestone Aug 29, 2026
@pedrolamas
pedrolamas requested a lite review from Copilot August 29, 2026 17:51
@pedrolamas pedrolamas added the Code - Infrastructure Code related to CICD, Pipelines, Testing, and Deployment label Aug 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two workflow issues will break CI execution (invalid shell quoting in merge-base step and invalid hyphenated secret access in expressions).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Refactors the repo’s GitHub Actions CI/CD to reduce duplication via reusable workflows, tighten job permissions/security, and add an automated PR bundle-size (gzip) report generated from build artifacts.

Changes:

  • Introduces reusable workflows for build, Docker publish, and web deploy; updates existing pipelines to call them.
  • Adds a bundle-size manifest generator/comparator and new workflows to publish a sticky PR comment via a workflow_run split.
  • Hardens workflows with least-privilege permissions, runner hardening, timeouts, and updated/pinned third-party actions.
File summaries
File Description
tools/bundle-size.mjs Adds a dependency-free CLI to generate/compare gzip bundle-size manifests for CI reporting.
.github/workflows/semantic_pull_request.yml Hardens and SHA-pins the semantic PR title check workflow.
.github/workflows/release.yml Refactors release flow to reuse build/publish workflows and modernizes GitHub release creation.
.github/workflows/pr-comment.yml Posts the bundle-size report as a sticky PR comment from a fork-safe workflow_run.
.github/workflows/docs.yml Expands docs CI to build/lint on develop and enforces documented lint checks.
.github/workflows/build.yml Refactors build pipeline to reusable workflows and adds bundle-size baseline/report jobs.
.github/workflows/_publish-web.yml Adds reusable S3/CloudFront deploy workflow (AWS CLI) with OIDC auth.
.github/workflows/_publish-docker.yml Adds reusable Docker publish workflow (multi-arch build + provenance/attestations).
.github/workflows/_build.yml Adds reusable build workflow (lint/typecheck/tests/build + dist artifact upload).
Review details

Suppressed comments (1)

.github/workflows/_publish-web.yml:55

  • secrets.distribution-id uses dot-property access with a hyphenated key, which GitHub Expressions treat as invalid. Use bracket notation for hyphenated secret names.
        env:
          BUCKET: ${{ secrets.bucket }}
          DIST_ID: ${{ secrets.distribution-id }}
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/build.yml Outdated
Comment thread .github/workflows/_publish-web.yml
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The bundle-size tooling/workflow has confirmed breakages (invalid parentPath usage, cache filename mismatch, and missing cache permissions) that will cause CI failures.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

Previously missed (3) — in code that hasn't changed since the last review.

.github/workflows/build.yml:80

  • The baseline cache saves bundle-size.json, but the PR job restores/consumes bundle-size.base.json, so a cache hit will still leave bundle-size.base.json missing and the compare step will fail. Use a consistent filename for generate + cache + restore.
      - name: Generate bundle size manifest
        run: node tools/bundle-size.mjs generate dist bundle-size.json

.github/workflows/build.yml:59

  • This job uses actions/cache/save, but its token permissions omit actions: write; with least-privilege permissions: {} at workflow level, cache uploads can fail. Grant actions: write here.

This issue also appears on line 93 of the same file.

    permissions:
      contents: read

.github/workflows/_publish-docker.yml:50

  • PR description says all third-party actions are SHA-pinned, but the Docker actions here are still referenced by mutable tags (e.g. @v6, @v4, @v7). Pin these to specific commit SHAs (like the other third-party actions in this PR) to match the stated hardening goal.
      - name: Prepare Docker image metadata
        id: docker_meta
        uses: docker/metadata-action@v6
        with:

.github/workflows/build.yml:94

  • This job uses actions/cache/restore and actions/cache/save, but its token permissions omit actions: scope. With least-privilege permissions, restores/saves can fail; grant at least actions: write (covers read + write).
    permissions:
      contents: read
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/bundle-size.mjs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are CI-breaking issues (hyphenated reusable-workflow secret access, and bundle baseline cache/filename mismatch) plus a few security-hardening gaps that should be addressed before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

.github/workflows/_publish-web.yml:51

  • Reusable workflow secret names include hyphens (aws-role, distribution-id), so secrets.aws-role / secrets.distribution-id will be parsed as an expression with subtraction and fail. Use bracket notation for these secret keys.
      - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
        with:
          aws-region: us-east-1
          role-to-assume: ${{ secrets.aws-role }}

  • Files reviewed: 9/9 changed files
  • Comments generated: 6
  • Review effort level: Lite

Comment thread tools/bundle-size.mjs
Comment thread tools/bundle-size.mjs Outdated
Comment thread tools/bundle-size.mjs Outdated
Comment thread .github/workflows/build.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/_publish-docker.yml
Baseline/PR cache filenames now match so the merge-base cache is
actually reachable; publish-docker/release/publish-web now require a
v* tag ref, since workflow_dispatch had no guard and could otherwise
deploy from any branch; bundle-size.mjs gets a stable sort tie-breaker
and POSIX-normalized manifest keys.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Drop the unused raw field from bundle-size.mjs manifests, remove a
redundant git fetch now that fetch-depth: 0 already covers it, move
release.yml's triple-duplicated tag-ref guard onto the build job so
it cascades to its dependents, and extract a setup-pnpm-node
composite action shared by _build.yml and the bundle-size job.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new Docker publish reusable workflow still uses several third-party docker/* actions via mutable tags rather than SHA pins, which conflicts with the stated supply-chain hardening intent.

Review details

Suppressed comments (1)

.github/workflows/_publish-docker.yml:75

  • This reusable workflow still uses several third-party Docker actions by mutable tags (e.g. docker/*@v4/@v6/@v7). That contradicts the PR description’s “SHA-pin all third-party actions” and leaves a supply-chain gap if an upstream tag is moved. Pin these Docker actions to immutable commit SHAs (as you already do for harden-runner / changelog-reader / sticky-comment).
      - name: Prepare Docker image metadata
        id: docker_meta
        uses: docker/metadata-action@v6
        with:
          images: |
            ghcr.io/${{ matrix.image-name }}
          tags: |
            type=semver,pattern={{raw}}
            type=sha,format=long
            type=raw,value=latest-develop,enable=${{ github.ref == 'refs/heads/develop' }}
            type=raw,value=latest-master,enable=${{ github.ref == 'refs/heads/master' }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to the Container registry
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        id: docker_push
        uses: docker/build-push-action@v7
        with:
  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

tools/bundle-size.mjs currently builds file paths from Dirent.parentPath in a way that can resolve outside the provided dist root, breaking manifest generation in CI.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tools/bundle-size.mjs:35

  • fs.readdirSync(..., { recursive: true, withFileTypes: true }) returns Dirent.parentPath relative to the directory you passed in. Using join(entry.parentPath, entry.name) and relative(distDir, entry.parentPath) can therefore look up files outside distDir (e.g. assets/foo.js instead of dist/assets/foo.js) and produce incorrect manifest keys. Resolve the recursive listing against a normalized root and compute full/relDir from that root.
function walk (distDir) {
  const entries = {}

  for (const entry of readdirSync(distDir, { recursive: true, withFileTypes: true })) {
    if (entry.isDirectory() || !/\.(?:js|css)$/.test(entry.name)) continue

    const full = join(entry.parentPath, entry.name)
    const gzip = gzipSync(readFileSync(full), { level: 9 }).length
    const relDir = relative(distDir, entry.parentPath).split(sep).join('/')
    const key = (relDir ? `${relDir}/` : '') + stripHash(entry.name)
  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The manifest format changed in cda7c15 from `{ raw, gzip }` objects to
plain byte counts, but the baseline cache key is just the base SHA, so
runs kept restoring a legacy-format manifest. `compare` then subtracted
an object from a number, publishing a table of `NaN B` cells on a green
check (run 33270230780).

- Include hashFiles('tools/bundle-size.mjs') in all three cache keys, so
  any future manifest format change invalidates every baseline. A PR that
  edits the tool now also misses, ensuring both sides are measured by the
  same code.
- Validate manifests in `compare` and exit non-zero on a non-finite entry
  or a missing `entries` object, rather than emitting NaN.
- Set `shell: bash` on the compare step for pipefail; the implicit default
  is `bash -e` without it, so a failing `compare` was masked by `tee`.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
@pedrolamas

Copy link
Copy Markdown
Member Author

Re: the suppressed comment on tools/bundle-size.mjs:35Dirent.parentPath "can resolve outside distDir".

This is not correct. The premise is that parentPath is relative to the directory passed in; it actually includes it. With a relative distDir, exactly as CI invokes it:

$ node tools/bundle-size.mjs generate dist m.json
parentPath=dist              name=sw.js
parentPath=dist/assets       name=index-AbCdEfGh.js
parentPath=dist/assets/deep  name=x-12345678.css

Producing:

{ "sw.js": 35, "assets/index-*.js": 35, "assets/deep/x-*.css": 35 }

Correct at every nesting level, so join(entry.parentPath, entry.name) and relative(distDir, entry.parentPath) both behave as intended.

It is also disproven in production: the bundle-size-report artifact from run 33270230780 contains 119 chunks, all correctly prefixed (assets/…, with sw.js at the root). Had parentPath behaved as described, readFileSync would have thrown ENOENT and the job would have failed rather than passing.

No change made.


Separately, reviewing that same artifact did surface a real bug, now fixed in c870d4c: every Base cell in the report read NaN B on a green check. The manifest format changed in cda7c15 ({ raw, gzip } → plain byte counts) but the baseline cache key was just the base SHA, so runs kept restoring a legacy-format manifest.

  • All three cache keys now include hashFiles('tools/bundle-size.mjs'), so any future format change invalidates every baseline automatically.
  • compare now validates manifests and exits non-zero instead of emitting NaN.
  • The compare step sets shell: bash for pipefail — the implicit default is bash -e without it, so a failing compare was being masked by tee.

Three facts went stale with the pipeline refactor:

- the Docker `BASE_IMAGE` matrix moved to `_publish-docker.yml`
- docs.yml now builds and lints on PRs and on both `master` and
  `develop`, uploading the Pages artifact from both, but only `master`
  runs the deploy job
- the CI check sequence carries `if: ${{ !cancelled() }}` after lint

Also record the reusable-workflow structure (including that a calling
job's `permissions:` is a ceiling on the called workflow's token, and
that secret values cannot cross `workflow_call` via `with:`), and the
bundle-size cache-key/manifest-format coupling plus the `pipefail`
requirement behind c870d4c.

CONTRIBUTING.md gains the contributor-facing half: checks no longer
stop at the first failure, and each PR gets a bundle-size comment.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new bundle-size manifest reader accepts arrays as valid entries, so malformed manifests can slip through validation and generate misleading reports instead of failing fast.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/bundle-size.mjs
`typeof [] === 'object'`, so an array passed the `readManifest` guard
and was then walked with `Object.entries`, yielding index keys. A base
manifest of `{"entries":[100,200]}` produced a report with chunks named
`0` and `1` and still exited 0 — the fabricated-report-on-a-green-check
failure that guard exists to prevent.

Add `Array.isArray`, which completes the check for every shape
`JSON.parse` can return: null, arrays, scalars and a missing key are
all rejected now. Reword the message, since "missing" was wrong for an
array or a scalar.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new bundle-baseline job uses a sparse checkout that omits .node-version but then references node-version-file: .node-version, which will break the workflow run.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

AGENTS.md:356

  • These bullets claim (1) “everything after lint” carries if: ${{ !cancelled() }} and (2) shell: bash is needed to get pipefail. In the new reusable build workflow, the final build step does not have an if: override, and GitHub Actions’ bash runner already uses -eo pipefail by default, so the wording here is misleading.
- **CI pipeline order**: `pnpm i --frozen-lockfile` → `lint --no-fix` → `type-check` → `test:unit` → `circular-check` → `build`. Everything after lint carries `if: ${{ !cancelled() }}`, so a lint failure no longer hides type, test or circular errors — one run reports all of them
- **Reusable workflows**: the build and both publish paths live in `_build.yml`, `_publish-docker.yml` and `_publish-web.yml` (`workflow_call`), called by `build.yml` (PRs + `develop`/`master` pushes) and `release.yml` (`v*` tags, which `build.yml` no longer triggers on). A calling job's `permissions:` is a **ceiling** on the called workflow's token, so every calling job needs its own explicit block — a top-level `permissions: {}` alone starves it. Secret *values* can't cross `workflow_call` via `with:`; `_publish-web.yml` takes them through `on.workflow_call.secrets`
- **PR bundle-size report**: `tools/bundle-size.mjs` (zero-dependency) emits a gzip-size manifest per build and diffs the PR against its merge-base, posted as a sticky comment by `pr-comment.yml`. That second workflow exists because the report has to build PR code, so it can't hold a write token — it's `workflow_run`-triggered, reads only an artifact, and takes the PR number from `pr-number.txt` since `workflow_run.pull_requests[]` is empty for fork PRs. `workflows: ['Build']` must match `build.yml`'s `name:` exactly, and a `workflow_run` trigger only fires once the file exists on the default branch
- **Bundle-size baselines are cached by `bundle-size-<hash of tools/bundle-size.mjs>-<sha>`** — the tool's own hash is in the key because the manifest format is not versioned, so without it a format change silently poisons every cached baseline (they're keyed by a base SHA that never changes) and `compare` publishes a table of `NaN B` on a green check. `compare` also rejects non-finite entries for the same reason. The compare step sets `shell: bash` for `pipefail`; the implicit default is `bash -e` without it, so a failure would be masked by `tee`

.github/workflows/build.yml:161

  • The note about needing shell: bash to get pipefail is inaccurate on GitHub-hosted Linux runners (bash is invoked with -eo pipefail by default). As written, this comment may confuse future edits (e.g., someone might think removing shell: bash changes failure behavior).
      - name: Compare bundle sizes
        # `shell: bash` for pipefail; the implicit default is `bash -e` without it,
        # so a failing `compare` would be masked by `tee`.
        shell: bash

.github/workflows/build.yml:70

  • actions/checkout is using sparse-checkout: tools, but the next step configures Node via node-version-file: .node-version. With this sparse checkout, .node-version won’t be present in the workspace, so actions/setup-node will fail to resolve the Node version.
      - name: Checkout
        uses: actions/checkout@v7
        with:
          persist-credentials: false
          sparse-checkout: tools
  • Files reviewed: 12/12 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The note claimed everything after lint carries `if: ${{ !cancelled() }}`.
Only the three checks do — `build` and the artifact upload deliberately
skip once anything above them has failed. Name the three steps and record
why the last two differ, so the inconsistency does not read as an
oversight worth "fixing".

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
@pedrolamas
pedrolamas merged commit 3341cd1 into fluidd-core:develop Aug 29, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown

Bundle size report (gzip)

Chunk Base Head Δ
assets/GcodePreview-*.js 365 B 369 B +4 B
assets/Dashboard-*.js 63.2 kB 63.2 kB +3 B
assets/JobQueueCard-*.js 4.5 kB 4.5 kB +3 B
assets/System-*.js 2.3 kB 2.3 kB +3 B
assets/AppNamedSlider-*.js 1.7 kB 1.7 kB +2 B
assets/Configure-*.js 775 B 777 B +2 B
assets/Console-*.js 367 B 369 B +2 B
assets/Diagnostics-*.js 16.0 kB 16.0 kB +2 B
assets/GcodePreviewCard-*.js 13.2 kB 13.2 kB -2 B
assets/MacroCategorySettings-*.js 2.0 kB 2.0 kB -2 B
assets/Settings-*.js 23.3 kB 23.3 kB +2 B
assets/Timelapse-*.js 1.9 kB 1.9 kB -2 B
assets/AppBtnCollapseGroup-*.js 640 B 639 B -1 B
assets/FileSystem-*.js 31.2 kB 31.2 kB -1 B
assets/Icons-*.js 528 B 527 B -1 B
assets/JobHistoryItemStatus-*.js 1.2 kB 1.2 kB +1 B
assets/Jobs-*.js 501 B 500 B -1 B
sw.js 11.2 kB 11.2 kB -1 B
Total 2.9 MB 2.9 MB +13 B

119 chunks compared, 18 changed. Sizes are gzip, matching what nginx serves.

@pedrolamas
pedrolamas deleted the pedrolamas/ci-pipeline-refactor2 branch August 29, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Code - Infrastructure Code related to CICD, Pipelines, Testing, and Deployment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants