From f9f596d7ce34c5e84a09c8614f87dab0b2fac2a6 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 8 Oct 2026 11:08:22 +0400 Subject: [PATCH] Make the PR workflow a real gate - continue-on-error is removed; lint, test and build each have to pass, and fail-fast is off so each reports on its own - after each step the tree must be unchanged (eslint --fix, packageInfo.ts written by the build) - after build, scripts/check-built-manifests.py checks the package.json npm receives from each build/ directory - push-main installs with --immutable and checks the manifests too - the auto-approve and auto-merge bots are removed --- .github/workflows/auto-approve.yml | 16 ----- .github/workflows/auto-merge.yml | 16 ----- .github/workflows/pr-any.yml | 14 ++++- .github/workflows/push-main.yml | 10 +-- scripts/check-built-manifests.py | 99 ++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 37 deletions(-) delete mode 100644 .github/workflows/auto-approve.yml delete mode 100644 .github/workflows/auto-merge.yml create mode 100755 scripts/check-built-manifests.py diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml deleted file mode 100644 index 566790fc..00000000 --- a/.github/workflows/auto-approve.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: bot - -on: - pull_request: - types: [labeled] - -jobs: - approve: - if: "! startsWith(github.event.head_commit.message, '[CI Skip]') && (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository)" - runs-on: ubuntu-latest - steps: - - uses: jacogr/action-approve@795afd1dd096a2071d7ec98740661af4e853b7da - with: - authors: jacogr, TarikGul - labels: -auto - token: ${{ secrets.GH_PAT_BOT }} diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml deleted file mode 100644 index caf5da1b..00000000 --- a/.github/workflows/auto-merge.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: bot - -on: - pull_request: - types: [labeled] - -jobs: - merge: - runs-on: ubuntu-latest - steps: - - uses: jacogr/action-merge@d2d64b4545acd93b0a9575177d3d215ae3f92029 - with: - checks: pr (build),pr (lint),pr (test) - labels: -auto - strategy: squash - token: ${{ secrets.GH_PAT_BOT }} diff --git a/.github/workflows/pr-any.yml b/.github/workflows/pr-any.yml index ac61b8d2..4b1b7295 100644 --- a/.github/workflows/pr-any.yml +++ b/.github/workflows/pr-any.yml @@ -3,8 +3,9 @@ on: [pull_request] jobs: pr: - continue-on-error: true strategy: + # Each step reports on its own; one red step must not cancel the others. + fail-fast: false matrix: step: ['lint', 'test', 'build'] runs-on: ubuntu-latest @@ -17,5 +18,16 @@ jobs: node-version: 'lts/*' - name: ${{ matrix.step }} run: | + corepack enable yarn install --immutable yarn ${{ matrix.step }} + # `yarn lint` runs eslint --fix and `yarn build` rewrites packageInfo.ts, + # so both pass on a tree they changed. The committed tree must already be + # what they produce. + - name: tree unchanged + run: git diff --exit-code --stat + # The package.json each build/ directory holds is what npm receives: entry + # points must exist, no ./build/ or ./cjs/cjs/ paths, no workspace: ranges. + - name: check built manifests + if: matrix.step == 'build' + run: python3 scripts/check-built-manifests.py . diff --git a/.github/workflows/push-main.yml b/.github/workflows/push-main.yml index 6c13bbaf..a2c0bf31 100644 --- a/.github/workflows/push-main.yml +++ b/.github/workflows/push-main.yml @@ -5,19 +5,21 @@ on: branches: - main +permissions: + contents: read + jobs: build: - if: "! startsWith(github.event.head_commit.message, '[CI Skip]')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 'lts/*' - name: Build run: | corepack enable - yarn install + yarn install --immutable yarn build + - name: Check built manifests + run: python3 scripts/check-built-manifests.py . diff --git a/scripts/check-built-manifests.py b/scripts/check-built-manifests.py new file mode 100755 index 00000000..9b379de3 --- /dev/null +++ b/scripts/check-built-manifests.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Check the package.json the build wrote into each packages/*/build directory. + +This is the manifest npm receives. It fails on what broke the January 2026 +releases and wasm 7.5.19: an entry point (main, module, types, browser, +react-native, every exports target) naming a file that is not in the build +directory, a path with ./build/ or a doubled ./cjs/cjs/ in it, numeric keys +inside exports (a string spread into an object), and workspace:/portal:/link: +dependency ranges. + + check-built-manifests.py [package ...] + +Prints one line per package and exits 1 if any package fails. +""" +import json +import pathlib +import sys + +ENTRY_FIELDS = ('main', 'module', 'types', 'typings', 'browser', 'react-native') +DEP_FIELDS = ('dependencies', 'peerDependencies', 'optionalDependencies') + + +def targets(node, path): + """Yield (json-path, target string) for every string leaf under exports.""" + if isinstance(node, str): + yield path, node + elif isinstance(node, dict): + for key, value in node.items(): + yield from targets(value, f'{path}/{key}') + elif isinstance(node, list): + for i, value in enumerate(node): + yield from targets(value, f'{path}[{i}]') + + +def numeric_keys(node, path): + if isinstance(node, dict): + for key, value in node.items(): + if key.isdigit(): + yield f'{path}/{key}' + yield from numeric_keys(value, f'{path}/{key}') + + +def check(build): + problems = [] + manifest = build / 'package.json' + if not manifest.is_file(): + return ['no build/package.json'] + pkg = json.loads(manifest.read_text()) + + def check_path(where, target): + if not isinstance(target, str) or not target.startswith('.'): + return + if '/build/' in target or target.startswith('./build') or '/cjs/cjs/' in target: + problems.append(f'{where}: suspicious path {target}') + if '*' in target: + base = build / target.split('*', 1)[0] + if not base.parent.exists(): + problems.append(f'{where}: {target} has no directory') + return + if not (build / target).is_file(): + problems.append(f'{where}: {target} does not exist') + + for field in ENTRY_FIELDS: + value = pkg.get(field) + if isinstance(value, str): + check_path(field, value) + for where, target in targets(pkg.get('exports'), 'exports'): + check_path(where, target) + for where in numeric_keys(pkg.get('exports'), 'exports'): + problems.append(f'{where}: numeric key (a string spread into an object)') + for field in DEP_FIELDS: + for dep, rng in (pkg.get(field) or {}).items(): + if str(rng).startswith(('workspace:', 'portal:', 'link:', 'file:')): + problems.append(f'{field}.{dep}: {rng}') + return problems + + +def main(): + repo = pathlib.Path(sys.argv[1]) + names = sys.argv[2:] + builds = [repo / 'packages' / n / 'build' for n in names] if names else sorted(repo.glob('packages/*/build')) + failed = 0 + for build in builds: + problems = check(build) + name = build.parent.name + if problems: + failed += 1 + print(f'FAIL {name}') + for p in problems[:12]: + print(f' {p}') + if len(problems) > 12: + print(f' ... {len(problems) - 12} more') + else: + print(f'ok {name}') + sys.exit(1 if failed else 0) + + +if __name__ == '__main__': + main()