diff --git a/.github/workflows/node-build.yml b/.github/workflows/node-build.yml new file mode 100644 index 0000000..6f10d68 --- /dev/null +++ b/.github/workflows/node-build.yml @@ -0,0 +1,161 @@ +# Reusable "Node.js build" workflow (LEAF). +# +# Runs a package-manager-agnostic build job, with an optional upload of the +# build output as a workflow artifact. +# +# Caller pattern: +# +# jobs: +# build: +# uses: netresearch/.github/.github/workflows/node-build.yml@main +# with: +# build-cmd: "bun run build:lib" +# upload-artifact: true +# artifact-path: "dist/" +# +# SECURITY: caller-supplied commands are routed through `env:` and run via +# `bash -c "$VAR"`, so the caller can't break out of argument position via +# quoting/metacharacters. Third-party actions are SHA-pinned, harden-runner +# is enabled, and checkout uses `persist-credentials: false`. + +name: Node.js Build (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: "Runner label." + type: string + default: "ubuntu-latest" + timeout-minutes: + description: "Per-job timeout in minutes." + type: number + default: 15 + + package-manager: + description: "Package manager to use (one of: bun, npm, pnpm, yarn)." + type: string + default: "bun" + node-version: + description: "Node.js version." + type: string + default: "24" + bun-version: + description: "Bun version passed to oven-sh/setup-bun (when package-manager is bun)." + type: string + default: "latest" + + install-cmd: + description: "Dependency install command." + type: string + default: "bun install --frozen-lockfile" + build-cmd: + description: "Build command." + type: string + default: "bun run build" + pre-command: + description: "Optional shell command to run after install but before the build command." + type: string + default: "" + + upload-artifact: + description: "Upload the build output as a workflow artifact." + type: boolean + default: false + artifact-name: + description: "Artifact name used when `upload-artifact` is true." + type: string + default: "dist" + artifact-path: + description: "Path(s) uploaded when `upload-artifact` is true." + type: string + default: "dist/" + artifact-retention-days: + description: "Retention for the artifact (1-90)." + type: number + default: 7 + +# CALLER REQUIREMENTS +# =================== +# When calling this reusable workflow, the caller's job-level +# `permissions:` block MUST grant at least this full set, otherwise +# the run fails with `startup_failure` before any job executes +# (GitHub rejects the workflow at startup when the reusable declares +# a permission the caller did not grant). NOTE: setting ANY scope in a +# `permissions:` block forces every UNLISTED scope to `none`, and a job +# that omits `permissions:` inherits the repository default +# (default_workflow_permissions) — so a repo hardened to `read` will +# startup-fail here unless these scopes are granted explicitly. +# +# jobs: +# build: +# uses: netresearch/.github/.github/workflows/node-build.yml@main +# permissions: +# contents: read +permissions: {} + +jobs: + node-build: + name: build + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Validate package-manager input + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + bun|npm|pnpm|yarn) : ;; + *) + echo "::error title=Invalid package-manager::Unsupported package-manager '$PACKAGE_MANAGER'. Expected one of: bun, npm, pnpm, yarn." + exit 1 + ;; + esac + + - name: Set up Bun + if: ${{ inputs.package-manager == 'bun' }} + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ inputs.bun-version }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + - name: Pre-command + if: ${{ inputs.pre-command != '' }} + env: + PRE_COMMAND: ${{ inputs.pre-command }} + run: bash -c "$PRE_COMMAND" + + - name: Install dependencies + env: + INSTALL_CMD: ${{ inputs.install-cmd }} + run: bash -c "$INSTALL_CMD" + + - name: Build + env: + BUILD_CMD: ${{ inputs.build-cmd }} + run: bash -c "$BUILD_CMD" + + - name: Upload build artifact + if: ${{ inputs.upload-artifact }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} + if-no-files-found: error + retention-days: ${{ inputs.artifact-retention-days }} diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml new file mode 100644 index 0000000..1d4d719 --- /dev/null +++ b/.github/workflows/node-ci.yml @@ -0,0 +1,231 @@ +# Reusable "Node.js CI" gold-standard workflow (META). +# +# Composes the granular Node.js/TypeScript reusables into one CI entry +# point: TS lint/type/format checks, tests (+ optional coverage), build, +# dependency audit, CodeQL, secret scanning, and dependency review. Each +# job calls a single-purpose reusable so consumers get one call site. +# +# Caller pattern: +# +# name: CI +# on: +# push: +# branches: [main] +# pull_request: +# permissions: {} +# jobs: +# node-ci: +# uses: netresearch/.github/.github/workflows/node-ci.yml@main +# permissions: +# actions: read +# contents: read +# security-events: write +# pull-requests: write +# with: +# coverage-upload: true +# build-artifact-path: "dist/" +# secrets: +# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +# +# SECURITY: secrets are forwarded explicitly (CODECOV_TOKEN, GITLEAKS_LICENSE), +# never via `secrets: inherit`. Composed first-party reusables track `@main` +# by policy (see .github/zizmor.yml); third-party actions inside those leaves +# are SHA-pinned. + +name: Node.js CI (reusable) + +on: + workflow_call: + inputs: + package-manager: + description: "Package manager to use (one of: bun, npm, pnpm, yarn)." + type: string + default: "bun" + node-version: + description: "Node.js version (also the coverage-upload matrix cell)." + type: string + default: "24" + test-matrix: + description: >- + JSON array string of Node.js versions to matrix the test job over + (e.g. '["20","22"]'). When empty, a single-element matrix of + [node-version] is used. + type: string + default: "" + + install-cmd: + description: "Dependency install command." + type: string + default: "bun install --frozen-lockfile" + lint-cmd: + description: "Lint command." + type: string + default: "bun run lint" + type-check-cmd: + description: "Type-check command." + type: string + default: "bun run build" + format-check-cmd: + description: "Format-check command." + type: string + default: "bunx prettier --check ." + test-cmd: + description: "Test command." + type: string + default: "bun run test:coverage" + build-cmd: + description: "Build command." + type: string + default: "bun run build:lib" + pre-command: + description: "Optional shell command to run after install but before the TS checks." + type: string + default: "" + + enable-format-check: + description: "Run the Prettier format check in the TS job." + type: boolean + default: true + enable-test: + description: "Run the test job." + type: boolean + default: true + enable-build: + description: "Run the build job." + type: boolean + default: true + build-artifact-path: + description: >- + Path(s) to upload as a build artifact. When non-empty, the build + job uploads this path; when empty, no artifact is uploaded. + type: string + default: "" + + coverage-upload: + description: "Upload test coverage to Codecov (in the test job)." + type: boolean + default: false + audit-level: + description: >- + Minimum severity to fail on for both the dependency audit and the + dependency-review job (low, moderate, high, critical). + type: string + default: "high" + codeql-languages: + description: "CodeQL language(s) to analyze." + type: string + default: "javascript-typescript" + secrets: + CODECOV_TOKEN: + description: "Codecov upload token. Required for private repositories when `coverage-upload` is true." + required: false + GITLEAKS_LICENSE: + description: "gitleaks-action license key (required for org repositories). Forwarded to the gitleaks job." + required: false + +# CALLER REQUIREMENTS +# =================== +# This meta-reusable composes leaves that need SARIF-upload and PR-write +# scopes, so its caller must grant the UNION of every composed job's +# requirements. When calling this reusable workflow, the caller's job-level +# `permissions:` block MUST grant at least this full set, otherwise the run +# fails with `startup_failure` before any job executes (GitHub rejects the +# workflow at startup when a nested reusable declares a permission the +# caller did not grant). NOTE: setting ANY scope in a `permissions:` block +# forces every UNLISTED scope to `none`, and a job that omits `permissions:` +# inherits the repository default (default_workflow_permissions) — so a repo +# hardened to `read` will startup-fail here unless these scopes are granted +# explicitly. Secrets are forwarded explicitly (CODECOV_TOKEN); never use +# `secrets: inherit`. +# +# jobs: +# node-ci: +# uses: netresearch/.github/.github/workflows/node-ci.yml@main +# permissions: +# actions: read +# contents: read +# security-events: write +# pull-requests: write +permissions: {} + +jobs: + ts: + name: TS Check + uses: netresearch/.github/.github/workflows/ts-check.yml@main + permissions: + contents: read + with: + install-cmd: ${{ inputs.install-cmd }} + lint-cmd: ${{ inputs.lint-cmd }} + type-check-cmd: ${{ inputs.type-check-cmd }} + format-check-cmd: ${{ inputs.format-check-cmd }} + enable-format-check: ${{ inputs.enable-format-check }} + pre-command: ${{ inputs.pre-command }} + + test: + name: Test + if: ${{ inputs.enable-test }} + uses: netresearch/.github/.github/workflows/node-test.yml@main + permissions: + contents: read + with: + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} + test-matrix: ${{ inputs.test-matrix }} + install-cmd: ${{ inputs.install-cmd }} + test-cmd: ${{ inputs.test-cmd }} + coverage-upload: ${{ inputs.coverage-upload }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + build: + name: Build + if: ${{ inputs.enable-build }} + uses: netresearch/.github/.github/workflows/node-build.yml@main + permissions: + contents: read + with: + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} + install-cmd: ${{ inputs.install-cmd }} + build-cmd: ${{ inputs.build-cmd }} + upload-artifact: ${{ inputs.build-artifact-path != '' }} + artifact-path: ${{ inputs.build-artifact-path }} + + node-audit: + name: Node.js Audit + uses: netresearch/.github/.github/workflows/node-audit.yml@main + permissions: + contents: read + with: + package-manager: ${{ inputs.package-manager }} + audit-level: ${{ inputs.audit-level }} + + codeql: + name: CodeQL + uses: netresearch/.github/.github/workflows/codeql.yml@main + permissions: + actions: read + contents: read + security-events: write + with: + languages: ${{ inputs.codeql-languages }} + + gitleaks: + name: Secret Scanning + uses: netresearch/.github/.github/workflows/gitleaks.yml@main + permissions: + contents: read + security-events: write + secrets: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + dependency-review: + name: Dependency Review + if: ${{ github.event_name == 'pull_request' }} + uses: netresearch/.github/.github/workflows/dependency-review.yml@main + permissions: + contents: read + pull-requests: write + with: + fail-on-severity: ${{ inputs.audit-level }} diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml new file mode 100644 index 0000000..b784f5a --- /dev/null +++ b/.github/workflows/node-test.yml @@ -0,0 +1,187 @@ +# Reusable "Node.js test" workflow (LEAF). +# +# Runs a package-manager-agnostic test job, optionally over a matrix of +# Node.js versions, with an optional Codecov coverage upload. +# +# Caller pattern (single Node version, coverage upload): +# +# jobs: +# test: +# uses: netresearch/.github/.github/workflows/node-test.yml@main +# with: +# coverage-upload: true +# secrets: +# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +# +# Caller pattern (Node-version matrix): +# +# jobs: +# test: +# uses: netresearch/.github/.github/workflows/node-test.yml@main +# with: +# test-matrix: '["20","22","24"]' +# +# SECURITY: caller-supplied commands are routed through `env:` and run via +# `bash -c "$VAR"`, so the caller can't break out of argument position via +# quoting/metacharacters. Third-party actions are SHA-pinned, harden-runner +# is enabled, checkout uses `persist-credentials: false`, and the Codecov +# secret is passed explicitly (never `secrets: inherit`). + +name: Node.js Test (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: "Runner label." + type: string + default: "ubuntu-latest" + timeout-minutes: + description: "Per-job timeout in minutes." + type: number + default: 15 + + package-manager: + description: "Package manager to use (one of: bun, npm, pnpm, yarn)." + type: string + default: "bun" + node-version: + description: >- + Node.js version used when `test-matrix` is empty, and the matrix + cell that uploads coverage when `coverage-upload` is true (so this + version SHOULD be one of the `test-matrix` entries). + type: string + default: "24" + test-matrix: + description: >- + JSON array string of Node.js versions to matrix over + (e.g. '["20","22"]'). When empty, a single-element matrix of + [node-version] is used. + type: string + default: "" + bun-version: + description: "Bun version passed to oven-sh/setup-bun (when package-manager is bun)." + type: string + default: "latest" + + install-cmd: + description: "Dependency install command." + type: string + default: "bun install --frozen-lockfile" + test-cmd: + description: "Test command." + type: string + default: "bun run test:coverage" + pre-command: + description: "Optional shell command to run after install but before the test command." + type: string + default: "" + + coverage-upload: + description: >- + Upload coverage to Codecov. Runs only on the single matrix cell + whose Node.js version matches `node-version`, to avoid duplicate + uploads across the matrix. + type: boolean + default: false + coverage-file: + description: "Path to the coverage report uploaded to Codecov." + type: string + default: "./coverage/lcov.info" + coverage-flags: + description: "Codecov `flags` (comma-separated)." + type: string + default: "unittests" + secrets: + CODECOV_TOKEN: + description: "Codecov upload token. Required for private repositories when `coverage-upload` is true." + required: false + +# CALLER REQUIREMENTS +# =================== +# When calling this reusable workflow, the caller's job-level +# `permissions:` block MUST grant at least this full set, otherwise +# the run fails with `startup_failure` before any job executes +# (GitHub rejects the workflow at startup when the reusable declares +# a permission the caller did not grant). NOTE: setting ANY scope in a +# `permissions:` block forces every UNLISTED scope to `none`, and a job +# that omits `permissions:` inherits the repository default +# (default_workflow_permissions) — so a repo hardened to `read` will +# startup-fail here unless these scopes are granted explicitly. +# +# jobs: +# test: +# uses: netresearch/.github/.github/workflows/node-test.yml@main +# permissions: +# contents: read +permissions: {} + +jobs: + node-test: + name: test (node ${{ matrix.node }}) + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.test-matrix != '' && inputs.test-matrix || format('["{0}"]', inputs.node-version)) }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Validate package-manager input + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + bun|npm|pnpm|yarn) : ;; + *) + echo "::error title=Invalid package-manager::Unsupported package-manager '$PACKAGE_MANAGER'. Expected one of: bun, npm, pnpm, yarn." + exit 1 + ;; + esac + + - name: Set up Bun + if: ${{ inputs.package-manager == 'bun' }} + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ inputs.bun-version }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + + - name: Pre-command + if: ${{ inputs.pre-command != '' }} + env: + PRE_COMMAND: ${{ inputs.pre-command }} + run: bash -c "$PRE_COMMAND" + + - name: Install dependencies + env: + INSTALL_CMD: ${{ inputs.install-cmd }} + run: bash -c "$INSTALL_CMD" + + - name: Test + env: + TEST_CMD: ${{ inputs.test-cmd }} + run: bash -c "$TEST_CMD" + + - name: Upload coverage to Codecov + if: ${{ inputs.coverage-upload && matrix.node == inputs.node-version }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ inputs.coverage-file }} + flags: ${{ inputs.coverage-flags }} + fail_ci_if_error: false diff --git a/docs/reusable-workflow-permissions.md b/docs/reusable-workflow-permissions.md index 1f33fb9..fb6250b 100644 --- a/docs/reusable-workflow-permissions.md +++ b/docs/reusable-workflow-permissions.md @@ -66,7 +66,8 @@ truth; this table is the index.) | `release-composer-package.yml` | `contents: write` | | `ghcr-retention.yml` | `packages: write` | | `security-container.yml` | `contents: read`, `security-events: write`, `packages: read` | -| `lint-*.yml` / `php-ci.yml` / `python-*.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` | +| `node-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write` | +| `lint-*.yml` / `php-ci.yml` / `python-*.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `node-test.yml` / `node-build.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` | ### `netresearch/typo3-ci-workflows`