From 210f10ade3e07453c438e90393fa4547b81a400c Mon Sep 17 00:00:00 2001 From: Mansi Visuals Date: Tue, 8 Sep 2026 11:41:29 +0200 Subject: [PATCH 1/2] ci: stop security patches from stranding on main Dependabot security updates ignore target-branch and land on main, where nothing tested them and nothing carried them back to dev. A browserslist advisory sat there while the release train kept building the vulnerable lockfile, and the audit gate only fired inside the Docker build, after publish had already pushed v1.3.5's commits to main. - test PRs to main, not just dev, so security patches are gated - fail the release audit in check, before publish mutates main - open an issue when dev falls behind main - drop the security-updates group that never applied --- .github/dependabot.yml | 17 +++-- .../workflows/docker-integration-tests.yml | 8 ++- .github/workflows/sync-main-to-dev.yml | 67 +++++++++++++++++++ .github/workflows/weekly-security-release.yml | 9 ++- 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/sync-main-to-dev.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f33af423..17b20623 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,12 @@ version: 2 # Maintenance mode: ViTransfer takes security patches weekly and little else. -# Everything targets dev; the weekly-security-release workflow promotes dev to -# main. +# Version updates target dev; the weekly-security-release workflow promotes dev +# to main. +# +# Security updates do NOT follow target-branch — Dependabot raises those against +# the default branch, main. sync-main-to-dev.yml carries them back to dev so the +# release train does not keep shipping a lockfile the advisory already covers. # # Major version updates are ignored everywhere: in maintenance mode a runtime # or framework major is not worth the churn. The release run's @@ -21,16 +25,17 @@ updates: prefix: deps prefix-development: deps-dev groups: - # One PR a week for routine churn, one for advisories. + # One PR a week for routine churn. There is deliberately no + # security-updates group here: `target-branch` applies to version updates + # only, so Dependabot opens security PRs against the default branch and + # ignores this file's groups and commit prefix entirely. A group here + # would read as if advisories arrive on dev, and they do not. npm: applies-to: version-updates patterns: ["*"] update-types: - patch - minor - npm-security: - applies-to: security-updates - patterns: ["*"] ignore: - dependency-name: "*" update-types: diff --git a/.github/workflows/docker-integration-tests.yml b/.github/workflows/docker-integration-tests.yml index c30f1ee3..4ea262f9 100644 --- a/.github/workflows/docker-integration-tests.yml +++ b/.github/workflows/docker-integration-tests.yml @@ -6,6 +6,12 @@ name: Docker Integration Tests # # No paths filter on PRs: "Test Summary" is a required check on dev, and a # required check that never runs leaves auto-merge pending forever. +# +# PRs to main are tested too. Setting `target-branch` in dependabot.yml turns +# off security updates from that config, so GitHub opens them against the +# default branch instead — main. Those PRs used to get CodeQL and nothing else, +# which is how a browserslist advisory sat on main untested while the release +# train kept building the vulnerable lockfile from dev. on: push: branches: [dev] @@ -18,7 +24,7 @@ on: - '.github/workflows/docker-*.yml' - '.github/workflows/test-dev-*.yml' pull_request: - branches: [dev] + branches: [dev, main] workflow_dispatch: permissions: diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml new file mode 100644 index 00000000..a58bf020 --- /dev/null +++ b/.github/workflows/sync-main-to-dev.yml @@ -0,0 +1,67 @@ +name: Sync main to dev + +# main moves without dev twice: when the release train pushes the version bump +# and changelog, and when a Dependabot security PR lands (those target the +# default branch — see dependabot.yml). Both are carried back by hand, and a +# missed one means dev — the branch every release is cut from — keeps a +# lockfile the advisory already covers while main looks patched. That is how +# v1.3.5 built a vulnerable tree after the fix was already sitting on main. +# +# This opens an issue rather than a PR. dev requires the "Test Summary" check, +# and a PR opened with GITHUB_TOKEN does not trigger `pull_request` workflows, +# so the required check would never run and the PR would block forever. The +# repo has no PAT to raise it as a human would. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + sync: + name: Check dev is in step + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Report drift + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git fetch origin main dev --quiet + + behind=$(git rev-list --count origin/dev..origin/main) + if [ "$behind" -eq 0 ]; then + echo "dev already contains main, nothing to carry back." + exit 0 + fi + echo "main is $behind commit(s) ahead of dev." + + # Its own label, not `ci`: that one is the release-failure report's, + # and sharing it would make either issue suppress the other. + gh label create sync \ + --description "dev has fallen behind main" \ + --color 1d76db --force + + # One open issue at a time: main moves several times per release run, + # and each push would otherwise file its own. + if [ -n "$(gh issue list --label sync --state open --json number -q '.[0].number')" ]; then + echo "A sync issue is already open." + exit 0 + fi + + gh issue create \ + --title "dev is behind main by $behind commit(s)" \ + --label sync \ + --body "$(printf '%s\n\n```\n%s\n```\n\n%s\n' \ + "main has commits dev does not. Until they are merged back, the weekly release builds from a tree that is missing them." \ + "$(git log --oneline origin/dev..origin/main)" \ + "Carry them over with \`git checkout dev && git merge origin/main && git push origin dev\`.")" diff --git a/.github/workflows/weekly-security-release.yml b/.github/workflows/weekly-security-release.yml index ec25e1b1..c3b45a96 100644 --- a/.github/workflows/weekly-security-release.yml +++ b/.github/workflows/weekly-security-release.yml @@ -112,8 +112,13 @@ jobs: run: | npm ci --legacy-peer-deps --ignore-scripts # Majors and unfixable advisories never reach auto-merge, so this is - # the backstop that makes them visible rather than silently skipped. - npm audit --audit-level=high || echo "::warning::npm audit reports unresolved advisories — review before approving" + # the backstop that catches them rather than silently skipping them. + # + # It fails rather than warns: the Dockerfile runs the same gate during + # `build`, which is after `publish` has merged dev and pushed the + # version bump. A warning here bought nothing and left main carrying + # release commits for a version that never tagged or shipped. + npm audit --audit-level=high # Individual PRs are tested against dev as it was when they opened, not # against the final merged state. With no human approving the release, this From 4a37924ea6a349037dedc8f2648354d1dd2afb2c Mon Sep 17 00:00:00 2001 From: Mansi Visuals Date: Tue, 8 Sep 2026 12:11:48 +0200 Subject: [PATCH 2/2] ci: report what is stranded on main, not how far dev is behind Counting commits fired after every release: the version bump lands on main only, by design, so dev is behind every single week and the issue would have been weekly noise for a healthy state. Filter the release train's own commits out by author instead, so it reports only patches that reached main and never made it back to dev. Checked against both v1.3.4 and v1.3.5: silent after each release, and it fires on exactly the browserslist merge that stranded. Closes the issue once dev catches up, so a stale one cannot mask the next. --- .github/workflows/sync-main-to-dev.yml | 69 ++++++++++++++++---------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index a58bf020..319088a0 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -1,16 +1,21 @@ name: Sync main to dev -# main moves without dev twice: when the release train pushes the version bump -# and changelog, and when a Dependabot security PR lands (those target the -# default branch — see dependabot.yml). Both are carried back by hand, and a -# missed one means dev — the branch every release is cut from — keeps a -# lockfile the advisory already covers while main looks patched. That is how -# v1.3.5 built a vulnerable tree after the fix was already sitting on main. +# main moves without dev twice, and only one of them matters. The release train +# pushing the version bump and changelog is by design — dev never carries +# VERSION, so it lags every week and that is fine. A Dependabot security PR +# landing is not: those target the default branch (see dependabot.yml), so dev — +# the branch every release is cut from — keeps a lockfile the advisory already +# covers while main looks patched. That is how v1.3.5 built a vulnerable tree +# after the fix was already sitting on main, and how the release then hit a +# lockfile conflict waiting to happen the next time dev touched the same file. # -# This opens an issue rather than a PR. dev requires the "Test Summary" check, -# and a PR opened with GITHUB_TOKEN does not trigger `pull_request` workflows, -# so the required check would never run and the PR would block forever. The -# repo has no PAT to raise it as a human would. +# So this reports on what is stranded, not on how far behind dev is. Counting +# commits would file an issue after every release, weekly, for the state that is +# supposed to be true. +# +# It opens an issue rather than a PR: dev requires the "Test Summary" check, and +# a PR opened with GITHUB_TOKEN does not trigger `pull_request` workflows, so +# the check would never run and the PR would block forever. There is no PAT. on: push: @@ -32,36 +37,48 @@ jobs: with: fetch-depth: 0 - - name: Report drift + - name: Report anything stranded on main env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git fetch origin main dev --quiet - behind=$(git rev-list --count origin/dev..origin/main) - if [ "$behind" -eq 0 ]; then - echo "dev already contains main, nothing to carry back." + # Release commits are authored by github-actions[bot] and dev is meant + # to lag them. Everything else on main is a patch that never reached + # the branch the next release builds from. + stranded=$(git log --format='%an %h %s' origin/dev..origin/main \ + | grep -v '^github-actions\[bot\] ' || true) + + # One open issue at a time, closed again once dev catches up, so a + # stale one can never mask the next strand. + open_issue=$(gh issue list --label sync --state open --json number -q '.[0].number') + + if [ -z "$stranded" ]; then + echo "Nothing stranded: dev has every patch main does." + if [ -n "$open_issue" ]; then + gh issue close "$open_issue" --comment "dev has caught up with main." + fi + exit 0 + fi + + echo "Stranded on main:" + echo "$stranded" + + if [ -n "$open_issue" ]; then + echo "Issue #$open_issue is already tracking this." exit 0 fi - echo "main is $behind commit(s) ahead of dev." # Its own label, not `ci`: that one is the release-failure report's, # and sharing it would make either issue suppress the other. gh label create sync \ - --description "dev has fallen behind main" \ + --description "A patch on main has not reached dev" \ --color 1d76db --force - # One open issue at a time: main moves several times per release run, - # and each push would otherwise file its own. - if [ -n "$(gh issue list --label sync --state open --json number -q '.[0].number')" ]; then - echo "A sync issue is already open." - exit 0 - fi - gh issue create \ - --title "dev is behind main by $behind commit(s)" \ + --title "Patches on main have not reached dev" \ --label sync \ --body "$(printf '%s\n\n```\n%s\n```\n\n%s\n' \ - "main has commits dev does not. Until they are merged back, the weekly release builds from a tree that is missing them." \ - "$(git log --oneline origin/dev..origin/main)" \ + "These commits are on main but not on dev. The weekly release is cut from dev, so until they are carried back it keeps building without them — and the release's own merge will conflict as soon as dev touches the same files." \ + "$stranded" \ "Carry them over with \`git checkout dev && git merge origin/main && git push origin dev\`.")"