diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ea0a422 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: docker + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e7d5ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: ci + +# The checks a merge waits on. `main`'s ruleset requires `test` and `scan`, and +# notify-uic-deploy.yml deploys a commit only after this workflow passed on it. +# The nightly run re-scans `main` for advisories published since it merged. +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # By event too, so the nightly and a push to `main` cannot cancel each other's queued run. + group: ci-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + # No error tolerance here, unlike code-review.yml: without poppler the PDF tests + # skip themselves and report as passes. + - run: sudo apt-get update && sudo apt-get install -y poppler-utils + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: ./test/e2e.sh + # Same pinned version and checksum as code-review.yml; bump both together. + # Unlike there, a failed download fails the check: a gate that skips is no gate. + - name: actionlint + run: | + set -euo pipefail + V=1.7.12 + curl -fsSL --retry 3 -o /tmp/actionlint.tgz \ + "https://github.com/rhysd/actionlint/releases/download/v${V}/actionlint_${V}_linux_amd64.tar.gz" + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 /tmp/actionlint.tgz" | sha256sum -c - + tar -xzf /tmp/actionlint.tgz -C /tmp actionlint + /tmp/actionlint -shellcheck= -pyflakes= .github/workflows/*.yml + - run: shellcheck -s bash -S warning .github/scripts/*.sh + + # High or critical, with a fix available. An advisory nobody can fix yet is + # reported in the log and does not block. + scan: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Build the image + run: docker build -t iris-ci:${{ github.sha }} . + # The base image's own npm is skipped: Iris never runs it, and on 2026-09-30 it + # held 7 fixed-upstream advisories that even npm 12.2.0 still bundled 3 of. + # Nothing retires this skip on its own (`24-slim` floats, so Dependabot never + # proposes a rebuild); to check, drop `skip-dirs` and see whether `scan` passes. + - name: Scan the image (OS packages and runtime node_modules) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: iris-ci:${{ github.sha }} + skip-dirs: /usr/local/lib/node_modules/npm + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" + - name: Scan the lockfile (dev dependencies included) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: package-lock.json + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" + skip-setup-trivy: true + + # A nightly failure opens one issue, or comments on the open one. + report: + needs: scan + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - run: | + set -euo pipefail + TITLE="Nightly security scan failed on main" + # A failed search files a new issue rather than none. + NUM=$(gh issue list --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty' || true) + if [ -n "$NUM" ]; then + gh issue comment "$NUM" --body "Still failing: $RUN_URL" + else + gh issue create --title "$TITLE" --body "A high or critical advisory with a fix now affects \`main\`. Details: $RUN_URL" + fi diff --git a/.github/workflows/notify-uic-deploy.yml b/.github/workflows/notify-uic-deploy.yml index f3edc0e..cf427b1 100644 --- a/.github/workflows/notify-uic-deploy.yml +++ b/.github/workflows/notify-uic-deploy.yml @@ -9,8 +9,13 @@ name: Notify UIC deploy # a revoked one — or an unreachable API — warns; neither fails the job, so anyone # forking this repo gets a harmless no-op. Other deployments should ignore this # file, or copy it with their own target. +# +# It waits for ci.yml to pass on the pushed commit, so a failing test or a blocking +# advisory stops the deploy as well as the merge. on: - push: + workflow_run: + workflows: [ci] + types: [completed] branches: [main] workflow_dispatch: @@ -18,10 +23,9 @@ on: # capability comes from the PAT below, which is scoped to the deployment repo. permissions: {} -# Newest wins, explicitly. Two merges seconds apart would otherwise race with no -# ordering guarantee, and if the older SHA's dispatch arrives last the far end -# deploys the older commit until something else is pushed — the exact drift this -# workflow exists to remove. +# Newest wins. Push order is kept by ci.yml's concurrency group, which runs `main`'s +# pushes one at a time, so they finish, and dispatch, in push order. One exception: +# re-running an older `ci` run on `main` redeploys that older SHA. concurrency: group: notify-uic-deploy cancel-in-progress: true @@ -40,10 +44,21 @@ jobs: # It takes repo write access to do, so it is a maintainer capability rather than # a hole — but "only what has actually landed on main gets deployed" should be # enforced here, not left to the far end to reject. - if: ${{ github.repository == 'EqualifyEverything/equalify-iris' && github.ref == 'refs/heads/main' }} + # + # On `workflow_run`, `github.sha` is main's tip, not the commit ci checked, so + # the SHA comes from the run itself. `event == 'push'` is the clause that keeps a + # fork out: a fork PR's branch can be named `main`, and `branches:` above only + # filters on that name. Never drop it. + if: >- + github.repository == 'EqualifyEverything/equalify-iris' && ( + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.conclusion == 'success')) env: TOKEN: ${{ secrets.UIC_DEPLOY_DISPATCH_TOKEN }} - SHA: ${{ github.sha }} + SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | set -euo pipefail if [ -z "${TOKEN:-}" ]; then diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ce97818 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Security + +Report a vulnerability privately through [GitHub's advisory form](https://github.com/EqualifyEverything/equalify-iris/security/advisories/new), not in a public issue. diff --git a/docs/ci.md b/docs/ci.md index 4c867c7..4a18193 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,14 +1,27 @@ # The repo's own automation -Five GitHub Actions workflows run this repository: they review pull requests, close duplicate -issues, triage and rank open ones, tell the deployment when `main` moves, and file a weekly -quality report. Each section below says what one does, what it costs, and what it deliberately +Six GitHub Actions workflows run this repository: they gate merges on tests and security scans, +review pull requests, close duplicate issues, triage and rank open ones, tell the deployment when +`main` moves, and file a weekly quality report. Each section below says what one does, what it costs, and what it deliberately does not do. This is for maintainers and for anyone whose PR just got reviewed by a bot. Nothing here is needed to run Iris — see the [README](../README.md) for that, and [CONTRIBUTING.md](../CONTRIBUTING.md) for how to open a PR in the first place. +## Required checks and security scans + +`ci.yml` runs on every PR and push to `main`, and `main`'s ruleset requires it to pass: + +- **`test`**: `npm ci`, typecheck, `npm test`, `./test/e2e.sh`, `actionlint` and `shellcheck`. +- **`scan`**: Trivy over the built image (minus the base image's own npm, which Iris never runs) + and `package-lock.json`. It fails on a high or critical + advisory that has a fix; one with no fix is logged and does not block. + +`scan` also runs nightly on `main`, and a failure opens or updates one issue. Outside the +workflows, GitHub's CodeQL (the ruleset blocks on high-severity alerts), Dependabot and secret +scanning with push protection are on. Report vulnerabilities as [SECURITY.md](../SECURITY.md) says. + ## Automated code review Every PR is reviewed by Claude in CI before a human reads it @@ -347,8 +360,8 @@ two diffs, not something to decide by timestamp. ## Telling a deployment that main moved -`notify-uic-deploy.yml` posts a `repository_dispatch` on every push to `main`, so the UIC test -deployment at `iris.equalify.uic.edu` can ship the exact SHA that just landed. That is all it +`notify-uic-deploy.yml` posts a `repository_dispatch` once `ci.yml` passes on a push to `main`, +so the UIC test deployment at `iris.equalify.uic.edu` can ship the exact SHA that just landed. That is all it does: it holds no infrastructure knowledge, and whether or how the commit is rolled out is the private deployment repo's business.