From fc134944a990595d2095346dba3a31499b77ab37 Mon Sep 17 00:00:00 2001 From: Justin Ling <2521993+itsjling@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:18:50 +0000 Subject: [PATCH] Protect release pushes and configure dependency updates --- .github/dependabot.yml | 18 +++++ .github/rulesets/main-history.json | 28 +++++++ .github/rulesets/main-review.json | 66 ++++++++++++++++ .github/workflows/release.yml | 7 +- docs/content/development.mdx | 5 ++ docs/repository-security.md | 123 +++++++++++++++++++++++++++++ tests/release-workflow.test.mjs | 8 +- 7 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/rulesets/main-history.json create mode 100644 .github/rulesets/main-review.json create mode 100644 docs/repository-security.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6040f3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 diff --git a/.github/rulesets/main-history.json b/.github/rulesets/main-history.json new file mode 100644 index 0000000..32c67ee --- /dev/null +++ b/.github/rulesets/main-history.json @@ -0,0 +1,28 @@ +{ + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "name": "main-history", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + } + ] +} diff --git a/.github/rulesets/main-review.json b/.github/rulesets/main-review.json new file mode 100644 index 0000000..2c06813 --- /dev/null +++ b/.github/rulesets/main-review.json @@ -0,0 +1,66 @@ +{ + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "name": "main-review", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + }, + { + "actor_id": null, + "actor_type": "DeployKey", + "bypass_mode": "always" + } + ], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "Automation trust review", + "integration_id": 15368 + }, + { + "context": "Product gate (Node 24.20.0)", + "integration_id": 15368 + }, + { + "context": "unit", + "integration_id": 15368 + }, + { + "context": "integration", + "integration_id": 15368 + }, + { + "context": "browser", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false + } + } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1456e74..7731db8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,14 +67,19 @@ jobs: runs-on: ubuntu-latest environment: npm-publish permissions: - contents: write + contents: read id-token: write steps: + - name: Require the release push identity + env: + RELEASE_PUSH_KEY: ${{ secrets.RELEASE_PUSH_KEY }} + run: test -n "$RELEASE_PUSH_KEY" - name: Check out the dispatch revision with release credentials uses: actions/checkout@v6 with: ref: ${{ github.sha }} fetch-depth: 0 + ssh-key: ${{ secrets.RELEASE_PUSH_KEY }} - name: Set up Node uses: actions/setup-node@v6 with: diff --git a/docs/content/development.mdx b/docs/content/development.mdx index 394166b..935be4f 100644 --- a/docs/content/development.mdx +++ b/docs/content/development.mdx @@ -405,6 +405,11 @@ The production audit has no findings. `pnpm audit` reports three high and one low finding below the Blume docs tool. We accept this limited risk while docs builds use only committed docs and do not handle user content. +## Repository security + +For dependency updates, main branch rules, the release push identity, and +admin recovery, see the [repository security runbook](https://github.com/itsjling/diffsplain/blob/main/docs/repository-security.md). + ## Refresh the demo `site/todo-demo.js` holds the ten sample files used by the landing page and the diff --git a/docs/repository-security.md b/docs/repository-security.md new file mode 100644 index 0000000..46e3d94 --- /dev/null +++ b/docs/repository-security.md @@ -0,0 +1,123 @@ +# Repository security operations + +## Dependency updates + +The dependency graph, Dependabot alerts, and Dependabot security updates are +repository settings. Check them under Settings > Advanced Security. The public +repository's dependency graph includes the pnpm lockfile. + +`.github/dependabot.yml` requests npm updates for the pnpm package graph and +GitHub Actions updates every Monday at 09:00 UTC. Each ecosystem allows five +open version-update PRs. Security updates remain enabled independently of that +limit. Updates require review through the normal PR process; automatic merging +is not enabled. + +Verify the live settings with: + +```sh +gh api repos/itsjling/diffsplain/vulnerability-alerts --include +gh api repos/itsjling/diffsplain/automated-security-fixes +gh api repos/itsjling/diffsplain/dependency-graph/sbom --jq '.sbom.packages | length' +``` + +Expect HTTP 204 for alerts, `enabled: true` and `paused: false` for security +fixes, and a populated SBOM. After the config reaches main, check +[Dependabot runs](https://github.com/itsjling/diffsplain/network/updates) for +both ecosystems. A config on a feature branch does not activate version updates. + +## Main branch rules + +The JSON files in `.github/rulesets/` describe the intended active rules. +GitHub does not automatically apply these files. Check the actual state at +[repository rules](https://github.com/itsjling/diffsplain/rules). + +- `main-history` blocks deletion and force pushes. Only repository admins can + bypass it for emergency recovery. The release key cannot bypass this rule. +- `main-review` requires a PR, resolved review threads, an up-to-date branch, + and the five checks below. It requires zero approving reviews, as agreed in + [issue #129](https://github.com/itsjling/diffsplain/issues/129). A maintainer + can merge their own work after CI passes. + +| Required check | PR trigger | +| --- | --- | +| Automation trust review | `pull_request_target` | +| Product gate (Node 24.20.0) | `pull_request` | +| unit | `pull_request` | +| integration | `pull_request` | +| browser | `pull_request` | + +Checks are bound to the GitHub Actions app, ID 15368. Keep the exact names in +sync when workflows change. Platform jobs, site deployment, live-provider +canaries, and scheduled or manual-only checks are not required. The Fallow +audit remains advisory. Dependabot PRs run the required jobs without secrets. +Changes to the release workflow require the existing `automation-reviewed` +label after a maintainer reviews the automation diff. + +## Release identity + +Only the publish job in `.github/workflows/release.yml` receives +`RELEASE_PUSH_KEY`, an environment secret in `npm-publish`. That environment +allows the `main` branch only. The preparation job has no push key or npm +publication credential. Both workflow tokens have read-only contents access. +The publish job still uses npm trusted publishing through OIDC. + +The key is the repository's sole write deploy key, titled +`npm-publish release push`. It bypasses only `main-review`, so the finalizer can +atomically push its verified release commit and tag before npm publication. +The workflow fails if the key is missing instead of using its default token. +The commit author `github-actions[bot]` is metadata, not the bypass identity. + +GitHub's [ruleset API](https://docs.github.com/en/rest/repos/rules#create-a-repository-ruleset) +uses `DeployKey` with a null actor ID. This grants the exception to all write +deploy keys on the repository. Keep this as the only write deploy key; adding +another one expands release authority. Do not store it as a repository secret +or give it to PR jobs. Admins can change the environment and rules and are +trusted recovery actors. + +To rotate the key, pause release dispatches, generate a new Ed25519 key, add +its public half as a write deploy key, and replace the environment secret: + +```sh +gh secret set RELEASE_PUSH_KEY --env npm-publish < /secure/path/release-key +``` + +Remove the old deploy key in Settings > Deploy keys, verify the remaining +key, then remove local private-key copies. Never put keys in Git or issue logs. + +## Rollout and recovery + +Issue #129 activates `main-history` immediately and stages `main-review` as +disabled. Activate the latter only after the release credential change has +merged into main and `RELEASE_PUSH_KEY` is installed. Activating it against the +old workflow would prevent releases because its default token cannot bypass. + +From a checkout of the merged main revision, apply the reviewed rule: + +```sh +gh api --method PUT repos/itsjling/diffsplain/rulesets/22360425 \ + --input .github/rulesets/main-review.json +gh api repos/itsjling/diffsplain/rulesets/22360425 --jq '{name, enforcement, bypass_actors, rules}' +``` + +Before closing #129, confirm the active rule, both Dependabot ecosystem runs, +and the initial OpenSSF Scorecard result when available. Monitoring is tracked +in [issue #127](https://github.com/itsjling/diffsplain/issues/127). No Scorecard +workflow was present during rollout, and the public results API returned 404. +Record unavailable or stale results explicitly. +The zero-review policy and admin/release bypasses may lower Branch-Protection +scoring; do not add mandatory reviews or remove release access to improve a score. + +For an emergency, an administrator can bypass the rule for the necessary push +or use Settings > Rules > Rulesets to repair a mistaken check or bypass entry. +Record every emergency use and its reason, actor, affected refs and commits, +and any settings changed in the affected issue or PR. Restore the intended +rules from the reviewed JSON and confirm enforcement after recovery. Normal +work, including an admin's normal changes, should use PRs. + +To verify enforcement without publishing, create a disposable branch with +copies of both rules targeting only that branch. Remove the admin bypass from +the copies so an admin token can test an ordinary actor. Confirm an ordinary +ref update is rejected for missing PR/checks, a fast-forward release-key push +succeeds, and release-key force pushes and deletion fail. Delete the temporary +rules before deleting the branch. Never use main as the rejection-test target +or dispatch a production release just to test branch protection. diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index 6ea5c6b..80714a4 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -604,7 +604,7 @@ test('workflow pins the trusted, serialized, split-job release contract', async assert.match(workflow, /^ release:\n needs: prepare/m); assert.match( workflow, - /^ release:\n(?:.|\n)*? permissions:\n contents: write\n id-token: write/m, + /^ release:\n(?:.|\n)*? permissions:\n contents: read\n id-token: write/m, ); assert.match(workflow, /actions\/download-artifact@v5/); assert.match(workflow, /if: github\.ref == 'refs\/heads\/main'/); @@ -632,10 +632,16 @@ test('workflow pins the trusted, serialized, split-job release contract', async ); assert.match(prepareJob, /ref: \$\{\{ github\.sha \}\}/); assert.doesNotMatch(prepareJob, /ref: main/); + assert.doesNotMatch(prepareJob, /RELEASE_PUSH_KEY|ssh-key:/); assert.match(prepareJob, /git checkout -B main "\$GITHUB_SHA"/); const releaseJob = workflow.slice(workflow.indexOf('\n release:')); assert.match(releaseJob, /ref: \$\{\{ github\.sha \}\}/); assert.doesNotMatch(releaseJob, /ref: main/); + assert.match(releaseJob, /ssh-key: \$\{\{ secrets\.RELEASE_PUSH_KEY \}\}/); + assert.match(releaseJob, /test -n "\$RELEASE_PUSH_KEY"/); + assert.ok( + releaseJob.indexOf('test -n') < releaseJob.indexOf('uses: actions/checkout'), + ); assert.doesNotMatch(releaseJob, /pnpm install|release:verify/); assert.doesNotMatch(helper, /^import .*release\.mjs/m); assert.match(helper, /'--ignore-scripts'/);