ci: refactor pipelines, harden security, add bundle size report - #1949
Conversation
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>
There was a problem hiding this comment.
🟡 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_runsplit. - 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-iduses 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.
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
There was a problem hiding this comment.
🟡 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/consumesbundle-size.base.json, so a cache hit will still leavebundle-size.base.jsonmissing 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 omitactions: write; with least-privilegepermissions: {}at workflow level, cache uploads can fail. Grantactions: writehere.
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/restoreandactions/cache/save, but its token permissions omitactions:scope. With least-privilege permissions, restores/saves can fail; grant at leastactions: write(covers read + write).
permissions:
contents: read
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 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), sosecrets.aws-role/secrets.distribution-idwill 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
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>
There was a problem hiding this comment.
🔵 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>
There was a problem hiding this comment.
🔵 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 })returnsDirent.parentPathrelative to the directory you passed in. Usingjoin(entry.parentPath, entry.name)andrelative(distDir, entry.parentPath)can therefore look up files outsidedistDir(e.g.assets/foo.jsinstead ofdist/assets/foo.js) and produce incorrect manifest keys. Resolve the recursive listing against a normalized root and computefull/relDirfrom 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>
|
Re: the suppressed comment on This is not correct. The premise is that Producing: { "sw.js": 35, "assets/index-*.js": 35, "assets/deep/x-*.css": 35 }Correct at every nesting level, so It is also disproven in production: the No change made. Separately, reviewing that same artifact did surface a real bug, now fixed in c870d4c: every
|
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>
There was a problem hiding this comment.
🟡 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
`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>
There was a problem hiding this comment.
🔵 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: bashis needed to getpipefail. In the new reusable build workflow, the final build step does not have anif:override, and GitHub Actions’ bash runner already uses-eo pipefailby 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: bashto getpipefailis inaccurate on GitHub-hosted Linux runners (bash is invoked with-eo pipefailby default). As written, this comment may confuse future edits (e.g., someone might think removingshell: bashchanges 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/checkoutis usingsparse-checkout: tools, but the next step configures Node vianode-version-file: .node-version. With this sparse checkout,.node-versionwon’t be present in the workspace, soactions/setup-nodewill 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>
Bundle size report (gzip)
119 chunks compared, 18 changed. Sizes are gzip, matching what nginx serves. |
Summary
_build.yml,_publish-docker.yml,_publish-web.yml), removing the duplicated pipeline that ran twice on every tag pushsoftprops/action-gh-release, and the stalereggionick/s3-deployaction with plain AWS CLI callspermissionsthroughout,harden-runneron every job, and SHA-pin all third-party actionsworkflow_runsplit (no privileged job ever runs PR code)docs.ymlnow also builds/lints ondeveloppushes (no deploy), and enforces thecodespell/markdownlintchecks that were documented but never run in CI