diff --git a/.github/pinact.yml b/.github/pinact.yml new file mode 100644 index 0000000..d128fe0 --- /dev/null +++ b/.github/pinact.yml @@ -0,0 +1,11 @@ +# pinact (https://github.com/suzuki-shunsuke/pinact) pins third-party actions to +# a commit SHA and updates them: `pinact run --update`. See CONTRIBUTING.md. +version: 3 +# Skip versions published in the last week, like `pnpm dep:check`. +min_age: + value: 7 +rules: + # GitHub's own actions stay on version tags (see .github/zizmor.yml). + - ignore: true + conditions: + - expr: ActionRepoOwner in ["actions", "github"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09c12c0..dd501ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,27 @@ on: permissions: {} jobs: + # Security audit of the workflows (.github/zizmor.yml), including that + # third-party actions are pinned to a commit SHA. Same config as + # `pnpm lint:gha`. + lint-gha: + name: Lint GitHub Actions + runs-on: ubuntu-latest + permissions: + contents: read # to check out repo (actions/checkout) + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 + with: + version: 1.31.1 + # The default, set explicitly: online audits include impostor-commit, + # which checks that each SHA pin is a commit in the action's own repo. + online-audits: true + advanced-security: false + annotations: true + test: name: Test on ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -96,9 +117,11 @@ jobs: cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - # Changes the working tree only; nothing is committed. + # Changes the working tree only; nothing is committed. Outside the + # lockfile on purpose, but still under `minimumReleaseAge`, and the job + # has no secrets and read-only permissions. - name: Install React 18.0.0 and Preact 10 - run: | + run: | # zizmor: ignore[adhoc-packages] pnpm add --save-dev react@18.0.0 react-dom@18.0.0 preact@^10 node -e 'for (const [p, v] of [["react", "18.0.0"], ["react-dom", "18.0.0"], ["preact", "10."]]) { const got = require(`${p}/package.json`).version; console.log(`${p} ${got}`); if (!got.startsWith(v)) throw new Error(`${p} is ${got}, expected ${v}`); }' - name: Test diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..5eca042 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,12 @@ +# zizmor (https://docs.zizmor.sh) audits GitHub Actions workflows: `pnpm lint:gha` +# locally, and the lint job in ci.yml. +rules: + unpinned-uses: + config: + # GitHub's own actions use version tags. Third-party actions are pinned + # to a commit SHA, with the version in a trailing comment. Refresh them + # with pinact (see CONTRIBUTING.md). + policies: + actions/*: ref-pin + github/*: ref-pin + '*': hash-pin diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2b8343..c9857e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,33 @@ $ pnpm run lint:fix `index.js` stays ES5 in 3.x, so don't apply ES2015+ fixes (`let`/`const`, arrow functions) to it. +### GitHub Actions + +Workflows reference GitHub's own actions (`actions/*`, `github/*`) by major +version tag, such as `actions/checkout@v7`. Third-party actions are pinned to a +commit SHA with the version in a comment, such as +`pnpm/action-setup@ # v6.1.0`, because a tag can be moved to different +code. [zizmor](https://docs.zizmor.sh) enforces this (`.github/zizmor.yml`) +along with its other security checks. + +CI runs zizmor in the `lint-gha` job, at the `version` pinned there. +`pnpm lint:gha` (part of `pnpm check`) runs it locally if it's installed, and +skips otherwise. Newer zizmor releases add audits, so a different version +locally can report findings that CI doesn't. To match CI, install that version, +e.g. `uv tool install zizmor==1.31.1` (or `brew install zizmor` for the +latest). Set `GH_TOKEN` to include zizmor's online audits. + +To update actions: + +- Third-party actions: run `pinact run --update` + ([pinact](https://github.com/suzuki-shunsuke/pinact), `brew install pinact`). + It updates SHAs and their version comments, skipping versions published in + the last week (`.github/pinact.yml`). Review each action's release notes. +- GitHub's actions: check each one's releases page for a new major and read its + breaking changes. +- zizmor itself: bump `version` on the zizmor step in + `.github/workflows/ci.yml`. + ### Published package `pnpm pack:check` (part of `pnpm check`) packs the package and checks what it publishes: diff --git a/package.json b/package.json index 4bc0845..19412df 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "main": "index.js", "typings": "index.d.ts", "scripts": { - "check": "pnpm format && pnpm lint && pnpm types:check && pnpm test && pnpm pack:check", + "check": "pnpm format && pnpm lint && pnpm lint:gha && pnpm types:check && pnpm test && pnpm pack:check", "benchmark": "node benchmark", "format": "oxfmt --check", "format:fix": "oxfmt", "lint": "oxlint --report-unused-disable-directives-severity=error", "lint:fix": "oxlint --report-unused-disable-directives-severity=error --fix", + "lint:gha": "node scripts/lint-gha.mjs", "types:check": "node scripts/types-check.mjs", "test": "vitest run", "changeset": "changeset", diff --git a/scripts/lint-gha.mjs b/scripts/lint-gha.mjs new file mode 100644 index 0000000..61e901f --- /dev/null +++ b/scripts/lint-gha.mjs @@ -0,0 +1,15 @@ +// Runs zizmor (https://docs.zizmor.sh) on .github/ with .github/zizmor.yml. +// zizmor isn't a Node tool, so this skips with a hint if it isn't installed; +// CI runs it in the lint-gha job (ci.yml). +import { spawnSync } from 'node:child_process'; + +const result = spawnSync('zizmor', ['.'], { stdio: 'inherit' }); + +if (result.error?.code === 'ENOENT') { + process.stdout.write( + "zizmor is not installed, skipping (CI runs it). To match CI's version, see CONTRIBUTING.md.\n" + ); + process.exit(0); +} +if (result.error) throw result.error; +process.exit(result.status ?? 1);