diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 00ee193478..e6be54b0d7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -43,11 +43,22 @@ updates: - "minor" - "patch" - # Enable version updates for GitHub Actions + # Enable version updates for GitHub Actions. Weekly and grouped into one + # pull request: the third-party actions that hold a credential are pinned + # by commit sha, so a release reaches them only through this update. - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "monthly" + interval: "weekly" + groups: + # Majors stay separate pull requests: one declined major (the + # changesets/action v1 pin is deliberate) must not hold the group. + github-actions: + patterns: + - "*" + update-types: + - "minor" + - "patch" labels: - "dependencies" - "github-actions" diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 3785572d19..7f0dd7bdcf 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -99,7 +99,7 @@ jobs: # shells out to pnpm, and this workflow pins no toolchain. - name: Generate changelog - uses: orhun/git-cliff-action@v4 + uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1 id: changelog with: config: cliff.toml diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index 533cf01fe5..8f28eee19e 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -880,9 +880,9 @@ jobs: # plausibly the shape that makes the clear step unnecessary — which is an # argument for revisiting the pin eventually, not for bumping it blind. # - # ⛔ WHAT NOTICES A BUMP. This `@v1` ref is pinned by exactly one thing: + # ⛔ WHAT NOTICES A BUMP. This v1 ref is pinned by exactly one thing: # `scripts/__tests__/changeset-release-action-ref-pin.test.ts`. It fails on - # a bump of EITHER `uses: changesets/action@v1` line in this file and names + # a bump of EITHER `uses: changesets/action@...` line in this file and names # the two properties above as what a bump must re-establish. There is no # second guard — the type checker and the linters do not read this file at # all — so ⛔ do not delete that test as collateral of some other cleanup. @@ -895,9 +895,19 @@ jobs: # `CLAUDE.md` / `GEMINI.md` and on `.changeset/pre/`). objectui#6081 # deleted that mirror, because the bundled copy is a minified chunk behind # a MOVING tag and cannot be pinned from here at any effort — a retag of - # `v1` in place changed it and nothing here failed. A ref pin cannot catch - # a retag either, but it does not pretend to: it catches the DELIBERATE - # bump, which is the only moment anyone can act on. + # `v1` in place changed it and nothing here failed. A ref pin catches the + # DELIBERATE bump, which is the only moment anyone can act on. + # + # ⭐ THE REF IS A COMMIT SHA, NOT THE MOVING `v1`. Both `uses:` lines name + # `a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d`, the commit the `v1` branch + # and the `v1.9.0` tag both resolved to when this was written, and the + # same commit the git-surface reading above was taken at. This step runs + # with `NPM_TOKEN`, so a retag of `v1` would otherwise hand the publish + # credential to code nobody here has read; a sha cannot move. A bump is + # still a deliberate edit of both lines to another sha (the trailing + # `# vX.Y.Z` comment names the release it is), and it still has to + # re-establish the two properties above. The sha is a declared exception + # to `scripts/check-action-ref-convention.mjs`'s floating-tag default. # ⚠️ THE STEP THAT LETS THE PREDICATE REACH THE ACTION (objectui#5442). # @@ -1019,7 +1029,7 @@ jobs: - name: Publish to npm id: changesets-publish if: github.event_name == 'push' - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: publish: pnpm changeset:publish env: @@ -1437,7 +1447,7 @@ jobs: - name: Refresh the version PR id: changesets-version if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: version: pnpm changeset:version title: 'chore: release packages' diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 1d65ce8792..c316fc9c15 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -61,6 +61,11 @@ on: # - main # pull_request: +# Least privilege: the job checks out and reads public pages. The token lychee +# receives is for GitHub's API rate limit on link checks, a read. +permissions: + contents: read + jobs: check-links: runs-on: ubuntu-latest @@ -145,7 +150,7 @@ jobs: uses: actions/checkout@v7 - name: Check links with Lychee - uses: lycheeverse/lychee-action@v2 + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: # Both trees: # content/docs/** — the published site's documentation (the fumadocs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc8af276c2..787c393c26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,16 @@ on: # which on a queue build is the queue's own generation ref # (`gh-readonly-queue/main/pr--`) — colliding with # neither a PR group nor a push group, exactly as before. +# Least privilege for the job token. Every job without its own `permissions:` +# block (changeset-check, type-check, test, test-dist-pins, test-coverage, +# e2e, docs) only checks out, builds, tests, and saves caches and artifacts; +# none of those calls the API with this token (actions/cache and +# actions/upload-artifact use the runner's own token), so `contents: read` is +# all they get. The two jobs that read the Actions API (test-aggregate, +# coverage-report) declare their own block, which replaces this one. +permissions: + contents: read + concurrency: group: >- ci-${{ github.workflow }}-${{ github.event.pull_request.number diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 61c4daf057..943bbd4601 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -153,7 +153,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v3 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/scripts/__tests__/changeset-release-action-ref-pin.test.ts b/scripts/__tests__/changeset-release-action-ref-pin.test.ts index f0f3b359e7..b9b9905a3d 100644 --- a/scripts/__tests__/changeset-release-action-ref-pin.test.ts +++ b/scripts/__tests__/changeset-release-action-ref-pin.test.ts @@ -48,20 +48,30 @@ import { parse as parseYaml } from 'yaml'; * on that ref. Move the assertion somewhere that survives first — do not just * drop it. * - * ## What this pin does NOT do + * ## The ref is a commit sha, so a retag cannot reach it * - * It cannot catch a **retag of `v1` in place**. `changesets/action@v1` is a - * moving tag whose bundled `@changesets/read` is a minified chunk nothing here - * installs, so no test can execute it and no lockfile records it — that gap is - * objectui#6081's subject, and it is the reason the mirror was deleted rather - * than given a better referent. This pin catches the DELIBERATE bump, which is - * the only moment anyone can act on anyway. + * `changesets/action@v1` names a moving branch whose bundled `@changesets/read` + * is a minified chunk nothing here installs, so no test can execute it and no + * lockfile records it — that gap is objectui#6081's subject, and it is the + * reason the mirror was deleted rather than given a better referent. Both + * `uses:` lines now name the v1.9.0 commit instead, so a retag of `v1` in place + * no longer changes what runs; this pin catches the DELIBERATE bump, which is + * the only moment anyone can act on anyway, and a step that drifts back onto + * the moving `v1`. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const workflowPath = path.join(repoRoot, '.github/workflows/changeset-release.yml'); const workflowSource = fs.readFileSync(workflowPath, 'utf8'); -const PINNED_REF = 'changesets/action@v1'; +/** + * The v1 commit, not the moving `v1` branch: `v1` and the `v1.9.0` tag both + * resolved to this sha when the pin moved off the branch, and it is the commit + * the workflow's git-surface reading was taken at. The publish step runs with + * the npm token, so the ref it executes must not be able to move under it. + */ +const PINNED_SHA = 'a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d'; +const PINNED_REF = `changesets/action@${PINNED_SHA}`; +const PINNED_LINE = ` uses: ${PINNED_REF} # v1.9.0`; interface Step { name?: string; @@ -93,7 +103,7 @@ function changesetsActionSteps(source: string): Array<{ job: string; step: strin return found; } -describe('changeset-release.yml pins changesets/action to @v1', () => { +describe('changeset-release.yml pins changesets/action to the v1.9.0 commit', () => { it('still uses the action at all — the pin is not vacuous', () => { // Without this, every assertion below passes over a workflow that dropped // the action entirely, which is the one way a "toContain" pin lies. @@ -145,7 +155,7 @@ describe('the pin can fail (non-vacuity)', () => { } it('goes red when the publish step is bumped to @v2', () => { - const bumped = mutate(' uses: changesets/action@v1\n with:\n publish:', ' uses: changesets/action@v2\n with:\n publish:'); + const bumped = mutate(`${PINNED_LINE}\n with:\n publish:`, ' uses: changesets/action@v2\n with:\n publish:'); const offenders = changesetsActionSteps(bumped).filter((s) => s.uses !== PINNED_REF); expect(offenders).toEqual([ { job: 'release', step: 'Publish to npm', uses: 'changesets/action@v2' }, @@ -153,7 +163,7 @@ describe('the pin can fail (non-vacuity)', () => { }); it('goes red when the refresh step is bumped to @v2', () => { - const bumped = mutate(' uses: changesets/action@v1\n with:\n version:', ' uses: changesets/action@v2\n with:\n version:'); + const bumped = mutate(`${PINNED_LINE}\n with:\n version:`, ' uses: changesets/action@v2\n with:\n version:'); const offenders = changesetsActionSteps(bumped).filter((s) => s.uses !== PINNED_REF); expect(offenders).toEqual([ { job: 'release', step: 'Refresh the version PR', uses: 'changesets/action@v2' }, @@ -164,15 +174,23 @@ describe('the pin can fail (non-vacuity)', () => { // The shape a real v2 migration takes: `changesets/action/publish@v2`. A // pin that matched only the exact old string would find nothing here and // pass. - const bumped = mutate(' uses: changesets/action@v1\n with:\n publish:', ' uses: changesets/action/publish@v2\n with:\n publish:'); + const bumped = mutate(`${PINNED_LINE}\n with:\n publish:`, ' uses: changesets/action/publish@v2\n with:\n publish:'); const offenders = changesetsActionSteps(bumped).filter((s) => s.uses !== PINNED_REF); expect(offenders).toEqual([ { job: 'release', step: 'Publish to npm', uses: 'changesets/action/publish@v2' }, ]); }); + it('goes red when the publish step goes back to the moving v1 branch', () => { + const floating = mutate(`${PINNED_LINE}\n with:\n publish:`, ' uses: changesets/action@v1\n with:\n publish:'); + const offenders = changesetsActionSteps(floating).filter((s) => s.uses !== PINNED_REF); + expect(offenders).toEqual([ + { job: 'release', step: 'Publish to npm', uses: 'changesets/action@v1' }, + ]); + }); + it('goes red when the action is dropped from the workflow entirely', () => { - const stripped = mutate(' uses: changesets/action@v1', ' uses: actions/checkout@v7'); + const stripped = mutate(PINNED_LINE, ' uses: actions/checkout@v7'); expect(changesetsActionSteps(stripped).length).toBeLessThan(2); }); }); diff --git a/scripts/check-action-ref-convention.mjs b/scripts/check-action-ref-convention.mjs index 74c67eacde..4649366c1c 100644 --- a/scripts/check-action-ref-convention.mjs +++ b/scripts/check-action-ref-convention.mjs @@ -141,6 +141,45 @@ export const DECLARED_EXCEPTIONS = [ 'reviewed moment; a floating ref would re-adopt every upstream change on the next scheduled ' + 'run, with nothing in this repository to roll back to.', }, + // The four below share one reason, said once: each is a third-party action + // that runs holding a credential, so the code a floating tag resolves to + // would receive that credential on the next run after the tag moved, with + // nothing here reviewing it. A commit sha cannot move; the trailing + // `# vX.Y.Z` comment names the release it is. First-party `actions/*` refs + // stay on the floating-tag default. + { + workflow: 'changeset-release.yml', + action: 'changesets/action', + issue: 'objectui#13430', + reason: + 'runs with NPM_TOKEN and GITHUB_TOKEN in the publish and version-PR steps; pinned to the ' + + 'v1.9.0 commit, the one the workflow comment and the ref-pin test say both v1 properties ' + + 'were read at (scripts/__tests__/changeset-release-action-ref-pin.test.ts holds the sha).', + }, + { + workflow: 'check-links.yml', + action: 'lycheeverse/lychee-action', + issue: 'objectui#13430', + reason: + 'receives GITHUB_TOKEN through its env and its token input default; pinned to the v2.9.0 ' + + 'commit so the link checker cannot change under that token between reviews.', + }, + { + workflow: 'dependabot-auto-merge.yml', + action: 'dependabot/fetch-metadata', + issue: 'objectui#13430', + reason: + 'receives GITHUB_TOKEN as its github-token input in a job that approves and enqueues ' + + 'Dependabot pull requests; pinned to the v3.1.0 commit.', + }, + { + workflow: 'changelog.yml', + action: 'orhun/git-cliff-action', + issue: 'objectui#13430', + reason: + 'defaults its github_token input to the job token, which holds contents: write in this ' + + 'job; pinned to the v4.9.1 commit.', + }, ]; /**