diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml new file mode 100644 index 0000000..dfc056e --- /dev/null +++ b/.github/workflows/merge.yml @@ -0,0 +1,41 @@ +name: Merge + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + merge: + runs-on: ubuntu-latest + if: > + github.event.issue.pull_request && + github.event.comment.body == '/merge' && + github.event.comment.author_association != 'NONE' + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + + - run: npm ci + + - name: Verify actor is authorized + uses: actions/github-script@v9 + with: + script: | + const { default: verifyMerge } = await import('./scripts/verify-merge.js'); + await verifyMerge({ github, context }); + + - name: Merge PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + gh pr merge "$PR_NUMBER" --squash diff --git a/.github/workflows/sync-codeowners.yml b/.github/workflows/sync-codeowners.yml deleted file mode 100644 index 48833b9..0000000 --- a/.github/workflows/sync-codeowners.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Sync CODEOWNERS - -on: - push: - branches: [main] - paths: - - "gaps/*/metadata.yml" - -jobs: - sync: - name: Sync CODEOWNERS from metadata - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Sync CODEOWNERS - run: node scripts/sync-codeowners.js - - - name: Commit if changed - run: | - git diff --quiet CODEOWNERS && exit 0 - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CODEOWNERS - git commit -m "Sync CODEOWNERS from GAP metadata" - git push diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 908e748..0000000 --- a/CODEOWNERS +++ /dev/null @@ -1,3 +0,0 @@ -/gaps/GAP-10/ @magicmark @rebello95 -/gaps/GAP-13/ @benjie -/gaps/GAP-7/ @magicmark diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c223c4..64e0992 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,11 +9,10 @@ that address issues outside the core GraphQL specifications. ([@graphql/gaps-editors](https://github.com/orgs/graphql/teams/gaps-editors)), approved by the TSC to administer the GAP program. - **Sponsor** — an _editor_ assigned to a GAP who is responsible for approving - the initial contents. A _sponsor_ may also be an _author_. + and merging the initial contents. A _sponsor_ may also be an _author_. - **Author** — a person (or people) who have made significant contributions to a - GAP, listed in the `authors` field of `metadata.yml`. _Authors_ are given - commit access via `CODEOWNERS` to merge their own and others' submissions to - the GAP. + GAP, listed in the `authors` field of `metadata.yml`. _Authors_ can merge PRs + that only touch their GAP directory (see [Merging](#merging) below). ## GAP Numbering @@ -62,9 +61,6 @@ gauge public interest, but doing so is not necessary. Once approved by the _authors_ and _sponsor_, the PR should be merged by the _sponsor_. -`CODEOWNERS` will automatically be updated allowing _authors_ to merge future -contributions to their GAP. - > [!IMPORTANT] > GAP numbers never change. If a proposal needs significant changes, create a > new GAP and deprecate the old one. @@ -126,6 +122,12 @@ The _sponsor_ of a GAP is responsible for ensuring changes to the GAP are approved by the _authors_ before merging, though this task may also be performed by the TSC. The _authors_ are responsible for guiding contribution to the GAP. +### Merging + +PRs that only modify files within a single `gaps/GAP-N/` directory can be merged +by any _author_ listed in that directory's `metadata.yml` by commenting `/merge` +on the PR. + ### Versioning To release a version of a GAP, copy the current `DRAFT.md` into a `versions` diff --git a/package-lock.json b/package-lock.json index e69676e..e037258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "handlebars": "^4.7.9", "lodash.memoize": "^4.1.2", "lodash.merge": "^4.6.2", + "memfs": "^4.57.2", "nodemon": "2.0.20", "p-limit": "^7.3.0", "prettier": "^3.8.1", @@ -354,6 +355,415 @@ "dev": true, "license": "MIT" }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.2", + "integrity": "sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.2", + "integrity": "sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.2", + "integrity": "sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-print": "4.57.2", + "@jsonjoy.com/fs-snapshot": "4.57.2", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.2", + "integrity": "sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.2", + "integrity": "sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.2", + "integrity": "sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.2" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.2", + "integrity": "sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.2", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.2", + "integrity": "sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/@mlarah/spec-md": { "version": "3.1.0", "integrity": "sha1-zkfFwC4q9JRVYeeXu1MCmvh1Qps=", @@ -922,6 +1332,22 @@ "node": ">= 6" } }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/global-dirs": { "version": "0.1.1", "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", @@ -970,6 +1396,15 @@ "node": ">=8" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", @@ -1186,6 +1621,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/memfs": { + "version": "4.57.2", + "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-fsa": "4.57.2", + "@jsonjoy.com/fs-node": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-to-fsa": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-print": "4.57.2", + "@jsonjoy.com/fs-snapshot": "4.57.2", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/micromatch": { "version": "4.0.8", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", @@ -1581,6 +2045,22 @@ "node": ">=8" } }, + "node_modules/thingies": { + "version": "2.6.0", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", @@ -1602,6 +2082,28 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tree-dump": { + "version": "1.1.0", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", diff --git a/package.json b/package.json index a2621eb..19e1794 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "suggest:format": "echo \"\nTo resolve this, run: $(tput bold)npm run format$(tput sgr0)\" && exit 1", "test:format": "prettier --check . || npm run suggest:format", "test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md", - "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}", - "sync:codeowners": "node scripts/sync-codeowners.js" + "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}" }, "devDependencies": { "@mlarah/spec-md": "^3.1.0", @@ -22,6 +21,7 @@ "handlebars": "^4.7.9", "lodash.memoize": "^4.1.2", "lodash.merge": "^4.6.2", + "memfs": "^4.57.2", "nodemon": "2.0.20", "p-limit": "^7.3.0", "prettier": "^3.8.1", diff --git a/scripts/sync-codeowners.js b/scripts/sync-codeowners.js deleted file mode 100755 index 35201ea..0000000 --- a/scripts/sync-codeowners.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node - -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parse as parseYaml } from "yaml"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, ".."); -const gapsDir = join(rootDir, "gaps"); - -async function getGapDirs() { - const entries = await readdir(gapsDir, { withFileTypes: true }); - return entries.filter((d) => d.isDirectory() && /^GAP-[1-9]\d*$/.test(d.name)); -} - -async function main() { - const dirs = await getGapDirs(); - dirs.sort((a, b) => parseInt(a.name.split("-")[1], 10) - parseInt(b.name.split("-")[1], 10)); - - const lines = await Promise.all( - dirs.map(async (dir) => { - const metadataPath = join(gapsDir, dir.name, "metadata.yml"); - const metadata = parseYaml(await readFile(metadataPath, "utf8")); - const owners = metadata.authors.map((a) => a.githubUsername.replace(/^@/, "")); - const ownerList = owners.map((o) => `@${o}`).join(" "); - return `/gaps/${dir.name}/ ${ownerList}`; - }), - ); - - await writeFile(join(rootDir, "CODEOWNERS"), lines.join("\n") + "\n"); -} - -main(); diff --git a/scripts/verify-merge.js b/scripts/verify-merge.js new file mode 100644 index 0000000..74154fb --- /dev/null +++ b/scripts/verify-merge.js @@ -0,0 +1,64 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { parse as parseYaml } from "yaml"; + +export default async ({ github, context }) => { + const actor = context.payload.comment.user.login; + const prNumber = context.issue.number; + + const [ + { data: pr }, + { data: files }, + ] = await Promise.all([ + github.rest.pulls.get({ ...context.repo, pull_number: prNumber }), + github.rest.pulls.listFiles({ ...context.repo, pull_number: prNumber, per_page: 100 }), + ]); + + if (files.length >= 100) { + throw new Error("PR touches too many files!"); + } + + if (pr.mergeable === null) { + throw new Error("GitHub is still computing mergeability. Try again in a moment."); + } + + if (pr.mergeable === false) { + throw new Error("PR is not in a mergeable state. Resolve conflicts and try again."); + } + + const gapDirs = new Set(); + + for (const f of files) { + const normalized = path.normalize(f.filename); + if (normalized !== f.filename || normalized.startsWith("..")) { + throw new Error(`File path "${f.filename}" contains path traversal or is not normalized.`); + } + + // e.g. 'gaps/GAP-10/versions/2026-01.md' -> 'gaps/GAP-10' + gapDirs.add(f.filename.split("/").slice(0, 2).join("/")); + } + + const gapsChanged = [...gapDirs]; + + if (gapsChanged.length !== 1 || !gapsChanged[0].match(/^gaps\/GAP-\d+$/)) { + throw new Error("You can only run /merge for PRs that touch exactly one GAP directory and nothing else."); + } + + const gapDir = gapsChanged[0]; + + for (const f of files) { + if (!f.filename.startsWith(`${gapDir}/`)) { + throw new Error(`File "${f.filename}" is outside the expected GAP directory (${gapDir}).`); + } + } + + const metadata = parseYaml(await readFile(`${gapDir}/metadata.yml`, "utf8")); + const authorizedMergers = new Set([ + ...metadata.authors.map(author => author.githubUsername.replace(/^@/, '')), + metadata.sponsor.replace(/^@/, ''), + ]); + + if (!authorizedMergers.has(actor)) { + throw new Error(`${actor} is not authorized to merge ${gapDir}.`); + } +}; diff --git a/scripts/verify-merge.test.js b/scripts/verify-merge.test.js new file mode 100644 index 0000000..63dcff3 --- /dev/null +++ b/scripts/verify-merge.test.js @@ -0,0 +1,160 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; +import { vol } from "memfs"; + +mock.module("fs/promises", { exports: vol.promises }); + +const { default: verifyMerge } = await import("./verify-merge.js"); + +function makeContext({ actor = "alice", prNumber = 1 } = {}) { + return { + payload: { comment: { user: { login: actor } } }, + issue: { number: prNumber }, + repo: { owner: "graphql", repo: "gaps" }, + }; +} + +function makeGithub({ mergeable = true, files = [] } = {}) { + return { + rest: { + pulls: { + get: mock.fn(async () => ({ data: { mergeable } })), + listFiles: mock.fn(async () => ({ data: files })), + }, + }, + }; +} + +const METADATA = ` +authors: + - name: "Alice" + email: "alice@example.com" + githubUsername: "@alice" + - name: "Bob" + email: "bob@example.com" + githubUsername: "@bob" +sponsor: "@charlie" +`; + +beforeEach(() => { + vol.reset(); + vol.fromJSON({ "gaps/GAP-10/metadata.yml": METADATA }); +}); + +describe("verify-merge", () => { + it("allows an author to merge", async () => { + const github = makeGithub({ + files: [{ filename: "gaps/GAP-10/DRAFT.md" }], + }); + + await assert.doesNotReject( + verifyMerge({ github, context: makeContext({ actor: "alice" }) }), + ); + }); + + it("allows a co-author to merge", async () => { + const github = makeGithub({ + files: [{ filename: "gaps/GAP-10/DRAFT.md" }], + }); + + await assert.doesNotReject( + verifyMerge({ github, context: makeContext({ actor: "bob" }) }), + ); + }); + + it("allows the sponsor to merge", async () => { + const github = makeGithub({ + files: [{ filename: "gaps/GAP-10/DRAFT.md" }], + }); + + await assert.doesNotReject( + verifyMerge({ github, context: makeContext({ actor: "charlie" }) }), + ); + }); + + it("rejects an unauthorized user", async () => { + const github = makeGithub({ + files: [{ filename: "gaps/GAP-10/DRAFT.md" }], + }); + + await assert.rejects( + verifyMerge({ github, context: makeContext({ actor: "eve" }) }), + /not authorized to merge/, + ); + }); + + it("rejects PRs touching multiple GAP directories", async () => { + const github = makeGithub({ + files: [ + { filename: "gaps/GAP-10/DRAFT.md" }, + { filename: "gaps/GAP-7/DRAFT.md" }, + ], + }); + + await assert.rejects( + verifyMerge({ github, context: makeContext() }), + /touch exactly one GAP/, + ); + }); + + it("rejects PRs touching files outside gaps/", async () => { + const github = makeGithub({ + files: [ + { filename: "gaps/GAP-10/DRAFT.md" }, + { filename: ".github/workflows/evil.yml" }, + ], + }); + + await assert.rejects( + verifyMerge({ github, context: makeContext() }), + /touch exactly one GAP/, + ); + }); + + it("rejects when PR is not mergeable", async () => { + const github = makeGithub({ + mergeable: false, + files: [{ filename: "gaps/GAP-10/DRAFT.md" }], + }); + + await assert.rejects( + verifyMerge({ github, context: makeContext() }), + /not in a mergeable state/, + ); + }); + + it("rejects when mergeable is null (still computing)", async () => { + const github = makeGithub({ + mergeable: null, + files: [{ filename: "gaps/GAP-10/DRAFT.md" }], + }); + + await assert.rejects( + verifyMerge({ github, context: makeContext() }), + /still computing mergeability/, + ); + }); + + it("rejects PRs with 100+ files", async () => { + const files = Array.from({ length: 100 }, (_, i) => ({ + filename: `gaps/GAP-10/file${i}.md`, + })); + const github = makeGithub({ files }); + + await assert.rejects( + verifyMerge({ github, context: makeContext() }), + /touches too many files/, + ); + }); + + it("rejects path traversal attempts", async () => { + const github = makeGithub({ + files: [{ filename: "gaps/GAP-10/../../../etc/passwd" }], + }); + + await assert.rejects( + verifyMerge({ github, context: makeContext() }), + /contains path traversal or is not normalized/, + ); + }); +});