diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml deleted file mode 100644 index 5c116d0..0000000 --- a/.github/workflows/lockfile.yml +++ /dev/null @@ -1,283 +0,0 @@ -# Reusable lockfile regeneration bot. -# -# Unifies three near-identical implementations (rishitank-portfolio, -# rishitank-recipes, quarterdeck) and keeps the non-obvious correctness -# properties all three discovered independently: -# -# * stage before diffing. `git diff --quiet` compares the index to the -# worktree and is blind to untracked files, so a lockfile that does not yet -# exist reads as "nothing to commit". Stage first, then diff --cached. -# * refuse to proceed if the resolver rewrote package.json. Regenerating a -# lockfile must not silently change declared dependency ranges. -# * prove the result. A lockfile that resolves but cannot be installed -# frozen is worse than no change at all. -# -# Two jobs, so that no dependency code ever shares a job with a write token. -# Proving the lockfile runs `npm ci` / `pnpm install --frozen-lockfile` and the -# caller's `verify`, i.e. third-party lifecycle scripts. Within one job those -# can reach the token however it is scoped: from a persisted .git/config, from a -# git hook or a $GITHUB_ENV / $GITHUB_PATH entry they plant for a later step, -# or, on a runner with sudo, from the runner process's memory. So `lockfile` -# does all of that read-only and records the lockfile's hash before the install -# runs; `commit` runs no dependency code, commits only a lockfile with that -# hash, and is the only job holding contents: write. -# -# The boundary holds because each job gets a fresh machine: GitHub-hosted -# runners do, and so does the CI_RUNNER fallback, which boots a VM per job. On -# a persistent self-hosted runner, `lockfile` could leave a process or files -# behind for `commit` to inherit, so CI_RUNNER must stay ephemeral. `commit` -# still follows CI_RUNNER rather than pinning ubuntu-latest: CI_RUNNER is set -# when the included minutes run out, which is exactly when a hosted-only job -# could not run. It ignores the runs-on input, though, so a caller's override -# cannot put it on the same persistent machine as `lockfile`; it only runs git. -name: lockfile - -on: - workflow_call: - inputs: - package-manager: - description: npm or pnpm. - type: string - default: npm - pnpm-version: - type: string - default: "" - node-version-file: - type: string - default: .nvmrc - node-version: - type: string - default: "" - runs-on: - description: >- - Runner label. Empty (the default) follows the calling repository's - CI_RUNNER variable, which moves CI onto a fallback runner when the - included Actions minutes run out, and means ubuntu-latest when that - variable is unset. A public repository ignores CI_RUNNER: its - minutes are free, and a fork's pull request must never reach a - self-hosted runner. Applies to the lockfile job only; the commit job - ignores it (see the header). - type: string - default: "" - verify: - description: Command proving the regenerated lockfile actually installs and builds. - type: string - default: "" - timeout-minutes: - type: number - default: 20 - -permissions: - contents: read - -jobs: - lockfile: - name: Regenerate lockfile - runs-on: ${{ inputs.runs-on || (github.event.repository.private && vars.CI_RUNNER) || 'ubuntu-latest' }} - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - outputs: - changed: ${{ steps.changed.outputs.changed }} - sum: ${{ steps.changed.outputs.sum }} - steps: - # No persisted token: the proof steps run dependencies' install scripts, - # which must not find one in .git/config. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install pnpm - if: inputs.package-manager == 'pnpm' - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: - version: ${{ inputs.pnpm-version }} - - - name: Resolve Node version source - id: node - run: | - set -euo pipefail - if [ -n "${VERSION_FILE}" ] && [ -f "${VERSION_FILE}" ]; then - echo "file=${VERSION_FILE}" >> "$GITHUB_OUTPUT" - echo "version=" >> "$GITHUB_OUTPUT" - elif [ -n "${VERSION}" ]; then - echo "file=" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - else - echo "::error title=No Node version::Neither node-version-file nor node-version resolved to anything." - exit 1 - fi - env: - VERSION_FILE: ${{ inputs.node-version-file }} - VERSION: ${{ inputs.node-version }} - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: ${{ steps.node.outputs.file }} - node-version: ${{ steps.node.outputs.version }} - - - name: Record package.json checksum - id: before - run: echo "sum=$(sha256sum package.json | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - - - name: Resolve dependencies - run: | - set -euo pipefail - if [ "${PM}" = "pnpm" ]; then - pnpm install --lockfile-only - else - npm install --package-lock-only - fi - env: - PM: ${{ inputs.package-manager }} - - - name: Refuse if package.json was rewritten - run: | - set -euo pipefail - after=$(sha256sum package.json | cut -d' ' -f1) - if [ "$after" != "${BEFORE}" ]; then - echo "::error title=package.json modified::The resolver rewrote package.json. Regenerating a lockfile must not change declared ranges. Review and commit that change deliberately." - git --no-pager diff -- package.json - exit 1 - fi - env: - BEFORE: ${{ steps.before.outputs.sum }} - - - name: Determine lockfile - id: lock - run: | - set -euo pipefail - if [ "${PM}" = "pnpm" ]; then - echo "path=pnpm-lock.yaml" >> "$GITHUB_OUTPUT" - else - echo "path=package-lock.json" >> "$GITHUB_OUTPUT" - fi - env: - PM: ${{ inputs.package-manager }} - - # Stage first. `git diff --quiet` cannot see an untracked file, so a - # brand-new lockfile would otherwise report as "already up to date". - # The hash is taken here, before the proof steps run any dependency - # code, and `commit` refuses any lockfile that does not match it. - - name: Stage and check for changes - id: changed - run: | - set -euo pipefail - git add -- "${LOCKFILE}" - if git diff --cached --quiet -- "${LOCKFILE}"; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "${LOCKFILE} already up to date; nothing to commit." - else - echo "changed=true" >> "$GITHUB_OUTPUT" - echo "sum=$(sha256sum "${LOCKFILE}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - echo "${LOCKFILE}: $(wc -l < "${LOCKFILE}") lines" - fi - env: - LOCKFILE: ${{ steps.lock.outputs.path }} - - - name: Prove the lockfile installs frozen - if: steps.changed.outputs.changed == 'true' - run: | - set -euo pipefail - if [ "${PM}" = "pnpm" ]; then - pnpm install --frozen-lockfile - else - npm ci - fi - env: - PM: ${{ inputs.package-manager }} - - - name: Prove the project still builds - if: steps.changed.outputs.changed == 'true' && inputs.verify != '' - run: ${{ inputs.verify }} - - - name: Upload the proven lockfile - if: steps.changed.outputs.changed == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: lockfile - path: ${{ steps.lock.outputs.path }} - if-no-files-found: error - retention-days: 1 - - commit: - name: Commit lockfile - needs: lockfile - if: needs.lockfile.outputs.changed == 'true' - # No inputs.runs-on here: see the header. - runs-on: ${{ (github.event.repository.private && vars.CI_RUNNER) || 'ubuntu-latest' }} - timeout-minutes: 10 - permissions: - contents: write # the Commit step pushes the regenerated lockfile - steps: - # No persisted token either: only the Commit step below gets one. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download the proven lockfile - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: lockfile - path: ${{ runner.temp }}/lockfile - - # The artifact was uploaded after dependency code ran, so it is checked - # against the hash recorded before that, not trusted as is. - - name: Stage the proven lockfile - run: | - set -euo pipefail - dir="${RUNNER_TEMP}/lockfile" - src="${dir}/${LOCKFILE}" - if [ "$(find "${dir}" -mindepth 1 | wc -l)" -ne 1 ] || [ -L "${src}" ] || [ ! -f "${src}" ]; then - echo "::error title=Unexpected artifact::The lockfile artifact must hold exactly one regular file, ${LOCKFILE}." - find "${dir}" -mindepth 1 -exec ls -ld {} + - exit 1 - fi - sum=$(sha256sum "${src}" | cut -d' ' -f1) - if [ "${sum}" != "${EXPECTED}" ]; then - echo "::error title=Lockfile changed after it was hashed::${LOCKFILE} is not the file the lockfile job hashed before proving it (expected ${EXPECTED}, got ${sum}). Refusing to commit it." - exit 1 - fi - cp -- "${src}" "${LOCKFILE}" - git add -- "${LOCKFILE}" - staged=$(git diff --cached --name-only) - if [ "${staged}" != "${LOCKFILE}" ]; then - echo "::error title=Unexpected staged changes::Expected only ${LOCKFILE} to be staged, got: ${staged:-nothing}" - exit 1 - fi - env: - # From the caller's input, not from a job output: the lockfile job runs - # dependency code and must not be able to choose which file is committed. - LOCKFILE: ${{ inputs.package-manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json' }} - EXPECTED: ${{ needs.lockfile.outputs.sum }} - - # The token reaches this step only, and only as an environment-supplied - # git header on the one push: not in .git/config, not on the command line. - - name: Commit - run: | - set -euo pipefail - # The full ref, not GITHUB_REF_TYPE: that reads "branch" on - # pull_request runs too, whose ref is refs/pull/N/merge. - case "${GITHUB_REF}" in - refs/heads/*) ;; - *) - echo "::error title=Not a branch::Run this from a branch; ${GITHUB_REF} is not under refs/heads/." - exit 1 - ;; - esac - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore: regenerate ${LOCKFILE}" - # The same header actions/checkout would have persisted, given to - # this one push through the environment instead of .git/config or - # the command line. The server URL is the checkout's own, so the - # header matches whichever host origin points at. - auth=$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n') - echo "::add-mask::${auth}" - GIT_CONFIG_COUNT=1 \ - GIT_CONFIG_KEY_0="http.${GITHUB_SERVER_URL}/.extraheader" \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ - git push origin "HEAD:${GITHUB_REF}" - env: - LOCKFILE: ${{ inputs.package-manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json' }} - GH_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 211e521..f18cedd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,9 +33,12 @@ default branch are reserved for automation. ## Dependencies -Renovate manages upgrades. Do not hand-edit a lockfile; if one needs -regenerating, run the repo's `lockfile` workflow from the Actions tab so the -result is proven to install and build before it is committed. +Renovate manages upgrades. Do not hand-edit a lockfile. To regenerate one, +tick lock file maintenance under "Awaiting Schedule" on the repo's Dependency +Dashboard: Renovate opens a pull request (without running install scripts) and +CI proves it installs and builds before it merges. A lockfile out of step with +a `package.json` change on your own branch is fixed by running the package +manager's install locally; CI's frozen install fails until it is. ## CI configuration diff --git a/README.md b/README.md index eefd04d..59e7db4 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,6 @@ needs to be insulated from that. | `security.yml` | any | osv-scanner + gitleaks in one job, both blocking; the advisory ecosystem audit runs weekly (every run if `audit-strict`) | | `lighthouse-ci.yml` | Node / web frontends | `lhci autorun` against a locally built app, plus a sticky PR comment with the score table; advisory by default | | `runtime-gate.yml` | web apps (any stack) | boots the app (Dockerfile, command or static), crawls it in a real browser and **fails on runtime errors**: exceptions, console errors, hydration errors, failed requests, 4xx/5xx, blank pages; plus advisory screenshot diffs and an advisory Gemini explorer that uses the app like a person. See `docs/runtime-gate.md` | -| `lockfile.yml` | Node / TypeScript | `workflow_dispatch` lockfile regeneration that proves the result before committing; the proof runs read-only and a separate job commits, so dependency install scripts never share a job with the write token | | `workflows-lint.yml` | any | actionlint over `.github/workflows/`, self-updating and cached | Every job sets `timeout-minutes` and a least-privilege `permissions` block. @@ -235,7 +234,8 @@ normally as two pull requests per repository: `non-major (automerge)` Renovate on green CI inside the window) and `non-major (review)` (everything else non-major, Docker digests included). The scientific stack keeps its own group. Majors, GitHub Actions majors included, open no pull request until they -are ticked on the Dependency Dashboard. Lockfile maintenance is quarterly. +are ticked on the Dependency Dashboard. Lockfile maintenance is quarterly; to +run it sooner, tick it under "Awaiting Schedule" on the Dependency Dashboard. Branches are not updated or rebased outside the window, so a reviewed pull request merged later needs "Update branch" first. Security fixes are unaffected: `vulnerabilityAlerts` ignores the schedule and grouping and opens @@ -301,12 +301,6 @@ inputs.runs-on || (github.event.repository.private && vars.CI_RUNNER) || 'ubuntu A caller's own jobs (ones that do not call a workflow here) should use the same expression. -One exception: `lockfile.yml`'s `commit` job, which holds the write token, -drops `inputs.runs-on` and uses `(github.event.repository.private && -vars.CI_RUNNER) || 'ubuntu-latest'`. A caller's override then cannot put it on -the same persistent machine as the job that ran dependency code. That -isolation also assumes `CI_RUNNER` gives each job a fresh machine. - ## Changing a workflow A change here lands on every consuming repo at once. Treat it accordingly: