From fbc0b3fc73b205d7cf60ac4bb122b0ac70a2e65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 11:44:47 +0800 Subject: [PATCH 1/9] docs: design merge queue support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- .../specs/2026-08-21-merge-queue-design.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-merge-queue-design.md diff --git a/docs/superpowers/specs/2026-08-21-merge-queue-design.md b/docs/superpowers/specs/2026-08-21-merge-queue-design.md new file mode 100644 index 0000000..dcaeb05 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-merge-queue-design.md @@ -0,0 +1,124 @@ +# Merge Queue Design + +## Objective + +Enable GitHub Merge Queue for `QoderAI/cloud-agents-cookbook` so pull requests can be merged sequentially against the latest `main` without maintainers repeatedly updating each branch by hand. Preserve the existing contribution trust boundary, DCO policy, required checks, squash-only history, and Ruleset protections. + +## Current state + +The repository-level `Protect main` Ruleset targets the default branch and currently enforces: + +- branch deletion and non-fast-forward updates are blocked; +- linear history is required; +- pull requests are required, conversation threads must be resolved, and only squash merge is allowed; +- required checks are `dco`, `preview`, and `validate`; +- `strict_required_status_checks_policy` is enabled; +- no actor can bypass the Ruleset. + +The three required workflows listen only to `pull_request` and depend on `github.event.pull_request.*`. GitHub dispatches the separate `merge_group.checks_requested` event for Merge Queue, so enabling the Ruleset rule before adding merge-group checks would leave the queue waiting for required checks that never report. + +## Selected approach + +Add one dedicated `.github/workflows/merge-queue.yml` instead of making the existing pull-request workflows dual-purpose. + +The dedicated workflow keeps the current PR gate unchanged and limits merge-group-specific logic to one file. It reports jobs named exactly `dco`, `preview`, and `validate`, matching the existing required status-check contexts. + +Alternatives rejected: + +1. Adding `merge_group` conditionals throughout all three existing workflows would duplicate event branching across many steps and increase the chance of changing public-fork behavior. +2. Refactoring all checks into reusable workflows would be architecturally clean but creates a larger infrastructure migration than is necessary to enable a single-entry queue. + +## Merge-group workflow + +The workflow triggers only on: + +```yaml +on: + merge_group: + types: [checks_requested] +``` + +It grants only `contents: read`, uses no repository Secrets, never writes repository state, and never executes Demo source. A PR may be marked "Merge when ready" while its pull-request checks are still running, but it becomes an active, buildable queue entry only after satisfying the existing branch requirements. Those checks prevent public contributors from changing repository infrastructure, while internal infrastructure PRs exercise proposed tooling before they become eligible for merge-group validation. + +### `dco` job + +The job checks out trusted tooling from the merge-group base SHA and the merge-group history from the head SHA. It verifies every non-merge commit in `base..head` with the existing DCO trailer rule. + +The synthetic commits created by GitHub Merge Queue are merge commits and are excluded only in the merge-group job. The existing PR `dco` job continues checking every commit, including contributor-created merge commits. Therefore an unsigned contributor commit cannot become queue-eligible, while GitHub's unsigned synthetic queue commit does not create a false failure. + +The existing `scripts/check-dco.mjs` receives a narrowly scoped `--no-merges` CLI option, with tests proving that the default PR behavior remains unchanged and the merge-group mode excludes only merge commits. + +### `preview` job + +The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, builds the static preview, and uploads an artifact named with `github.run_id`. It does not depend on a pull-request number. + +Running the repository preview tooling is safe at this stage because an active merge-group entry has already passed the pull-request contribution-scope gate. External contributors cannot place modified infrastructure into an eligible merge group. + +### `validate` job + +The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, and runs `npm run check`. This validates the combined tree containing current `main`, all earlier queue entries, and the current entry. It executes repository validation and build tooling but does not install, import, or execute Demo source. + +## Automated regression checks + +Repository tests will statically assert that: + +- the merge-queue workflow listens to `merge_group` and not `pull_request` or `push`; +- the workflow exposes jobs named `dco`, `preview`, and `validate`; +- permissions remain read-only and no Secrets or write permissions are referenced; +- the DCO job uses the trusted base implementation with merge exclusion enabled; +- preview and validate operate on the merge-group SHA and do not depend on `github.event.pull_request.*`; +- Demo source is not executed; +- default DCO behavior still rejects unsigned merge commits, while merge-group mode ignores merge commits and continues rejecting unsigned non-merge commits. + +The complete repository check remains `npm run check`. + +## Infrastructure pull request + +All workflow, script, test, and design changes are committed on `codex/enable-merge-queue` with DCO sign-off and submitted as a repository-infrastructure pull request. The PR is merged through the current strict, squash-only process after `dco`, `preview`, and `validate` pass. + +## Ruleset update + +After the infrastructure PR is merged, save the complete current Ruleset JSON and update Ruleset `20582196` through the GitHub REST API. Preserve every existing condition, rule, review parameter, required check, and empty bypass list except for these intentional changes: + +1. Add a `merge_queue` rule with: + - `check_response_timeout_minutes`: `10` + - `grouping_strategy`: `ALLGREEN` + - `max_entries_to_build`: `1` + - `max_entries_to_merge`: `1` + - `merge_method`: `SQUASH` + - `min_entries_to_merge`: `1` + - `min_entries_to_merge_wait_minutes`: `0` +2. Change `strict_required_status_checks_policy` from `true` to `false`. The merge queue now creates and validates a merge group against the latest base, so contributor branches no longer require manual synchronization. + +The update is performed with `gh api`, followed by an independent GET readback that compares all Ruleset conditions, rules, required checks, review settings, and bypass actors with the intended payload. + +## Live acceptance test + +Use the already-open PR #11 as the first queue entry after confirming it remains open, mergeable, has no unresolved review requirement, and has passing PR checks. + +Add #11 through `gh pr merge 11 --squash`. Because the Ruleset requires Merge Queue, this command must enqueue the PR instead of directly merging it. Acceptance requires: + +- a `merge_group` workflow run is created; +- `dco`, `preview`, and `validate` run on the merge-group SHA and pass; +- #11 is automatically squash-merged by the queue; +- `main` advances to the queued result; +- the Ruleset readback remains unchanged after the merge. + +No bypass option or direct push to `main` is allowed during acceptance. + +## Failure handling and rollback + +Before changing the Ruleset, write its complete API response and the exact update payload to a temporary local validation directory outside the repository. Do not include tokens or response headers. + +If the Ruleset update is rejected, stop without retrying a mutated payload until the error and saved payload are compared. If merge-group checks do not start, remain pending, or fail because of workflow configuration, restore the original Ruleset with `gh api` so normal strict PR merging is available again. Do not bypass checks or merge #11 directly. + +The merged infrastructure workflow may remain on `main` after a Ruleset rollback because it is inert unless GitHub dispatches `merge_group`. + +## Success criteria + +The configuration is complete only when: + +1. The infrastructure PR is merged with DCO and all required checks passing. +2. The active Ruleset contains the exact single-entry squash Merge Queue configuration and no unintended changes. +3. PR #11 passes all three checks on a real merge-group SHA and is automatically squash-merged. +4. The final `main` and Ruleset states are independently read back through GitHub CLI. From 8d9a098e91f5aec015777f422ab1de2ae0509e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 11:50:21 +0800 Subject: [PATCH 2/9] docs: plan merge queue rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- .../plans/2026-08-21-merge-queue.md | 693 ++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-merge-queue.md diff --git a/docs/superpowers/plans/2026-08-21-merge-queue.md b/docs/superpowers/plans/2026-08-21-merge-queue.md new file mode 100644 index 0000000..ba8d578 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-merge-queue.md @@ -0,0 +1,693 @@ +# Merge Queue Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable a conservative, squash-only GitHub Merge Queue for `QoderAI/cloud-agents-cookbook`, with real `merge_group` validation and PR #11 as the end-to-end acceptance test. + +**Architecture:** Keep the existing `pull_request` workflows unchanged and add one dedicated merge-group workflow that reports the existing `dco`, `preview`, and `validate` check contexts. Extend the DCO helper with an opt-in merge exclusion for GitHub-generated queue commits, then merge the infrastructure PR before atomically updating Ruleset `20582196` through `gh api`. + +**Tech Stack:** GitHub Actions, GitHub CLI (`gh`), GitHub Rulesets REST API, Node.js 20, `node:test`, `yaml` 2.9.0, npm. + +## Global Constraints + +- The queue configuration is exactly: `min_entries_to_merge=1`, `max_entries_to_build=1`, `max_entries_to_merge=1`, `grouping_strategy=ALLGREEN`, `merge_method=SQUASH`, `check_response_timeout_minutes=10`, and `min_entries_to_merge_wait_minutes=0`. +- Required check contexts remain exactly `dco`, `preview`, and `validate`. +- Preserve every existing Ruleset condition, pull-request parameter, protection rule, and the empty bypass list except the addition of `merge_queue` and changing `strict_required_status_checks_policy` from `true` to `false`. +- All repository commits must include `Signed-off-by: 安陈 `. +- Workflows use only SHA-pinned official GitHub Actions, `permissions: contents: read`, bounded timeouts, no Secrets, no write tokens, and no Demo source execution. +- Existing `pull_request` DCO behavior must continue checking merge commits; only the dedicated `merge_group` job passes `--no-merges`. +- Do not bypass checks, force-push, directly push `main`, or directly merge PR #11. +- If merge-group validation fails or does not complete within the configured 10-minute response window, restore the original Ruleset before attempting any workflow repair. + +--- + +## File Structure + +- Modify `scripts/check-dco.mjs`: add an opt-in `excludeMerges` range option and the `--no-merges` CLI flag. +- Modify `tests/automation.test.mjs`: test real Git histories for DCO behavior and statically enforce merge-queue workflow security/event contracts. +- Create `.github/workflows/merge-queue.yml`: run `dco`, `preview`, and `validate` for `merge_group.checks_requested`. +- Preserve `docs/superpowers/specs/2026-08-21-merge-queue-design.md`: approved design and acceptance contract. +- Create no persistent repository file for Ruleset payloads; store snapshots and request bodies only under `/private/tmp/qca-merge-queue-20260821/`. + +### Task 1: Add opt-in merge exclusion to the DCO range reader + +**Files:** +- Modify: `tests/automation.test.mjs:3-18` +- Modify: `scripts/check-dco.mjs:16-30` + +**Interfaces:** +- Consumes: `checkDcoMessages(commits: Array<{sha: string, message: string}>)`. +- Produces: `commitsInRange(repo: string, base: string, head: string, options?: {excludeMerges?: boolean}): Promise>` and CLI flag `--no-merges`. + +- [ ] **Step 1: Write a failing real-history DCO test** + +Add imports for `execFile`, `mkdtemp`, `tmpdir`, `writeFile`, and `promisify`; import `commitsInRange` beside `checkDcoMessages`. Add this test after the existing DCO unit test: + +```js +test('merge-group DCO mode excludes only merge commits', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-dco-')); + const git = (...args) => execFileAsync('git', args, { cwd: root }); + await git('init', '-b', 'main'); + await git('config', 'user.name', 'Example Author'); + await git('config', 'user.email', 'author@example.com'); + + await writeFile(path.join(root, 'base.txt'), 'base\n'); + await git('add', 'base.txt'); + await git('commit', '-m', 'docs: base', '-m', 'Signed-off-by: Example Author '); + const { stdout: baseOutput } = await git('rev-parse', 'HEAD'); + const base = baseOutput.trim(); + + await git('checkout', '-b', 'feature'); + await writeFile(path.join(root, 'feature.txt'), 'feature\n'); + await git('add', 'feature.txt'); + await git('commit', '-m', 'docs: unsigned feature'); + const { stdout: featureOutput } = await git('rev-parse', 'HEAD'); + const feature = featureOutput.trim(); + + await git('checkout', 'main'); + await writeFile(path.join(root, 'main.txt'), 'main\n'); + await git('add', 'main.txt'); + await git('commit', '-m', 'docs: main', '-m', 'Signed-off-by: Example Author '); + await git('merge', '--no-ff', 'feature', '-m', 'Merge feature'); + const { stdout: headOutput } = await git('rev-parse', 'HEAD'); + const head = headOutput.trim(); + + const defaultFailures = checkDcoMessages(await commitsInRange(root, base, head)); + assert.equal(defaultFailures.length, 2); + assert.ok(defaultFailures.some((failure) => failure.sha === feature)); + + const queueFailures = checkDcoMessages(await commitsInRange(root, base, head, { excludeMerges: true })); + assert.deepEqual(queueFailures, [{ sha: feature, message: 'Commit is missing a valid Signed-off-by trailer.' }]); +}); +``` + +Define once near the imports: + +```js +const execFileAsync = promisify(execFile); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +node --test --test-name-pattern='merge-group DCO mode' tests/automation.test.mjs +``` + +Expected: FAIL because `commitsInRange` does not yet accept or apply `excludeMerges`. + +- [ ] **Step 3: Implement the minimal range and CLI option** + +Replace `commitsInRange` with: + +```js +export async function commitsInRange(repo, base, head, { excludeMerges = false } = {}) { + const args = ['log']; + if (excludeMerges) args.push('--no-merges'); + args.push('--format=%H%x1f%B%x1e', `${base}..${head}`); + const { stdout } = await execFileAsync('git', args, { cwd: repo, maxBuffer: 10 * 1024 * 1024 }); + return stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { + const separator = record.indexOf('\x1f'); + return { sha: record.slice(0, separator), message: record.slice(separator + 1) }; + }); +} +``` + +Change `runCli()` to parse and pass the Boolean option: + +```js +const { values } = parseArgs({ + options: { + repo: { type: 'string', default: process.cwd() }, + base: { type: 'string' }, + head: { type: 'string' }, + 'no-merges': { type: 'boolean', default: false } + } +}); +if (!values.base || !values.head) throw new Error('Usage: check-dco.mjs --repo --base --head [--no-merges]'); +const failures = checkDcoMessages(await commitsInRange(values.repo, values.base, values.head, { excludeMerges: values['no-merges'] })); +``` + +Keep the existing success/failure output and exit-code handling unchanged. + +- [ ] **Step 4: Run focused and full automation tests** + +Run: + +```bash +node --test --test-name-pattern='DCO|merge-group DCO mode' tests/automation.test.mjs +node --test tests/automation.test.mjs +``` + +Expected: PASS; the default range reports the unsigned non-merge and unsigned merge commits, while `{ excludeMerges: true }` reports only the unsigned non-merge commit. + +- [ ] **Step 5: Commit the DCO change** + +```bash +git add scripts/check-dco.mjs tests/automation.test.mjs +git commit -s -m "feat: support merge-group DCO checks" +``` + +Expected trailer: `Signed-off-by: 安陈 `. + +### Task 2: Add the dedicated merge-group workflow and security contract + +**Files:** +- Modify: `tests/automation.test.mjs:32-99` +- Create: `.github/workflows/merge-queue.yml` + +**Interfaces:** +- Consumes: `scripts/check-dco.mjs --repo --base --head --no-merges` from Task 1. +- Produces: GitHub check contexts named exactly `dco`, `preview`, and `validate` for `merge_group.checks_requested`. + +- [ ] **Step 1: Write failing workflow-contract assertions** + +Change the expected workflow list to: + +```js +assert.deepEqual(files.sort(), ['dco.yml', 'merge-queue.yml', 'preview.yml', 'publish.yml', 'validate.yml']); +``` + +Apply the no-Secrets and credential checks to both PR and merge-group workflows: + +```js +if (workflow.on?.pull_request || workflow.on?.merge_group) { + assert.doesNotMatch(source, /pull_request_target/); + assert.doesNotMatch(source, /secrets\./, `${file} must not expose secrets to untrusted changes`); + assert.match(source, /persist-credentials:\s*false/); +} +``` + +Add this dedicated contract test: + +```js +test('merge queue validates the synthetic group with the existing check contexts', async () => { + const source = await readFile(path.join(repoRoot, '.github', 'workflows', 'merge-queue.yml'), 'utf8'); + const workflow = YAML.parse(source); + assert.deepEqual(workflow.on, { merge_group: { types: ['checks_requested'] } }); + assert.deepEqual(Object.keys(workflow.jobs).sort(), ['dco', 'preview', 'validate']); + assert.deepEqual(workflow.permissions, { contents: 'read' }); + for (const job of Object.values(workflow.jobs)) assert.equal(job.permissions, undefined, 'jobs must not override read-only workflow permissions'); + assert.doesNotMatch(source, /github\.event\.pull_request|pull_request_target|secrets\.|\b(?:actions|checks|contents|deployments|id-token|issues|packages|pages|pull-requests|security-events|statuses):\s*write\b/); + assert.match(source, /BASE_SHA: \${{ github\.event\.merge_group\.base_sha }}/); + assert.match(source, /HEAD_SHA: \${{ github\.event\.merge_group\.head_sha }}/); + assert.match(source, /node trusted\/scripts\/check-dco\.mjs --repo submission --base "\$BASE_SHA" --head "\$HEAD_SHA" --no-merges/); + assert.match(source, /node submission\/scripts\/build-preview\.mjs --root submission --contract-root submission --out-dir artifacts\/preview/); + assert.match(source, /cookbook-preview-\${{ github\.run_id }}/); + assert.match(source, /working-directory: submission\n\s+run: npm run check/); + assert.doesNotMatch(source, /working-directory:\s*submission\/demos|npm\s+--prefix\s+demos|docker\s+build|make\s+(?:-[^\s]+\s+)*demos/i); +}); +``` + +Include `merge-queue.yml` in the `automationSource` array used by the existing Demo-execution test. + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +node --test --test-name-pattern='workflows pin|merge queue validates|trusted automation' tests/automation.test.mjs +``` + +Expected: FAIL because `.github/workflows/merge-queue.yml` does not exist. + +- [ ] **Step 3: Create the merge-group workflow** + +Create `.github/workflows/merge-queue.yml` with exactly this structure: + +```yaml +name: Validate merge queue + +on: + merge_group: + types: [checks_requested] + +permissions: + contents: read + +concurrency: + group: merge-queue-${{ github.event.merge_group.head_ref }} + cancel-in-progress: true + +jobs: + dco: + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + BASE_SHA: ${{ github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.merge_group.head_sha }} + steps: + - name: Check out trusted tooling + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.BASE_SHA }} + path: trusted + persist-credentials: false + - name: Check out merge-group history + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.HEAD_SHA }} + path: submission + fetch-depth: 0 + persist-credentials: false + - name: Use Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + - name: Check every non-merge commit sign-off + run: node trusted/scripts/check-dco.mjs --repo submission --base "$BASE_SHA" --head "$HEAD_SHA" --no-merges + + preview: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out merge-group tree + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.merge_group.head_sha }} + path: submission + persist-credentials: false + - name: Use Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + cache: npm + cache-dependency-path: submission/package-lock.json + - name: Install merge-group dependencies + working-directory: submission + run: npm ci --ignore-scripts + - name: Build merge-group preview + run: node submission/scripts/build-preview.mjs --root submission --contract-root submission --out-dir artifacts/preview + - name: Upload preview artifact + id: preview-artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cookbook-preview-${{ github.run_id }} + path: artifacts/preview + retention-days: 3 + if-no-files-found: error + + validate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out merge-group tree + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.merge_group.head_sha }} + path: submission + persist-credentials: false + - name: Use Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + cache: npm + cache-dependency-path: submission/package-lock.json + - name: Install merge-group dependencies + working-directory: submission + run: npm ci --ignore-scripts + - name: Validate merge-group tree + working-directory: submission + run: npm run check +``` + +- [ ] **Step 4: Run focused workflow tests** + +Run: + +```bash +node --test --test-name-pattern='workflows pin|merge queue validates|trusted automation' tests/automation.test.mjs +``` + +Expected: PASS, including the exact event, job names, SHA pinning, permissions, artifact naming, and Demo non-execution assertions. + +- [ ] **Step 5: Run the complete repository check** + +Run: + +```bash +git diff --check +npm run check +``` + +Expected: all Node tests pass; content, Demo-as-data, links, catalog, and preview checks report zero errors. + +- [ ] **Step 6: Commit the workflow and tests** + +```bash +git add .github/workflows/merge-queue.yml tests/automation.test.mjs +git commit -s -m "ci: validate merge queue groups" +``` + +Expected trailer: `Signed-off-by: 安陈 `. + +### Task 3: Publish and merge the infrastructure pull request + +**Files:** +- Verify: `docs/superpowers/specs/2026-08-21-merge-queue-design.md` +- Verify: `docs/superpowers/plans/2026-08-21-merge-queue.md` +- Verify: `.github/workflows/merge-queue.yml` +- Verify: `scripts/check-dco.mjs` +- Verify: `tests/automation.test.mjs` + +**Interfaces:** +- Consumes: the signed commits and passing `npm run check` from Tasks 1-2. +- Produces: a merged infrastructure commit on `origin/main` containing the `merge_group` workflow before the Ruleset starts requiring it. + +- [ ] **Step 1: Reconcile with the latest base without rewriting history** + +Run: + +```bash +git fetch origin main +git status --short --branch +git log --oneline --decorate --max-count=5 +``` + +If `origin/main` advanced, merge it with a signed merge commit, rerun `npm run check`, and push the resulting history normally: + +```bash +git merge --no-ff --no-edit --signoff origin/main +npm run check +``` + +Do not rebase or force-push. + +- [ ] **Step 2: Push the feature branch** + +```bash +git push -u origin codex/enable-merge-queue +``` + +Expected: the remote branch points to the locally verified signed history. + +- [ ] **Step 3: Create the ready-for-review infrastructure PR** + +Create a temporary PR body containing: + +```markdown +## Summary + +- add dedicated `merge_group` validation with the existing `dco`, `preview`, and `validate` contexts +- ignore only GitHub-generated merge commits in merge-group DCO checks +- preserve the existing pull-request trust boundary and Demo-as-data policy + +## Validation + +- `npm run check` +- real Merge Queue acceptance will run after this PR lands and Ruleset `20582196` is updated + +Signed-off-by: 安陈 +``` + +Then run: + +```bash +gh pr create --repo QoderAI/cloud-agents-cookbook --base main --head codex/enable-merge-queue --title "ci: enable merge queue validation" --body-file /private/tmp/qca-merge-queue-20260821/pr-body.md +``` + +Expected: a non-draft PR URL. + +- [ ] **Step 4: Verify the PR diff and checks** + +Run: + +```bash +gh pr view --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,files,commits,statusCheckRollup,url +gh pr checks --repo QoderAI/cloud-agents-cookbook +``` + +Poll the second command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; the files are limited to the approved spec, plan, DCO script, automation test, and merge-queue workflow. + +- [ ] **Step 5: Handle a newly stale infrastructure PR if necessary** + +If `mergeStateStatus` becomes `BEHIND`, run: + +```bash +git fetch origin main +git merge --no-ff --no-edit --signoff origin/main +npm run check +git push origin codex/enable-merge-queue +``` + +Then wait again for all three PR checks. Do not use GitHub's unqualified force-update or bypass options. + +- [ ] **Step 6: Squash-merge the infrastructure PR through the current Ruleset** + +Resolve the PR number from the current feature branch and merge that exact PR: + +```bash +gh pr merge "$(gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json number --jq .number)" --repo QoderAI/cloud-agents-cookbook --squash +``` + +Expected: the branch lookup returns the newly created infrastructure PR and it merges without bypass after all required checks pass. Never reuse a historical PR number. + +- [ ] **Step 7: Verify the workflow is on `main`** + +```bash +git fetch origin main +git show origin/main:.github/workflows/merge-queue.yml +gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json state,mergedAt,mergeCommit,url +``` + +Expected: PR state `MERGED`, a non-null `mergedAt`, and the workflow on `origin/main` contains `merge_group: types: [checks_requested]`. + +### Task 4: Atomically enable Merge Queue in Ruleset 20582196 + +**Files:** +- Create outside repository: `/private/tmp/qca-merge-queue-20260821/ruleset-before.json` +- Create outside repository: `/private/tmp/qca-merge-queue-20260821/ruleset-enable.json` +- Create outside repository: `/private/tmp/qca-merge-queue-20260821/ruleset-restore.json` + +**Interfaces:** +- Consumes: merged `.github/workflows/merge-queue.yml` on `main` from Task 3. +- Produces: active Ruleset `20582196` requiring a single-entry squash Merge Queue with non-strict branch freshness. + +- [ ] **Step 1: Read and preserve the live Ruleset** + +Run this read-only command and capture the complete JSON output exactly, without headers or credentials: + +```bash +gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 +``` + +Save that exact output as `/private/tmp/qca-merge-queue-20260821/ruleset-before.json`. Confirm its `updated_at` before proceeding and stop if its rules differ from the approved design. + +- [ ] **Step 2: Create the exact enable payload** + +Create `/private/tmp/qca-merge-queue-20260821/ruleset-enable.json` with: + +```json +{ + "name": "Protect main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "exclude": [], + "include": ["~DEFAULT_BRANCH"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "dismissal_restriction": { "enabled": false, "allowed_actors": [] }, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "merge_queue", + "parameters": { + "check_response_timeout_minutes": 10, + "grouping_strategy": "ALLGREEN", + "max_entries_to_build": 1, + "max_entries_to_merge": 1, + "merge_method": "SQUASH", + "min_entries_to_merge": 1, + "min_entries_to_merge_wait_minutes": 0 + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "dco" }, + { "context": "preview" }, + { "context": "validate" } + ] + } + } + ] +} +``` + +- [ ] **Step 3: Create the exact rollback payload** + +Create `/private/tmp/qca-merge-queue-20260821/ruleset-restore.json` with: + +```json +{ + "name": "Protect main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "exclude": [], + "include": ["~DEFAULT_BRANCH"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "dismissal_restriction": { "enabled": false, "allowed_actors": [] }, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "dco" }, + { "context": "preview" }, + { "context": "validate" } + ] + } + } + ] +} +``` + +Before mutation, compare `ruleset-restore.json` against the filtered fields from `ruleset-before.json`; they must be identical for `name`, `target`, `enforcement`, `bypass_actors`, `conditions`, and `rules`. + +- [ ] **Step 4: Update the Ruleset once** + +```bash +gh api --method PUT repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 --input /private/tmp/qca-merge-queue-20260821/ruleset-enable.json +``` + +Expected: HTTP success and a response containing the new `merge_queue` rule. If rejected, stop and compare the response error with the saved payload; do not retry a changed payload speculatively. + +- [ ] **Step 5: Independently read back every protected field** + +Run: + +```bash +gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 +``` + +Verify all of the following from the fresh GET response: + +```text +name = Protect main +target = branch +enforcement = active +conditions.ref_name.exclude = [] +conditions.ref_name.include = ["~DEFAULT_BRANCH"] +bypass_actors = [] +rule types = deletion, non_fast_forward, required_linear_history, pull_request, merge_queue, required_status_checks +allowed_merge_methods = ["squash"] +required_review_thread_resolution = true +require_extra_approval_for_unattributed_changes = true +strict_required_status_checks_policy = false +required checks = dco, preview, validate +merge queue = 10 / ALLGREEN / 1 / 1 / SQUASH / 1 / 0 +``` + +Stop and restore immediately if any field differs. + +### Task 5: Run the real queue acceptance test with PR #11 + +**Files:** +- Read only: `/private/tmp/qca-merge-queue-20260821/ruleset-before.json` +- Read only: `/private/tmp/qca-merge-queue-20260821/ruleset-restore.json` + +**Interfaces:** +- Consumes: the active Merge Queue Ruleset and merged workflow. +- Produces: PR #11 automatically squash-merged by a passing real `merge_group` run, or a restored pre-queue Ruleset if acceptance fails. + +- [ ] **Step 1: Revalidate PR #11 immediately before enqueueing** + +```bash +gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefName,baseRefName,url +``` + +Expected: `state=OPEN`, `isDraft=false`, `mergeable=MERGEABLE`, base `main`, no unresolved review requirement, and the PR-level `dco`, `preview`, and `validate` conclusions are `SUCCESS`. A `BEHIND` status is acceptable because the Merge Queue now validates the synthetic group. + +- [ ] **Step 2: Submit PR #11 to the queue** + +```bash +gh pr merge 11 --repo QoderAI/cloud-agents-cookbook --squash +``` + +Expected: GitHub queues the PR rather than directly merging it. + +- [ ] **Step 3: Locate and monitor the real merge-group run** + +Poll no more frequently than every 15 seconds: + +```bash +gh run list --repo QoderAI/cloud-agents-cookbook --workflow merge-queue.yml --event merge_group --limit 5 --json databaseId,status,conclusion,headSha,event,createdAt,url +``` + +After a newly created run appears, resolve the newest merge-queue run ID directly and poll it: + +```bash +gh run view "$(gh run list --repo QoderAI/cloud-agents-cookbook --workflow merge-queue.yml --event merge_group --limit 1 --json databaseId --jq '.[0].databaseId')" --repo QoderAI/cloud-agents-cookbook --json databaseId,status,conclusion,jobs,headSha,event,url +``` + +Expected within the configured 10-minute response window: event `merge_group`; jobs `dco`, `preview`, and `validate`; all three conclude `success`. Confirm the run `createdAt` is later than the enqueue action before treating it as acceptance evidence. + +- [ ] **Step 4: Verify automatic squash merge and final state** + +```bash +gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json state,mergedAt,mergeCommit,statusCheckRollup,url +git fetch origin main +git log origin/main --oneline --decorate --max-count=3 +gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 +``` + +Expected: PR #11 is `MERGED`, `mergedAt` and `mergeCommit` are non-null, `origin/main` contains the queued squash result, and the Ruleset is byte-for-field equivalent to the verified post-update configuration from Task 4. + +- [ ] **Step 5: Roll back on any acceptance failure** + +If the merge-group run does not appear, remains incomplete beyond 10 minutes, or any required job fails, do not bypass or directly merge PR #11. Run once: + +```bash +gh api --method PUT repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 --input /private/tmp/qca-merge-queue-20260821/ruleset-restore.json +gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 +``` + +Expected: no `merge_queue` rule, `strict_required_status_checks_policy=true`, required checks still exactly `dco`, `preview`, and `validate`, and every other rule unchanged. Report the failed run URL and leave the merged workflow inert on `main` for a follow-up repair PR. + +- [ ] **Step 6: Report completion evidence** + +Report the infrastructure PR URL and merge SHA, real merge-group Actions run URL and three job conclusions, PR #11 merge SHA, final `origin/main` SHA, Ruleset ID and exact queue parameters, and whether rollback was needed. From 0fc0f96f5270f1d8f92a5a5c6c81c94288abbb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 12:19:13 +0800 Subject: [PATCH 3/9] feat: support merge-group DCO checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- scripts/check-dco.mjs | 20 ++++++++++++----- tests/automation.test.mjs | 45 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/scripts/check-dco.mjs b/scripts/check-dco.mjs index b3525d2..6eddf59 100644 --- a/scripts/check-dco.mjs +++ b/scripts/check-dco.mjs @@ -13,8 +13,11 @@ export function checkDcoMessages(commits) { return commits.filter((commit) => !trailerPattern.test(commit.message)).map((commit) => ({ sha: commit.sha, message: 'Commit is missing a valid Signed-off-by trailer.' })); } -export async function commitsInRange(repo, base, head) { - const { stdout } = await execFileAsync('git', ['log', '--format=%H%x1f%B%x1e', `${base}..${head}`], { cwd: repo, maxBuffer: 10 * 1024 * 1024 }); +export async function commitsInRange(repo, base, head, { excludeMerges = false } = {}) { + const args = ['log']; + if (excludeMerges) args.push('--no-merges'); + args.push('--format=%H%x1f%B%x1e', `${base}..${head}`); + const { stdout } = await execFileAsync('git', args, { cwd: repo, maxBuffer: 10 * 1024 * 1024 }); return stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { const separator = record.indexOf('\x1f'); return { sha: record.slice(0, separator), message: record.slice(separator + 1) }; @@ -22,9 +25,16 @@ export async function commitsInRange(repo, base, head) { } async function runCli() { - const { values } = parseArgs({ options: { repo: { type: 'string', default: process.cwd() }, base: { type: 'string' }, head: { type: 'string' } } }); - if (!values.base || !values.head) throw new Error('Usage: check-dco.mjs --repo --base --head '); - const failures = checkDcoMessages(await commitsInRange(values.repo, values.base, values.head)); + const { values } = parseArgs({ + options: { + repo: { type: 'string', default: process.cwd() }, + base: { type: 'string' }, + head: { type: 'string' }, + 'no-merges': { type: 'boolean', default: false } + } + }); + if (!values.base || !values.head) throw new Error('Usage: check-dco.mjs --repo --base --head [--no-merges]'); + const failures = checkDcoMessages(await commitsInRange(values.repo, values.base, values.head, { excludeMerges: values['no-merges'] })); for (const failure of failures) console.error(`${failure.sha.slice(0, 12)}: ${failure.message}`); console.log(failures.length ? `${failures.length} commit(s) failed DCO.` : 'Every pull-request commit has a valid DCO sign-off.'); if (failures.length) process.exitCode = 1; diff --git a/tests/automation.test.mjs b/tests/automation.test.mjs index e7013cd..9a5187c 100644 --- a/tests/automation.test.mjs +++ b/tests/automation.test.mjs @@ -2,13 +2,18 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readdir, readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; import YAML from 'yaml'; -import { checkDcoMessages } from '../scripts/check-dco.mjs'; +import { checkDcoMessages, commitsInRange } from '../scripts/check-dco.mjs'; import { checkContributionScope } from '../scripts/check-contribution-scope.mjs'; import { repoRoot } from './helpers.mjs'; +const execFileAsync = promisify(execFile); + test('DCO check requires a valid Signed-off-by trailer in every commit', () => { const result = checkDcoMessages([ { sha: 'aaa111', message: 'docs: valid\n\nSigned-off-by: Example Author ' }, @@ -17,6 +22,42 @@ test('DCO check requires a valid Signed-off-by trailer in every commit', () => { assert.deepEqual(result, [{ sha: 'bbb222', message: 'Commit is missing a valid Signed-off-by trailer.' }]); }); +test('merge-group DCO mode excludes only merge commits', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-dco-')); + const git = (...args) => execFileAsync('git', args, { cwd: root }); + await git('init', '-b', 'main'); + await git('config', 'user.name', 'Example Author'); + await git('config', 'user.email', 'author@example.com'); + + await writeFile(path.join(root, 'base.txt'), 'base\n'); + await git('add', 'base.txt'); + await git('commit', '-m', 'docs: base', '-m', 'Signed-off-by: Example Author '); + const { stdout: baseOutput } = await git('rev-parse', 'HEAD'); + const base = baseOutput.trim(); + + await git('checkout', '-b', 'feature'); + await writeFile(path.join(root, 'feature.txt'), 'feature\n'); + await git('add', 'feature.txt'); + await git('commit', '-m', 'docs: unsigned feature'); + const { stdout: featureOutput } = await git('rev-parse', 'HEAD'); + const feature = featureOutput.trim(); + + await git('checkout', 'main'); + await writeFile(path.join(root, 'main.txt'), 'main\n'); + await git('add', 'main.txt'); + await git('commit', '-m', 'docs: main', '-m', 'Signed-off-by: Example Author '); + await git('merge', '--no-ff', 'feature', '-m', 'Merge feature'); + const { stdout: headOutput } = await git('rev-parse', 'HEAD'); + const head = headOutput.trim(); + + const defaultFailures = checkDcoMessages(await commitsInRange(root, base, head)); + assert.equal(defaultFailures.length, 2); + assert.ok(defaultFailures.some((failure) => failure.sha === feature)); + + const queueFailures = checkDcoMessages(await commitsInRange(root, base, head, { excludeMerges: true })); + assert.deepEqual(queueFailures, [{ sha: feature, message: 'Commit is missing a valid Signed-off-by trailer.' }]); +}); + test('external content contributions cannot change repository infrastructure', () => { assert.deepEqual(checkContributionScope(['content/zh-CN/recipes/example/index.md'], { allowInfrastructure: false }), []); assert.deepEqual(checkContributionScope([ From acf8769621156cf44b0e963ac1e1b6defc4fddc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 12:22:25 +0800 Subject: [PATCH 4/9] test: isolate DCO git fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- tests/automation.test.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/automation.test.mjs b/tests/automation.test.mjs index 9a5187c..9f29ac8 100644 --- a/tests/automation.test.mjs +++ b/tests/automation.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -24,7 +24,14 @@ test('DCO check requires a valid Signed-off-by trailer in every commit', () => { test('merge-group DCO mode excludes only merge commits', async () => { const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-dco-')); - const git = (...args) => execFileAsync('git', args, { cwd: root }); + const hooksPath = path.join(root, 'hooks'); + await mkdir(hooksPath); + const git = (...args) => execFileAsync('git', [ + '-c', 'commit.gpgSign=false', + '-c', 'merge.gpgSign=false', + '-c', `core.hooksPath=${hooksPath}`, + ...args + ], { cwd: root }); await git('init', '-b', 'main'); await git('config', 'user.name', 'Example Author'); await git('config', 'user.email', 'author@example.com'); From d07115e697e100639f164e621487f07ffcfdfed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 12:25:14 +0800 Subject: [PATCH 5/9] ci: validate merge queue groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- .github/workflows/merge-queue.yml | 93 +++++++++++++++++++++++++++++++ tests/automation.test.mjs | 25 +++++++-- 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/merge-queue.yml diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml new file mode 100644 index 0000000..f8da026 --- /dev/null +++ b/.github/workflows/merge-queue.yml @@ -0,0 +1,93 @@ +name: Validate merge queue + +on: + merge_group: + types: [checks_requested] + +permissions: + contents: read + +concurrency: + group: merge-queue-${{ github.event.merge_group.head_ref }} + cancel-in-progress: true + +jobs: + dco: + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + BASE_SHA: ${{ github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.merge_group.head_sha }} + steps: + - name: Check out trusted tooling + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.BASE_SHA }} + path: trusted + persist-credentials: false + - name: Check out merge-group history + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.HEAD_SHA }} + path: submission + fetch-depth: 0 + persist-credentials: false + - name: Use Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + - name: Check every non-merge commit sign-off + run: node trusted/scripts/check-dco.mjs --repo submission --base "$BASE_SHA" --head "$HEAD_SHA" --no-merges + + preview: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out merge-group tree + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.merge_group.head_sha }} + path: submission + persist-credentials: false + - name: Use Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + cache: npm + cache-dependency-path: submission/package-lock.json + - name: Install merge-group dependencies + working-directory: submission + run: npm ci --ignore-scripts + - name: Build merge-group preview + run: node submission/scripts/build-preview.mjs --root submission --contract-root submission --out-dir artifacts/preview + - name: Upload preview artifact + id: preview-artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cookbook-preview-${{ github.run_id }} + path: artifacts/preview + retention-days: 3 + if-no-files-found: error + + validate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out merge-group tree + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.merge_group.head_sha }} + path: submission + persist-credentials: false + - name: Use Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + cache: npm + cache-dependency-path: submission/package-lock.json + - name: Install merge-group dependencies + working-directory: submission + run: npm ci --ignore-scripts + - name: Validate merge-group tree + working-directory: submission + run: npm run check diff --git a/tests/automation.test.mjs b/tests/automation.test.mjs index 9f29ac8..fa68cd9 100644 --- a/tests/automation.test.mjs +++ b/tests/automation.test.mjs @@ -80,7 +80,7 @@ test('external content contributions cannot change repository infrastructure', ( test('workflows pin actions and isolate public pull requests from secrets and write tokens', async () => { const directory = path.join(repoRoot, '.github', 'workflows'); const files = (await readdir(directory)).filter((file) => file.endsWith('.yml')); - assert.deepEqual(files.sort(), ['dco.yml', 'preview.yml', 'publish.yml', 'validate.yml']); + assert.deepEqual(files.sort(), ['dco.yml', 'merge-queue.yml', 'preview.yml', 'publish.yml', 'validate.yml']); for (const file of files) { const source = await readFile(path.join(directory, file), 'utf8'); @@ -93,14 +93,31 @@ test('workflows pin actions and isolate public pull requests from secrets and wr if (step.uses) assert.match(step.uses, /^actions\/[a-z-]+@[a-f0-9]{40}$/, `${file} must pin '${step.uses}' to a commit SHA`); } } - if (workflow.on?.pull_request) { + if (workflow.on?.pull_request || workflow.on?.merge_group) { assert.doesNotMatch(source, /pull_request_target/); - assert.doesNotMatch(source, /secrets\./, `${file} must not expose secrets to pull requests`); + assert.doesNotMatch(source, /secrets\./, `${file} must not expose secrets to untrusted changes`); assert.match(source, /persist-credentials:\s*false/); } } }); +test('merge queue validates the synthetic group with the existing check contexts', async () => { + const source = await readFile(path.join(repoRoot, '.github', 'workflows', 'merge-queue.yml'), 'utf8'); + const workflow = YAML.parse(source); + assert.deepEqual(workflow.on, { merge_group: { types: ['checks_requested'] } }); + assert.deepEqual(Object.keys(workflow.jobs).sort(), ['dco', 'preview', 'validate']); + assert.deepEqual(workflow.permissions, { contents: 'read' }); + for (const job of Object.values(workflow.jobs)) assert.equal(job.permissions, undefined, 'jobs must not override read-only workflow permissions'); + assert.doesNotMatch(source, /github\.event\.pull_request|pull_request_target|secrets\.|\b(?:actions|checks|contents|deployments|id-token|issues|packages|pages|pull-requests|security-events|statuses):\s*write\b/); + assert.match(source, /BASE_SHA: \${{ github\.event\.merge_group\.base_sha }}/); + assert.match(source, /HEAD_SHA: \${{ github\.event\.merge_group\.head_sha }}/); + assert.match(source, /node trusted\/scripts\/check-dco\.mjs --repo submission --base "\$BASE_SHA" --head "\$HEAD_SHA" --no-merges/); + assert.match(source, /node submission\/scripts\/build-preview\.mjs --root submission --contract-root submission --out-dir artifacts\/preview/); + assert.match(source, /cookbook-preview-\${{ github\.run_id }}/); + assert.match(source, /working-directory: submission\n\s+run: npm run check/); + assert.doesNotMatch(source, /working-directory:\s*submission\/demos|npm\s+--prefix\s+demos|docker\s+build|make\s+(?:-[^\s]+\s+)*demos/i); +}); + test('maintainer infrastructure pull requests exercise the proposed tooling', async () => { const validateSource = await readFile(path.join(repoRoot, '.github', 'workflows', 'validate.yml'), 'utf8'); assert.match(validateSource, /name: Install proposed dependencies/); @@ -128,7 +145,7 @@ test('trusted automation validates Demo source as data without executing it', as const automationSource = (await Promise.all([ readFile(path.join(repoRoot, 'package.json'), 'utf8'), - ...['validate.yml', 'preview.yml', 'publish.yml', 'dco.yml'].map((name) => readFile(path.join(repoRoot, '.github', 'workflows', name), 'utf8')) + ...['validate.yml', 'preview.yml', 'publish.yml', 'dco.yml', 'merge-queue.yml'].map((name) => readFile(path.join(repoRoot, '.github', 'workflows', name), 'utf8')) ])).join('\n'); assert.doesNotMatch(automationSource, /working-directory:\s*submission\/demos|npm\s+--prefix\s+demos|docker\s+build|make\s+(?:-[^\s]+\s+)*demos/i); }); From e324b4c6cf396b60130e09617e952ed5116bc59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 14:11:46 +0800 Subject: [PATCH 6/9] fix: preserve merge queue trust boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- .github/workflows/merge-queue.yml | 24 +-- .../plans/2026-08-21-merge-queue.md | 191 +++++++----------- .../specs/2026-08-21-merge-queue-design.md | 22 +- package.json | 2 +- scripts/check-dco.mjs | 20 +- tests/automation.test.mjs | 81 ++++---- 6 files changed, 122 insertions(+), 218 deletions(-) diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index f8da026..88da0da 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -15,29 +15,9 @@ jobs: dco: runs-on: ubuntu-latest timeout-minutes: 5 - env: - BASE_SHA: ${{ github.event.merge_group.base_sha }} - HEAD_SHA: ${{ github.event.merge_group.head_sha }} steps: - - name: Check out trusted tooling - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ env.BASE_SHA }} - path: trusted - persist-credentials: false - - name: Check out merge-group history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ env.HEAD_SHA }} - path: submission - fetch-depth: 0 - persist-credentials: false - - name: Use Node.js 20 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 20 - - name: Check every non-merge commit sign-off - run: node trusted/scripts/check-dco.mjs --repo submission --base "$BASE_SHA" --head "$HEAD_SHA" --no-merges + - name: Confirm DCO admission gate + run: printf '%s\n' 'Required pull-request checks, including DCO, passed before this merge group became active.' preview: runs-on: ubuntu-latest diff --git a/docs/superpowers/plans/2026-08-21-merge-queue.md b/docs/superpowers/plans/2026-08-21-merge-queue.md index ba8d578..484ea3d 100644 --- a/docs/superpowers/plans/2026-08-21-merge-queue.md +++ b/docs/superpowers/plans/2026-08-21-merge-queue.md @@ -4,7 +4,7 @@ **Goal:** Enable a conservative, squash-only GitHub Merge Queue for `QoderAI/cloud-agents-cookbook`, with real `merge_group` validation and PR #11 as the end-to-end acceptance test. -**Architecture:** Keep the existing `pull_request` workflows unchanged and add one dedicated merge-group workflow that reports the existing `dco`, `preview`, and `validate` check contexts. Extend the DCO helper with an opt-in merge exclusion for GitHub-generated queue commits, then merge the infrastructure PR before atomically updating Ruleset `20582196` through `gh api`. +**Architecture:** Keep the existing `pull_request` workflows and DCO helper unchanged, and add one dedicated merge-group workflow that reports the existing `dco`, `preview`, and `validate` check contexts. The pull-request `dco` check remains authoritative; the merge-group `dco` job is a single-step queue-admission attestation, while `preview` and `validate` exercise the synthetic merge-group tree. Scope Node test discovery to `tests/*.test.mjs`, then merge the infrastructure PR before atomically updating Ruleset `20582196` through `gh api`. **Tech Stack:** GitHub Actions, GitHub CLI (`gh`), GitHub Rulesets REST API, Node.js 20, `node:test`, `yaml` 2.9.0, npm. @@ -15,7 +15,9 @@ - Preserve every existing Ruleset condition, pull-request parameter, protection rule, and the empty bypass list except the addition of `merge_queue` and changing `strict_required_status_checks_policy` from `true` to `false`. - All repository commits must include `Signed-off-by: 安陈 `. - Workflows use only SHA-pinned official GitHub Actions, `permissions: contents: read`, bounded timeouts, no Secrets, no write tokens, and no Demo source execution. -- Existing `pull_request` DCO behavior must continue checking merge commits; only the dedicated `merge_group` job passes `--no-merges`. +- Existing `pull_request` DCO behavior and `scripts/check-dco.mjs` remain unchanged and continue checking every contributor commit. +- The pull-request `dco` context must remain required before and after queue enablement; the merge-group job named `dco` only attests that this admission gate passed. +- The root test command is exactly `node --test tests/*.test.mjs`; a Demo sentinel must prove that `demos/**/test.js` is never discovered or executed. - Do not bypass checks, force-push, directly push `main`, or directly merge PR #11. - If merge-group validation fails or does not complete within the configured 10-minute response window, restore the original Ruleset before attempting any workflow repair. @@ -23,130 +25,89 @@ ## File Structure -- Modify `scripts/check-dco.mjs`: add an opt-in `excludeMerges` range option and the `--no-merges` CLI flag. -- Modify `tests/automation.test.mjs`: test real Git histories for DCO behavior and statically enforce merge-queue workflow security/event contracts. +- Modify `package.json`: scope `npm test` to `node --test tests/*.test.mjs`. +- Modify `tests/automation.test.mjs`: add the Demo test-discovery sentinel and statically enforce merge-queue workflow security/event/admission contracts. - Create `.github/workflows/merge-queue.yml`: run `dco`, `preview`, and `validate` for `merge_group.checks_requested`. - Preserve `docs/superpowers/specs/2026-08-21-merge-queue-design.md`: approved design and acceptance contract. - Create no persistent repository file for Ruleset payloads; store snapshots and request bodies only under `/private/tmp/qca-merge-queue-20260821/`. -### Task 1: Add opt-in merge exclusion to the DCO range reader +### Task 1: Scope Node test discovery and prove Demo source stays inert **Files:** -- Modify: `tests/automation.test.mjs:3-18` -- Modify: `scripts/check-dco.mjs:16-30` +- Modify: `package.json:11` +- Modify: `tests/automation.test.mjs` **Interfaces:** -- Consumes: `checkDcoMessages(commits: Array<{sha: string, message: string}>)`. -- Produces: `commitsInRange(repo: string, base: string, head: string, options?: {excludeMerges?: boolean}): Promise>` and CLI flag `--no-merges`. +- Consumes: the root `npm test` script. +- Produces: deterministic discovery of repository tests under `tests/*.test.mjs`, with no automatic execution of files under `demos/`. -- [ ] **Step 1: Write a failing real-history DCO test** +- [ ] **Step 1: Write a failing Demo test-discovery sentinel** -Add imports for `execFile`, `mkdtemp`, `tmpdir`, `writeFile`, and `promisify`; import `commitsInRange` beside `checkDcoMessages`. Add this test after the existing DCO unit test: +Add imports for `execFile`, `mkdir`, `mkdtemp`, `tmpdir`, `writeFile`, and `promisify`, then define `execFileAsync = promisify(execFile)`. Add this test to `tests/automation.test.mjs`: ```js -test('merge-group DCO mode excludes only merge commits', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-dco-')); - const git = (...args) => execFileAsync('git', args, { cwd: root }); - await git('init', '-b', 'main'); - await git('config', 'user.name', 'Example Author'); - await git('config', 'user.email', 'author@example.com'); - - await writeFile(path.join(root, 'base.txt'), 'base\n'); - await git('add', 'base.txt'); - await git('commit', '-m', 'docs: base', '-m', 'Signed-off-by: Example Author '); - const { stdout: baseOutput } = await git('rev-parse', 'HEAD'); - const base = baseOutput.trim(); - - await git('checkout', '-b', 'feature'); - await writeFile(path.join(root, 'feature.txt'), 'feature\n'); - await git('add', 'feature.txt'); - await git('commit', '-m', 'docs: unsigned feature'); - const { stdout: featureOutput } = await git('rev-parse', 'HEAD'); - const feature = featureOutput.trim(); - - await git('checkout', 'main'); - await writeFile(path.join(root, 'main.txt'), 'main\n'); - await git('add', 'main.txt'); - await git('commit', '-m', 'docs: main', '-m', 'Signed-off-by: Example Author '); - await git('merge', '--no-ff', 'feature', '-m', 'Merge feature'); - const { stdout: headOutput } = await git('rev-parse', 'HEAD'); - const head = headOutput.trim(); - - const defaultFailures = checkDcoMessages(await commitsInRange(root, base, head)); - assert.equal(defaultFailures.length, 2); - assert.ok(defaultFailures.some((failure) => failure.sha === feature)); - - const queueFailures = checkDcoMessages(await commitsInRange(root, base, head, { excludeMerges: true })); - assert.deepEqual(queueFailures, [{ sha: feature, message: 'Commit is missing a valid Signed-off-by trailer.' }]); +test('npm test discovers only repository tests and never executes Demo source', async () => { + const packageJson = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-test-discovery-')); + await mkdir(path.join(root, 'tests'), { recursive: true }); + await mkdir(path.join(root, 'demos', 'example'), { recursive: true }); + await writeFile(path.join(root, 'package.json'), JSON.stringify({ + private: true, + type: 'module', + scripts: { test: packageJson.scripts.test } + })); + await writeFile(path.join(root, 'tests', 'safe.test.mjs'), ` + import test from 'node:test'; + test('SAFE_FIXTURE_TEST', () => {}); + `); + await writeFile(path.join(root, 'demos', 'example', 'test.js'), ` + throw new Error('DEMO_EXECUTED_SENTINEL'); + `); + + const env = { ...process.env }; + delete env.NODE_TEST_CONTEXT; + const result = await execFileAsync('npm', ['test'], { cwd: root, env }).catch((error) => error); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + assert.match(output, /SAFE_FIXTURE_TEST/); + assert.doesNotMatch(output, /DEMO_EXECUTED_SENTINEL/); + assert.equal(result.code ?? 0, 0, output); }); ``` -Define once near the imports: - -```js -const execFileAsync = promisify(execFile); -``` - - [ ] **Step 2: Run the focused test and verify it fails** Run: ```bash -node --test --test-name-pattern='merge-group DCO mode' tests/automation.test.mjs +node --test --test-name-pattern='npm test discovers only repository tests' tests/automation.test.mjs ``` -Expected: FAIL because `commitsInRange` does not yet accept or apply `excludeMerges`. - -- [ ] **Step 3: Implement the minimal range and CLI option** - -Replace `commitsInRange` with: +Expected: FAIL because the inherited broad `node --test` command discovers the Demo sentinel. -```js -export async function commitsInRange(repo, base, head, { excludeMerges = false } = {}) { - const args = ['log']; - if (excludeMerges) args.push('--no-merges'); - args.push('--format=%H%x1f%B%x1e', `${base}..${head}`); - const { stdout } = await execFileAsync('git', args, { cwd: repo, maxBuffer: 10 * 1024 * 1024 }); - return stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { - const separator = record.indexOf('\x1f'); - return { sha: record.slice(0, separator), message: record.slice(separator + 1) }; - }); -} -``` +- [ ] **Step 3: Scope the root test command** -Change `runCli()` to parse and pass the Boolean option: +Change `package.json` to: -```js -const { values } = parseArgs({ - options: { - repo: { type: 'string', default: process.cwd() }, - base: { type: 'string' }, - head: { type: 'string' }, - 'no-merges': { type: 'boolean', default: false } - } -}); -if (!values.base || !values.head) throw new Error('Usage: check-dco.mjs --repo --base --head [--no-merges]'); -const failures = checkDcoMessages(await commitsInRange(values.repo, values.base, values.head, { excludeMerges: values['no-merges'] })); +```json +"test": "node --test tests/*.test.mjs" ``` -Keep the existing success/failure output and exit-code handling unchanged. - -- [ ] **Step 4: Run focused and full automation tests** +- [ ] **Step 4: Run focused and complete repository tests** Run: ```bash -node --test --test-name-pattern='DCO|merge-group DCO mode' tests/automation.test.mjs -node --test tests/automation.test.mjs +node --test --test-name-pattern='npm test discovers only repository tests' tests/automation.test.mjs +node --test tests/*.test.mjs ``` -Expected: PASS; the default range reports the unsigned non-merge and unsigned merge commits, while `{ excludeMerges: true }` reports only the unsigned non-merge commit. +Expected: PASS; `SAFE_FIXTURE_TEST` runs, `DEMO_EXECUTED_SENTINEL` does not appear, and all repository tests pass. -- [ ] **Step 5: Commit the DCO change** +- [ ] **Step 5: Commit the test-discovery change** ```bash -git add scripts/check-dco.mjs tests/automation.test.mjs -git commit -s -m "feat: support merge-group DCO checks" +git add package.json tests/automation.test.mjs +git commit -s -m "test: scope node test discovery" ``` Expected trailer: `Signed-off-by: 安陈 `. @@ -154,12 +115,12 @@ Expected trailer: `Signed-off-by: 安陈 `. ### Task 2: Add the dedicated merge-group workflow and security contract **Files:** -- Modify: `tests/automation.test.mjs:32-99` +- Modify: `tests/automation.test.mjs` - Create: `.github/workflows/merge-queue.yml` **Interfaces:** -- Consumes: `scripts/check-dco.mjs --repo --base --head --no-merges` from Task 1. -- Produces: GitHub check contexts named exactly `dco`, `preview`, and `validate` for `merge_group.checks_requested`. +- Consumes: the existing pull-request `dco` required check as the queue-admission invariant. +- Produces: GitHub check contexts named exactly `dco`, `preview`, and `validate` for `merge_group.checks_requested`; the merge-group `dco` context is a single-step admission attestation. - [ ] **Step 1: Write failing workflow-contract assertions** @@ -190,9 +151,12 @@ test('merge queue validates the synthetic group with the existing check contexts assert.deepEqual(workflow.permissions, { contents: 'read' }); for (const job of Object.values(workflow.jobs)) assert.equal(job.permissions, undefined, 'jobs must not override read-only workflow permissions'); assert.doesNotMatch(source, /github\.event\.pull_request|pull_request_target|secrets\.|\b(?:actions|checks|contents|deployments|id-token|issues|packages|pages|pull-requests|security-events|statuses):\s*write\b/); - assert.match(source, /BASE_SHA: \${{ github\.event\.merge_group\.base_sha }}/); - assert.match(source, /HEAD_SHA: \${{ github\.event\.merge_group\.head_sha }}/); - assert.match(source, /node trusted\/scripts\/check-dco\.mjs --repo submission --base "\$BASE_SHA" --head "\$HEAD_SHA" --no-merges/); + assert.equal(workflow.jobs.dco.steps.length, 1); + assert.equal(workflow.jobs.dco.steps[0].uses, undefined); + assert.match(workflow.jobs.dco.steps[0].name, /admission/i); + assert.match(workflow.jobs.dco.steps[0].run, /printf/); + assert.match(workflow.jobs.dco.steps[0].run, /required pull-request checks, including dco, passed/i); + assert.doesNotMatch(source, /check-dco/); assert.match(source, /node submission\/scripts\/build-preview\.mjs --root submission --contract-root submission --out-dir artifacts\/preview/); assert.match(source, /cookbook-preview-\${{ github\.run_id }}/); assert.match(source, /working-directory: submission\n\s+run: npm run check/); @@ -234,29 +198,9 @@ jobs: dco: runs-on: ubuntu-latest timeout-minutes: 5 - env: - BASE_SHA: ${{ github.event.merge_group.base_sha }} - HEAD_SHA: ${{ github.event.merge_group.head_sha }} steps: - - name: Check out trusted tooling - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ env.BASE_SHA }} - path: trusted - persist-credentials: false - - name: Check out merge-group history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ env.HEAD_SHA }} - path: submission - fetch-depth: 0 - persist-credentials: false - - name: Use Node.js 20 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 20 - - name: Check every non-merge commit sign-off - run: node trusted/scripts/check-dco.mjs --repo submission --base "$BASE_SHA" --head "$HEAD_SHA" --no-merges + - name: Confirm DCO admission gate + run: printf '%s\n' 'Required pull-request checks, including DCO, passed before this merge group became active.' preview: runs-on: ubuntu-latest @@ -331,7 +275,7 @@ git diff --check npm run check ``` -Expected: all Node tests pass; content, Demo-as-data, links, catalog, and preview checks report zero errors. +Expected: `npm test` executes exactly `node --test tests/*.test.mjs`; all Node tests pass, the Demo sentinel remains unexecuted, and content, Demo-as-data, links, catalog, and preview checks report zero errors. - [ ] **Step 6: Commit the workflow and tests** @@ -348,7 +292,7 @@ Expected trailer: `Signed-off-by: 安陈 `. - Verify: `docs/superpowers/specs/2026-08-21-merge-queue-design.md` - Verify: `docs/superpowers/plans/2026-08-21-merge-queue.md` - Verify: `.github/workflows/merge-queue.yml` -- Verify: `scripts/check-dco.mjs` +- Verify: `package.json` - Verify: `tests/automation.test.mjs` **Interfaces:** @@ -390,7 +334,8 @@ Create a temporary PR body containing: ## Summary - add dedicated `merge_group` validation with the existing `dco`, `preview`, and `validate` contexts -- ignore only GitHub-generated merge commits in merge-group DCO checks +- keep pull-request DCO authoritative and use a queue-admission attestation for the merge-group `dco` context +- scope Node test discovery to repository tests and prove Demo source stays inert - preserve the existing pull-request trust boundary and Demo-as-data policy ## Validation @@ -418,7 +363,7 @@ gh pr view --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,merg gh pr checks --repo QoderAI/cloud-agents-cookbook ``` -Poll the second command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; the files are limited to the approved spec, plan, DCO script, automation test, and merge-queue workflow. +Poll the second command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; the files are limited to the approved spec, plan, package script, automation test, and merge-queue workflow. `scripts/check-dco.mjs` must remain unchanged. - [ ] **Step 5: Handle a newly stale infrastructure PR if necessary** @@ -472,7 +417,7 @@ Run this read-only command and capture the complete JSON output exactly, without gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 ``` -Save that exact output as `/private/tmp/qca-merge-queue-20260821/ruleset-before.json`. Confirm its `updated_at` before proceeding and stop if its rules differ from the approved design. +Save that exact output as `/private/tmp/qca-merge-queue-20260821/ruleset-before.json`. Confirm its `updated_at` before proceeding and stop if its rules differ from the approved design. In particular, verify that `dco` is still one of the required pull-request status contexts and that `bypass_actors` is still empty; the merge-group admission attestation is invalid without those invariants. - [ ] **Step 2: Create the exact enable payload** diff --git a/docs/superpowers/specs/2026-08-21-merge-queue-design.md b/docs/superpowers/specs/2026-08-21-merge-queue-design.md index dcaeb05..c2dcb0c 100644 --- a/docs/superpowers/specs/2026-08-21-merge-queue-design.md +++ b/docs/superpowers/specs/2026-08-21-merge-queue-design.md @@ -42,11 +42,13 @@ It grants only `contents: read`, uses no repository Secrets, never writes reposi ### `dco` job -The job checks out trusted tooling from the merge-group base SHA and the merge-group history from the head SHA. It verifies every non-merge commit in `base..head` with the existing DCO trailer rule. +The existing pull-request `dco` workflow remains the authoritative DCO check and continues verifying every contributor commit before a pull request can become an active queue entry. The merge-group workflow must still report a check context named `dco`, so its `dco` job records that queue-admission invariant instead of treating GitHub's generated queue commit as a contributor commit. -The synthetic commits created by GitHub Merge Queue are merge commits and are excluded only in the merge-group job. The existing PR `dco` job continues checking every commit, including contributor-created merge commits. Therefore an unsigned contributor commit cannot become queue-eligible, while GitHub's unsigned synthetic queue commit does not create a false failure. +The existing pull-request workflow and `scripts/check-dco.mjs` remain unchanged. -The existing `scripts/check-dco.mjs` receives a narrowly scoped `--no-merges` CLI option, with tests proving that the default PR behavior remains unchanged and the merge-group mode excludes only merge commits. +This distinction is required for squash queues: GitHub's synthetic queue head may be a single-parent commit without a contributor `Signed-off-by` trailer, so commit topology cannot reliably distinguish it from contributor commits. The admission job is intentionally valid only while `dco` remains a required pull-request check. Ruleset readback must confirm that invariant before enabling the queue and during final acceptance. + +The merge-group `dco` job has no checkout, token use, or repository mutation. Its only step prints the admission-attestation message. A static regression test locks it to that single step and rejects any attempt to represent the synthetic queue commit as a DCO-checked contributor commit. ### `preview` job @@ -56,7 +58,7 @@ Running the repository preview tooling is safe at this stage because an active m ### `validate` job -The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, and runs `npm run check`. This validates the combined tree containing current `main`, all earlier queue entries, and the current entry. It executes repository validation and build tooling but does not install, import, or execute Demo source. +The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, and runs `npm run check`. This validates the combined tree containing current `main`, all earlier queue entries, and the current entry. The root `npm test` command is explicitly scoped to `tests/*.test.mjs`; a Demo sentinel regression proves that `demos/**/test.js` is not discovered or executed. ## Automated regression checks @@ -65,16 +67,16 @@ Repository tests will statically assert that: - the merge-queue workflow listens to `merge_group` and not `pull_request` or `push`; - the workflow exposes jobs named `dco`, `preview`, and `validate`; - permissions remain read-only and no Secrets or write permissions are referenced; -- the DCO job uses the trusted base implementation with merge exclusion enabled; +- the DCO job contains only the approved queue-admission attestation, while the pull-request DCO workflow remains authoritative; - preview and validate operate on the merge-group SHA and do not depend on `github.event.pull_request.*`; -- Demo source is not executed; -- default DCO behavior still rejects unsigned merge commits, while merge-group mode ignores merge commits and continues rejecting unsigned non-merge commits. +- Demo source is not executed, including through Node's automatic test discovery; +- `npm test` runs only `tests/*.test.mjs`, and an allowed `demos/**/test.js` sentinel remains unexecuted. The complete repository check remains `npm run check`. ## Infrastructure pull request -All workflow, script, test, and design changes are committed on `codex/enable-merge-queue` with DCO sign-off and submitted as a repository-infrastructure pull request. The PR is merged through the current strict, squash-only process after `dco`, `preview`, and `validate` pass. +The workflow, package test command, regression tests, and design changes are committed on `codex/enable-merge-queue` with DCO sign-off and submitted as a repository-infrastructure pull request. The PR is merged through the current strict, squash-only process after `dco`, `preview`, and `validate` pass. ## Ruleset update @@ -99,7 +101,7 @@ Use the already-open PR #11 as the first queue entry after confirming it remains Add #11 through `gh pr merge 11 --squash`. Because the Ruleset requires Merge Queue, this command must enqueue the PR instead of directly merging it. Acceptance requires: - a `merge_group` workflow run is created; -- `dco`, `preview`, and `validate` run on the merge-group SHA and pass; +- `dco`, `preview`, and `validate` report for the merge-group run and pass; `preview` and `validate` operate on the merge-group SHA, while `dco` records the admission invariant; - #11 is automatically squash-merged by the queue; - `main` advances to the queued result; - the Ruleset readback remains unchanged after the merge. @@ -120,5 +122,5 @@ The configuration is complete only when: 1. The infrastructure PR is merged with DCO and all required checks passing. 2. The active Ruleset contains the exact single-entry squash Merge Queue configuration and no unintended changes. -3. PR #11 passes all three checks on a real merge-group SHA and is automatically squash-merged. +3. PR #11 receives all three successful contexts from a real merge-group run and is automatically squash-merged. 4. The final `main` and Ruleset states are independently read back through GitHub CLI. diff --git a/package.json b/package.json index 97d9ceb..65de40e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=20" }, "scripts": { - "test": "node --test", + "test": "node --test tests/*.test.mjs", "validate": "node scripts/validate.mjs", "validate:demos": "node scripts/validate-demos.mjs", "build": "node scripts/build-catalog.mjs", diff --git a/scripts/check-dco.mjs b/scripts/check-dco.mjs index 6eddf59..b3525d2 100644 --- a/scripts/check-dco.mjs +++ b/scripts/check-dco.mjs @@ -13,11 +13,8 @@ export function checkDcoMessages(commits) { return commits.filter((commit) => !trailerPattern.test(commit.message)).map((commit) => ({ sha: commit.sha, message: 'Commit is missing a valid Signed-off-by trailer.' })); } -export async function commitsInRange(repo, base, head, { excludeMerges = false } = {}) { - const args = ['log']; - if (excludeMerges) args.push('--no-merges'); - args.push('--format=%H%x1f%B%x1e', `${base}..${head}`); - const { stdout } = await execFileAsync('git', args, { cwd: repo, maxBuffer: 10 * 1024 * 1024 }); +export async function commitsInRange(repo, base, head) { + const { stdout } = await execFileAsync('git', ['log', '--format=%H%x1f%B%x1e', `${base}..${head}`], { cwd: repo, maxBuffer: 10 * 1024 * 1024 }); return stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { const separator = record.indexOf('\x1f'); return { sha: record.slice(0, separator), message: record.slice(separator + 1) }; @@ -25,16 +22,9 @@ export async function commitsInRange(repo, base, head, { excludeMerges = false } } async function runCli() { - const { values } = parseArgs({ - options: { - repo: { type: 'string', default: process.cwd() }, - base: { type: 'string' }, - head: { type: 'string' }, - 'no-merges': { type: 'boolean', default: false } - } - }); - if (!values.base || !values.head) throw new Error('Usage: check-dco.mjs --repo --base --head [--no-merges]'); - const failures = checkDcoMessages(await commitsInRange(values.repo, values.base, values.head, { excludeMerges: values['no-merges'] })); + const { values } = parseArgs({ options: { repo: { type: 'string', default: process.cwd() }, base: { type: 'string' }, head: { type: 'string' } } }); + if (!values.base || !values.head) throw new Error('Usage: check-dco.mjs --repo --base --head '); + const failures = checkDcoMessages(await commitsInRange(values.repo, values.base, values.head)); for (const failure of failures) console.error(`${failure.sha.slice(0, 12)}: ${failure.message}`); console.log(failures.length ? `${failures.length} commit(s) failed DCO.` : 'Every pull-request commit has a valid DCO sign-off.'); if (failures.length) process.exitCode = 1; diff --git a/tests/automation.test.mjs b/tests/automation.test.mjs index fa68cd9..d175e6a 100644 --- a/tests/automation.test.mjs +++ b/tests/automation.test.mjs @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import YAML from 'yaml'; -import { checkDcoMessages, commitsInRange } from '../scripts/check-dco.mjs'; +import { checkDcoMessages } from '../scripts/check-dco.mjs'; import { checkContributionScope } from '../scripts/check-contribution-scope.mjs'; import { repoRoot } from './helpers.mjs'; @@ -22,49 +22,6 @@ test('DCO check requires a valid Signed-off-by trailer in every commit', () => { assert.deepEqual(result, [{ sha: 'bbb222', message: 'Commit is missing a valid Signed-off-by trailer.' }]); }); -test('merge-group DCO mode excludes only merge commits', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-dco-')); - const hooksPath = path.join(root, 'hooks'); - await mkdir(hooksPath); - const git = (...args) => execFileAsync('git', [ - '-c', 'commit.gpgSign=false', - '-c', 'merge.gpgSign=false', - '-c', `core.hooksPath=${hooksPath}`, - ...args - ], { cwd: root }); - await git('init', '-b', 'main'); - await git('config', 'user.name', 'Example Author'); - await git('config', 'user.email', 'author@example.com'); - - await writeFile(path.join(root, 'base.txt'), 'base\n'); - await git('add', 'base.txt'); - await git('commit', '-m', 'docs: base', '-m', 'Signed-off-by: Example Author '); - const { stdout: baseOutput } = await git('rev-parse', 'HEAD'); - const base = baseOutput.trim(); - - await git('checkout', '-b', 'feature'); - await writeFile(path.join(root, 'feature.txt'), 'feature\n'); - await git('add', 'feature.txt'); - await git('commit', '-m', 'docs: unsigned feature'); - const { stdout: featureOutput } = await git('rev-parse', 'HEAD'); - const feature = featureOutput.trim(); - - await git('checkout', 'main'); - await writeFile(path.join(root, 'main.txt'), 'main\n'); - await git('add', 'main.txt'); - await git('commit', '-m', 'docs: main', '-m', 'Signed-off-by: Example Author '); - await git('merge', '--no-ff', 'feature', '-m', 'Merge feature'); - const { stdout: headOutput } = await git('rev-parse', 'HEAD'); - const head = headOutput.trim(); - - const defaultFailures = checkDcoMessages(await commitsInRange(root, base, head)); - assert.equal(defaultFailures.length, 2); - assert.ok(defaultFailures.some((failure) => failure.sha === feature)); - - const queueFailures = checkDcoMessages(await commitsInRange(root, base, head, { excludeMerges: true })); - assert.deepEqual(queueFailures, [{ sha: feature, message: 'Commit is missing a valid Signed-off-by trailer.' }]); -}); - test('external content contributions cannot change repository infrastructure', () => { assert.deepEqual(checkContributionScope(['content/zh-CN/recipes/example/index.md'], { allowInfrastructure: false }), []); assert.deepEqual(checkContributionScope([ @@ -109,15 +66,45 @@ test('merge queue validates the synthetic group with the existing check contexts assert.deepEqual(workflow.permissions, { contents: 'read' }); for (const job of Object.values(workflow.jobs)) assert.equal(job.permissions, undefined, 'jobs must not override read-only workflow permissions'); assert.doesNotMatch(source, /github\.event\.pull_request|pull_request_target|secrets\.|\b(?:actions|checks|contents|deployments|id-token|issues|packages|pages|pull-requests|security-events|statuses):\s*write\b/); - assert.match(source, /BASE_SHA: \${{ github\.event\.merge_group\.base_sha }}/); - assert.match(source, /HEAD_SHA: \${{ github\.event\.merge_group\.head_sha }}/); - assert.match(source, /node trusted\/scripts\/check-dco\.mjs --repo submission --base "\$BASE_SHA" --head "\$HEAD_SHA" --no-merges/); + assert.equal(workflow.jobs.dco.steps.length, 1); + assert.equal(workflow.jobs.dco.steps[0].uses, undefined); + assert.match(workflow.jobs.dco.steps[0].name, /admission/i); + assert.match(workflow.jobs.dco.steps[0].run, /printf/); + assert.match(workflow.jobs.dco.steps[0].run, /required pull-request checks, including dco, passed/i); + assert.doesNotMatch(source, /--no-merges|check-dco/); assert.match(source, /node submission\/scripts\/build-preview\.mjs --root submission --contract-root submission --out-dir artifacts\/preview/); assert.match(source, /cookbook-preview-\${{ github\.run_id }}/); assert.match(source, /working-directory: submission\n\s+run: npm run check/); assert.doesNotMatch(source, /working-directory:\s*submission\/demos|npm\s+--prefix\s+demos|docker\s+build|make\s+(?:-[^\s]+\s+)*demos/i); }); +test('npm test discovers only repository tests and never executes Demo source', async () => { + const packageJson = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-test-discovery-')); + await mkdir(path.join(root, 'tests'), { recursive: true }); + await mkdir(path.join(root, 'demos', 'example'), { recursive: true }); + await writeFile(path.join(root, 'package.json'), JSON.stringify({ + private: true, + type: 'module', + scripts: { test: packageJson.scripts.test } + })); + await writeFile(path.join(root, 'tests', 'safe.test.mjs'), ` + import test from 'node:test'; + test('SAFE_FIXTURE_TEST', () => {}); + `); + await writeFile(path.join(root, 'demos', 'example', 'test.js'), ` + throw new Error('DEMO_EXECUTED_SENTINEL'); + `); + + const env = { ...process.env }; + delete env.NODE_TEST_CONTEXT; + const result = await execFileAsync('npm', ['test'], { cwd: root, env }).catch((error) => error); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + assert.match(output, /SAFE_FIXTURE_TEST/); + assert.doesNotMatch(output, /DEMO_EXECUTED_SENTINEL/); + assert.equal(result.code ?? 0, 0, output); +}); + test('maintainer infrastructure pull requests exercise the proposed tooling', async () => { const validateSource = await readFile(path.join(repoRoot, '.github', 'workflows', 'validate.yml'), 'utf8'); assert.match(validateSource, /name: Install proposed dependencies/); From 6d6419a1ad20318b3ba1454eb8edeaf36e834fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 16:04:55 +0800 Subject: [PATCH 7/9] fix: harden manual queue admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- docs/automated-checks.md | 17 ++- docs/maintainers/repository-settings.md | 30 +++-- .../plans/2026-08-21-merge-queue.md | 106 +++++++++++++----- .../specs/2026-08-21-merge-queue-design.md | 27 +++-- package.json | 2 +- scripts/run-tests.mjs | 33 ++++++ tests/automation.test.mjs | 59 +++++++++- 7 files changed, 226 insertions(+), 48 deletions(-) create mode 100644 scripts/run-tests.mjs diff --git a/docs/automated-checks.md b/docs/automated-checks.md index dfd0267..bd85020 100644 --- a/docs/automated-checks.md +++ b/docs/automated-checks.md @@ -12,6 +12,8 @@ Automated checks run when a pull request is opened, reopened, or updated. Runnin Required checks block merge. Maintainers should not bypass a failed check; contract changes require a separate maintainer pull request that updates Schema, docs, templates, validator, and tests together. +When Merge Queue is enabled, `.github/workflows/merge-queue.yml` handles `merge_group.checks_requested` and reports the same `dco`, `preview`, and `validate` contexts for the synthetic group. `preview` and `validate` check out exactly the merge-group head SHA. The pull-request `DCO / dco` job remains the authoritative check of every contributor commit; the merge-group `dco` job is only an admission attestation because GitHub's generated squash candidate is not a contributor commit. + ## Validation families - `META`: Frontmatter, Schema version, single author, taxonomy, one-to-five tags, stable unique slug, related content, and platform-generated fields. @@ -28,10 +30,23 @@ An Error blocks merge. A Warning is shown for human review but does not block by ## Security model -Public and fork pull requests receive a read-only token and no Secrets. The workflow checks out validator code from the base commit into `trusted/`, checks out GitHub's synthetic merge tree into `submission/`, and invokes only code from `trusted/`. Fork-supplied scripts and workflows are never executed, even when the author is an organization member or collaborator. +Public and fork pull requests receive a read-only token and no Secrets. The validation and preview jobs check out validator code from the base commit into `trusted/`, check out GitHub's synthetic merge tree into `submission/`, and use trusted tooling for ordinary external content validation. Demo files are always treated as data and are never executed. + +That data/tooling split does not make a green check an authorization decision. A pull request can propose changes to GitHub Actions workflow orchestration and, for Maintainer-owned repository branches, the proposed validation tooling is deliberately exercised. Therefore the check-producing configuration itself is part of the candidate change and must be reviewed as infrastructure. Ordinary external pull requests may change only valid article paths under `content/**` and strongly bound source under `demos//**`. `demos/README.md`, contracts, templates, configuration, tooling, and workflows remain Maintainer-owned infrastructure. A trusted owner, member, or collaborator may change infrastructure only from a branch in this repository; that no-secret, read-only run additionally executes the complete proposed `npm run check`. All existing content is revalidated against the prospective merged contracts before merge. Demo dependency manifests, package scripts, Makefiles, Dockerfiles, tests, source, and README commands are never executed. Pull-request automation reads Demo files only as untrusted data using tooling from the trusted base revision. Automated checks do not determine factual correctness, public product status, Demo runtime behavior, operational safety, copyright ownership, customer authorization, or whether the content should be published. Maintainers review the Demo README and source manually. + +## Merge Queue admission + +Auto-merge must remain disabled. Only a Maintainer with write access may manually add a pull request to the queue, and green checks alone are never sufficient authorization. Before enqueueing, review both outputs in full: + +```bash +gh pr diff --name-only +gh pr diff +``` + +Do not queue an external pull request that touches `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned automation/security infrastructure. Recreate and review that work as a Maintainer-owned infrastructure pull request. The Ruleset keeps zero required approvals only because the repository currently has a single Maintainer; it compensates with an empty bypass list and this explicit manual admission boundary. When a second Maintainer is available, require approval, Code Owner review, and latest-push approval. diff --git a/docs/maintainers/repository-settings.md b/docs/maintainers/repository-settings.md index 2d60812..bbb306c 100644 --- a/docs/maintainers/repository-settings.md +++ b/docs/maintainers/repository-settings.md @@ -13,17 +13,31 @@ Apply these settings after creating `QoderAI/cloud-agents-cookbook` and before a ## Branch protection for `main` - Require a pull request before merging. -- Require at least one approving review. -- Require review from Code Owners. - Dismiss stale approvals after new commits. - Require all conversations to be resolved. -- Require the latest reviewed commit. -- Require `Validate content / validate`, `DCO / dco`, and `Preview content / preview`. -- Require branches to be up to date before merging so the required checks always represent the prospective merged tree. Do not enable Merge Queue until the workflows explicitly support the `merge_group` event. +- Keep required approvals at zero, Code Owner review disabled, and last-push approval disabled while the repository has only one Maintainer. GitHub does not allow an author to approve their own pull request, so enabling these controls now would block Maintainer infrastructure pull requests. +- Require the `validate`, `dco`, and `preview` status contexts. +- Enable Merge Queue only while `.github/workflows/merge-queue.yml` handles `merge_group.checks_requested` and reports those same three contexts. +- Configure the queue for one entry at a time: `ALLGREEN`, squash, one entry to build, one entry to merge, minimum one entry, zero-minute wait, and a ten-minute check-response timeout. +- Disable strict branch freshness after enabling the queue. The merge group, rather than the contributor branch, is tested against the latest `main`. +- Keep Auto-merge disabled. Only a Maintainer with write access may manually add a pull request to the queue after completing the admission review below. - Block force pushes and branch deletion. -- Do not allow bypass except for documented emergency recovery. +- Keep the Ruleset bypass list empty. -The initial CODEOWNER is `@anchenqlw`. Replace it with an organization maintainer team after that GitHub team exists and has write access. +The initial CODEOWNER is `@anchenqlw`, but CODEOWNERS is currently routing information rather than a required approval gate. After a second Maintainer or organization Maintainer team has write access, require at least one approval, Code Owner review, and approval of the latest push. Re-evaluate whether the manual queue-admission procedure can then be narrowed, but do not weaken the infrastructure diff review. + +## Manual queue admission + +Green checks show that the candidate produced the expected contexts; they do not authorize a merge. A pull request can propose changes to the workflows, scripts, and tests that produce those contexts. Before every enqueue operation, the write-access Maintainer must run: + +```bash +gh pr diff --name-only +gh pr diff +``` + +Review the complete file list and full diff, confirm the change is expected, and only then use the queue command. Do not queue an external pull request that changes `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned repository automation/security infrastructure. Recreate such work on a Maintainer-owned branch and submit it as a separate infrastructure pull request. + +The first queue acceptance case, PR #11, must contain only the expected content translation under `content/**`. Any other path is a stop condition, even if all checks are green. ## Fork pull-request Actions @@ -53,4 +67,4 @@ Rotate the token through the receiver and GitHub Secrets. Do not store it in con ## One-time verification -Open a signed test pull request from a public fork. Confirm that no Secrets appear, infrastructure changes are rejected, all three required checks run, the preview Artifact opens, an unsigned commit fails DCO, and a valid content correction can be merged by a Maintainer. +Open a signed test pull request from a public fork. Confirm that no Secrets appear, all three required checks run, the preview Artifact opens, an unsigned commit fails DCO, Auto-merge remains disabled, only a write-access Maintainer can enqueue, and a valid content correction completes all three `merge_group` checks before being squash-merged. Separately verify that an external infrastructure change is stopped by the manual admission review even if it displays green checks. diff --git a/docs/superpowers/plans/2026-08-21-merge-queue.md b/docs/superpowers/plans/2026-08-21-merge-queue.md index 484ea3d..a0cdde6 100644 --- a/docs/superpowers/plans/2026-08-21-merge-queue.md +++ b/docs/superpowers/plans/2026-08-21-merge-queue.md @@ -4,7 +4,7 @@ **Goal:** Enable a conservative, squash-only GitHub Merge Queue for `QoderAI/cloud-agents-cookbook`, with real `merge_group` validation and PR #11 as the end-to-end acceptance test. -**Architecture:** Keep the existing `pull_request` workflows and DCO helper unchanged, and add one dedicated merge-group workflow that reports the existing `dco`, `preview`, and `validate` check contexts. The pull-request `dco` check remains authoritative; the merge-group `dco` job is a single-step queue-admission attestation, while `preview` and `validate` exercise the synthetic merge-group tree. Scope Node test discovery to `tests/*.test.mjs`, then merge the infrastructure PR before atomically updating Ruleset `20582196` through `gh api`. +**Architecture:** Keep the existing `pull_request` workflows and DCO helper unchanged, and add one dedicated merge-group workflow that reports the existing `dco`, `preview`, and `validate` check contexts. The pull-request `dco` check remains authoritative; the merge-group `dco` job is a single-step queue-admission attestation, while `preview` and `validate` exercise the synthetic merge-group tree. Use a cross-platform Node runner to enumerate only top-level repository tests. Because a candidate PR can change check-producing infrastructure, keep Auto-merge disabled and require a write-access Maintainer to review the complete file list and diff before manually queueing. Merge the infrastructure PR before atomically updating Ruleset `20582196` through `gh api`. **Tech Stack:** GitHub Actions, GitHub CLI (`gh`), GitHub Rulesets REST API, Node.js 20, `node:test`, `yaml` 2.9.0, npm. @@ -17,7 +17,10 @@ - Workflows use only SHA-pinned official GitHub Actions, `permissions: contents: read`, bounded timeouts, no Secrets, no write tokens, and no Demo source execution. - Existing `pull_request` DCO behavior and `scripts/check-dco.mjs` remain unchanged and continue checking every contributor commit. - The pull-request `dco` context must remain required before and after queue enablement; the merge-group job named `dco` only attests that this admission gate passed. -- The root test command is exactly `node --test tests/*.test.mjs`; a Demo sentinel must prove that `demos/**/test.js` is never discovered or executed. +- Auto-merge remains disabled. Green checks are not authorization; only a Maintainer with write access may manually queue a PR after running `gh pr diff --name-only` and reviewing `gh pr diff ` in full. +- Never queue an external PR that changes `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned automation/security infrastructure. Recreate it as a Maintainer-owned infrastructure PR. +- Preserve the approved single-maintainer Ruleset review parameters: zero required approvals, no required Code Owner review, and no last-push approval. Preserve the empty bypass list. +- The root test command is exactly `node scripts/run-tests.mjs`; the runner enumerates sorted, top-level `tests/*.test.mjs` paths without a shell glob. Demo and nested sentinels must remain unexecuted. - Do not bypass checks, force-push, directly push `main`, or directly merge PR #11. - If merge-group validation fails or does not complete within the configured 10-minute response window, restore the original Ruleset before attempting any workflow repair. @@ -25,21 +28,23 @@ ## File Structure -- Modify `package.json`: scope `npm test` to `node --test tests/*.test.mjs`. -- Modify `tests/automation.test.mjs`: add the Demo test-discovery sentinel and statically enforce merge-queue workflow security/event/admission contracts. +- Modify `package.json`: run tests through `node scripts/run-tests.mjs`. +- Create `scripts/run-tests.mjs`: enumerate and sort only top-level `tests/*.test.mjs`, spawn `process.execPath --test` with exact paths, and propagate failure. +- Modify `tests/automation.test.mjs`: copy the runner into a temporary fixture, add Demo/nested sentinels, and statically enforce merge-queue and authoritative PR-DCO contracts. - Create `.github/workflows/merge-queue.yml`: run `dco`, `preview`, and `validate` for `merge_group.checks_requested`. -- Preserve `docs/superpowers/specs/2026-08-21-merge-queue-design.md`: approved design and acceptance contract. +- Modify `docs/superpowers/specs/2026-08-21-merge-queue-design.md`, `docs/superpowers/plans/2026-08-21-merge-queue.md`, `docs/maintainers/repository-settings.md`, and `docs/automated-checks.md`: record the approved single-Maintainer manual admission boundary. - Create no persistent repository file for Ruleset payloads; store snapshots and request bodies only under `/private/tmp/qca-merge-queue-20260821/`. ### Task 1: Scope Node test discovery and prove Demo source stays inert **Files:** - Modify: `package.json:11` +- Create: `scripts/run-tests.mjs` - Modify: `tests/automation.test.mjs` **Interfaces:** - Consumes: the root `npm test` script. -- Produces: deterministic discovery of repository tests under `tests/*.test.mjs`, with no automatic execution of files under `demos/`. +- Produces: deterministic, cross-platform discovery of top-level repository tests under `tests/*.test.mjs`, with no shell glob and no automatic execution of Demo or nested test-like files. - [ ] **Step 1: Write a failing Demo test-discovery sentinel** @@ -48,14 +53,22 @@ Add imports for `execFile`, `mkdir`, `mkdtemp`, `tmpdir`, `writeFile`, and `prom ```js test('npm test discovers only repository tests and never executes Demo source', async () => { const packageJson = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + const runnerSource = await readFile(path.join(repoRoot, 'scripts', 'run-tests.mjs'), 'utf8'); + assert.equal(packageJson.scripts.test, 'node scripts/run-tests.mjs'); + assert.match(runnerSource, /new URL\('\.\.\/tests\/', import\.meta\.url\)/); + assert.match(runnerSource, /entry\.isFile\(\) && entry\.name\.endsWith\('\.test\.mjs'\)/); + assert.doesNotMatch(runnerSource, /demos|recursive:\s*true/); const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-test-discovery-')); await mkdir(path.join(root, 'tests'), { recursive: true }); + await mkdir(path.join(root, 'tests', 'nested'), { recursive: true }); await mkdir(path.join(root, 'demos', 'example'), { recursive: true }); + await mkdir(path.join(root, 'scripts'), { recursive: true }); await writeFile(path.join(root, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { test: packageJson.scripts.test } })); + await writeFile(path.join(root, 'scripts', 'run-tests.mjs'), runnerSource); await writeFile(path.join(root, 'tests', 'safe.test.mjs'), ` import test from 'node:test'; test('SAFE_FIXTURE_TEST', () => {}); @@ -63,13 +76,18 @@ test('npm test discovers only repository tests and never executes Demo source', await writeFile(path.join(root, 'demos', 'example', 'test.js'), ` throw new Error('DEMO_EXECUTED_SENTINEL'); `); + await writeFile(path.join(root, 'tests', 'nested', 'nested.test.mjs'), ` + throw new Error('NESTED_TEST_EXECUTED_SENTINEL'); + `); const env = { ...process.env }; delete env.NODE_TEST_CONTEXT; - const result = await execFileAsync('npm', ['test'], { cwd: root, env }).catch((error) => error); + const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const result = await execFileAsync(npmExecutable, ['test'], { cwd: root, env }).catch((error) => error); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; assert.match(output, /SAFE_FIXTURE_TEST/); assert.doesNotMatch(output, /DEMO_EXECUTED_SENTINEL/); + assert.doesNotMatch(output, /NESTED_TEST_EXECUTED_SENTINEL/); assert.equal(result.code ?? 0, 0, output); }); ``` @@ -82,31 +100,33 @@ Run: node --test --test-name-pattern='npm test discovers only repository tests' tests/automation.test.mjs ``` -Expected: FAIL because the inherited broad `node --test` command discovers the Demo sentinel. +Expected: FAIL because `scripts/run-tests.mjs` does not exist and the old package command does not satisfy the contract. -- [ ] **Step 3: Scope the root test command** +- [ ] **Step 3: Add the cross-platform runner and update the package command** -Change `package.json` to: +Create `scripts/run-tests.mjs` using `readdir(..., { withFileTypes: true })`, keep only top-level regular files ending in `.test.mjs`, sort the exact absolute paths, and spawn: -```json -"test": "node --test tests/*.test.mjs" +```js +spawn(process.execPath, ['--test', ...testFiles], { stdio: 'inherit' }) ``` +Propagate the child exit code, re-emit a terminating signal, and report spawn errors with a nonzero exit. Change `package.json` to `"test": "node scripts/run-tests.mjs"`. + - [ ] **Step 4: Run focused and complete repository tests** Run: ```bash node --test --test-name-pattern='npm test discovers only repository tests' tests/automation.test.mjs -node --test tests/*.test.mjs +npm test ``` -Expected: PASS; `SAFE_FIXTURE_TEST` runs, `DEMO_EXECUTED_SENTINEL` does not appear, and all repository tests pass. +Expected: PASS; `SAFE_FIXTURE_TEST` runs, neither sentinel appears, and all repository tests pass on Node.js 20 without shell-glob behavior. - [ ] **Step 5: Commit the test-discovery change** ```bash -git add package.json tests/automation.test.mjs +git add package.json scripts/run-tests.mjs tests/automation.test.mjs git commit -s -m "test: scope node test discovery" ``` @@ -157,6 +177,11 @@ test('merge queue validates the synthetic group with the existing check contexts assert.match(workflow.jobs.dco.steps[0].run, /printf/); assert.match(workflow.jobs.dco.steps[0].run, /required pull-request checks, including dco, passed/i); assert.doesNotMatch(source, /check-dco/); + for (const jobName of ['preview', 'validate']) { + const checkout = workflow.jobs[jobName].steps.find((step) => step.name === 'Check out merge-group tree'); + assert.ok(checkout, `${jobName} must check out the merge-group tree`); + assert.equal(checkout.with.ref, '${{ github.event.merge_group.head_sha }}'); + } assert.match(source, /node submission\/scripts\/build-preview\.mjs --root submission --contract-root submission --out-dir artifacts\/preview/); assert.match(source, /cookbook-preview-\${{ github\.run_id }}/); assert.match(source, /working-directory: submission\n\s+run: npm run check/); @@ -164,6 +189,14 @@ test('merge queue validates the synthetic group with the existing check contexts }); ``` +Add a separate authoritative pull-request DCO contract. Parse `dco.yml` and assert that it triggers only for `pull_request` on `main`, has only job ID `dco`, maps `BASE_SHA` and `HEAD_SHA` from the PR payload, checks trusted tooling out at the base SHA, checks submission history out at the head SHA with `fetch-depth: 0`, and runs exactly: + +```text +node trusted/scripts/check-dco.mjs --repo submission --base "$BASE_SHA" --head "$HEAD_SHA" +``` + +Reject `--no-merges`. This prevents the queue admission-attestation job from silently replacing the real contributor-commit check. + Include `merge-queue.yml` in the `automationSource` array used by the existing Demo-execution test. - [ ] **Step 2: Run the focused tests and verify they fail** @@ -171,7 +204,7 @@ Include `merge-queue.yml` in the `automationSource` array used by the existing D Run: ```bash -node --test --test-name-pattern='workflows pin|merge queue validates|trusted automation' tests/automation.test.mjs +node --test --test-name-pattern='workflows pin|merge queue validates|pull-request DCO|trusted automation' tests/automation.test.mjs ``` Expected: FAIL because `.github/workflows/merge-queue.yml` does not exist. @@ -261,10 +294,10 @@ jobs: Run: ```bash -node --test --test-name-pattern='workflows pin|merge queue validates|trusted automation' tests/automation.test.mjs +node --test --test-name-pattern='workflows pin|merge queue validates|pull-request DCO|trusted automation' tests/automation.test.mjs ``` -Expected: PASS, including the exact event, job names, SHA pinning, permissions, artifact naming, and Demo non-execution assertions. +Expected: PASS, including the exact event, job names, structured `head_sha` checkout values, authoritative PR-DCO data flow and command, SHA pinning, permissions, artifact naming, and Demo non-execution assertions. - [ ] **Step 5: Run the complete repository check** @@ -275,7 +308,7 @@ git diff --check npm run check ``` -Expected: `npm test` executes exactly `node --test tests/*.test.mjs`; all Node tests pass, the Demo sentinel remains unexecuted, and content, Demo-as-data, links, catalog, and preview checks report zero errors. +Expected: `npm test` executes `node scripts/run-tests.mjs`; all top-level Node tests pass, Demo and nested sentinels remain unexecuted, and content, Demo-as-data, links, catalog, and preview checks report zero errors. - [ ] **Step 6: Commit the workflow and tests** @@ -293,7 +326,10 @@ Expected trailer: `Signed-off-by: 安陈 `. - Verify: `docs/superpowers/plans/2026-08-21-merge-queue.md` - Verify: `.github/workflows/merge-queue.yml` - Verify: `package.json` +- Verify: `scripts/run-tests.mjs` - Verify: `tests/automation.test.mjs` +- Verify: `docs/maintainers/repository-settings.md` +- Verify: `docs/automated-checks.md` **Interfaces:** - Consumes: the signed commits and passing `npm run check` from Tasks 1-2. @@ -335,12 +371,15 @@ Create a temporary PR body containing: - add dedicated `merge_group` validation with the existing `dco`, `preview`, and `validate` contexts - keep pull-request DCO authoritative and use a queue-admission attestation for the merge-group `dco` context -- scope Node test discovery to repository tests and prove Demo source stays inert +- use a cross-platform Node test runner and prove Demo and nested test-like files stay inert +- document the single-Maintainer manual diff-review and queue-admission boundary - preserve the existing pull-request trust boundary and Demo-as-data policy ## Validation - `npm run check` +- authoritative PR-DCO and exact merge-group `head_sha` checkout contract tests +- temporary-repository test-runner fixture - real Merge Queue acceptance will run after this PR lands and Ruleset `20582196` is updated Signed-off-by: 安陈 @@ -359,11 +398,13 @@ Expected: a non-draft PR URL. Run: ```bash -gh pr view --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,files,commits,statusCheckRollup,url -gh pr checks --repo QoderAI/cloud-agents-cookbook +gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,files,commits,statusCheckRollup,url +gh pr diff codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --name-only +gh pr diff codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook +gh pr checks codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook ``` -Poll the second command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; the files are limited to the approved spec, plan, package script, automation test, and merge-queue workflow. `scripts/check-dco.mjs` must remain unchanged. +Review the complete changed-file list and full diff before considering the PR eligible. Poll the checks command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; files are limited to the approved workflow, package script, Node test runner, automation test, design/plan, repository-settings, and automated-checks documentation. `scripts/check-dco.mjs` and `.github/workflows/dco.yml` must remain unchanged. Green checks do not replace this diff review. - [ ] **Step 5: Handle a newly stale infrastructure PR if necessary** @@ -380,7 +421,7 @@ Then wait again for all three PR checks. Do not use GitHub's unqualified force-u - [ ] **Step 6: Squash-merge the infrastructure PR through the current Ruleset** -Resolve the PR number from the current feature branch and merge that exact PR: +Resolve the PR number from the current feature branch. Re-run `gh pr diff --name-only` and review `gh pr diff ` in full, then merge that exact Maintainer-owned infrastructure PR through the current strict Ruleset: ```bash gh pr merge "$(gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json number --jq .number)" --repo QoderAI/cloud-agents-cookbook --squash @@ -415,9 +456,10 @@ Run this read-only command and capture the complete JSON output exactly, without ```bash gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 +gh api repos/QoderAI/cloud-agents-cookbook --jq '{allow_auto_merge,allow_squash_merge,allow_merge_commit,allow_rebase_merge}' ``` -Save that exact output as `/private/tmp/qca-merge-queue-20260821/ruleset-before.json`. Confirm its `updated_at` before proceeding and stop if its rules differ from the approved design. In particular, verify that `dco` is still one of the required pull-request status contexts and that `bypass_actors` is still empty; the merge-group admission attestation is invalid without those invariants. +Save the complete Ruleset output as `/private/tmp/qca-merge-queue-20260821/ruleset-before.json`. Confirm its `updated_at` before proceeding and stop if its rules differ from the approved design. In particular, verify that all three contexts (`dco`, `preview`, `validate`) remain required, `bypass_actors` is empty, the review parameters remain `required_approving_review_count=0`, `require_code_owner_review=false`, and `require_last_push_approval=false`, and repository `allow_auto_merge=false`. Also confirm squash is the only enabled repository merge method. The admission-attestation and single-Maintainer manual gate are invalid without these invariants. - [ ] **Step 2: Create the exact enable payload** @@ -560,11 +602,15 @@ conditions.ref_name.include = ["~DEFAULT_BRANCH"] bypass_actors = [] rule types = deletion, non_fast_forward, required_linear_history, pull_request, merge_queue, required_status_checks allowed_merge_methods = ["squash"] +required_approving_review_count = 0 +require_code_owner_review = false +require_last_push_approval = false required_review_thread_resolution = true require_extra_approval_for_unattributed_changes = true strict_required_status_checks_policy = false required checks = dco, preview, validate merge queue = 10 / ALLGREEN / 1 / 1 / SQUASH / 1 / 0 +repository allow_auto_merge = false ``` Stop and restore immediately if any field differs. @@ -583,9 +629,12 @@ Stop and restore immediately if any field differs. ```bash gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefName,baseRefName,url +gh pr diff 11 --repo QoderAI/cloud-agents-cookbook --name-only +gh pr diff 11 --repo QoderAI/cloud-agents-cookbook +gh api repos/QoderAI/cloud-agents-cookbook --jq .allow_auto_merge ``` -Expected: `state=OPEN`, `isDraft=false`, `mergeable=MERGEABLE`, base `main`, no unresolved review requirement, and the PR-level `dco`, `preview`, and `validate` conclusions are `SUCCESS`. A `BEHIND` status is acceptable because the Merge Queue now validates the synthetic group. +Expected: `state=OPEN`, `isDraft=false`, `mergeable=MERGEABLE`, base `main`, no unresolved review requirement, and the PR-level `dco`, `preview`, and `validate` conclusions are `SUCCESS`. Review the complete file list and full diff: PR #11 must contain only the expected content translation under `content/**`; any `.github/**`, `scripts/**`, `tests/**`, package, configuration, Schema, documentation, or otherwise unexpected file is a stop condition. Confirm `allow_auto_merge=false`. A `BEHIND` status is acceptable because the Merge Queue now validates the synthetic group. - [ ] **Step 2: Submit PR #11 to the queue** @@ -593,7 +642,7 @@ Expected: `state=OPEN`, `isDraft=false`, `mergeable=MERGEABLE`, base `main`, no gh pr merge 11 --repo QoderAI/cloud-agents-cookbook --squash ``` -Expected: GitHub queues the PR rather than directly merging it. +This command must be run by the write-access Maintainer only after completing Step 1. Expected: GitHub queues the PR rather than directly merging it. Green checks alone do not authorize the command. - [ ] **Step 3: Locate and monitor the real merge-group run** @@ -618,9 +667,10 @@ gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json state,mergedAt,mergeCo git fetch origin main git log origin/main --oneline --decorate --max-count=3 gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 +gh api repos/QoderAI/cloud-agents-cookbook --jq .allow_auto_merge ``` -Expected: PR #11 is `MERGED`, `mergedAt` and `mergeCommit` are non-null, `origin/main` contains the queued squash result, and the Ruleset is byte-for-field equivalent to the verified post-update configuration from Task 4. +Expected: PR #11 is `MERGED`, `mergedAt` and `mergeCommit` are non-null, `origin/main` contains the queued squash result, repository Auto-merge is still disabled, and the Ruleset is byte-for-field equivalent to the verified post-update configuration from Task 4. - [ ] **Step 5: Roll back on any acceptance failure** diff --git a/docs/superpowers/specs/2026-08-21-merge-queue-design.md b/docs/superpowers/specs/2026-08-21-merge-queue-design.md index c2dcb0c..9695a30 100644 --- a/docs/superpowers/specs/2026-08-21-merge-queue-design.md +++ b/docs/superpowers/specs/2026-08-21-merge-queue-design.md @@ -15,6 +15,8 @@ The repository-level `Protect main` Ruleset targets the default branch and curre - `strict_required_status_checks_policy` is enabled; - no actor can bypass the Ruleset. +The repository has `allow_auto_merge=false`. The current single-maintainer operating model intentionally keeps `required_approving_review_count=0`, `require_code_owner_review=false`, and `require_last_push_approval=false`: GitHub does not allow the author to approve their own pull request, so enabling those settings before a second Maintainer exists would block Maintainer infrastructure work. + The three required workflows listen only to `pull_request` and depend on `github.event.pull_request.*`. GitHub dispatches the separate `merge_group.checks_requested` event for Merge Queue, so enabling the Ruleset rule before adding merge-group checks would leave the queue waiting for required checks that never report. ## Selected approach @@ -38,7 +40,9 @@ on: types: [checks_requested] ``` -It grants only `contents: read`, uses no repository Secrets, never writes repository state, and never executes Demo source. A PR may be marked "Merge when ready" while its pull-request checks are still running, but it becomes an active, buildable queue entry only after satisfying the existing branch requirements. Those checks prevent public contributors from changing repository infrastructure, while internal infrastructure PRs exercise proposed tooling before they become eligible for merge-group validation. +It grants only `contents: read`, uses no repository Secrets, never writes repository state, and never executes Demo source. A PR may be marked "Merge when ready" while its pull-request checks are still running, but it becomes an active, buildable queue entry only after satisfying the existing branch requirements. + +Green checks are not authorization to publish or enqueue a change. A pull request can propose changes to the workflows and tests that produce those checks, so this single-maintainer configuration relies on a separate human admission boundary: Auto-merge remains disabled, and only a Maintainer with write access may manually add a PR to the queue after reviewing both its complete changed-file list and full diff. Before enqueueing, the Maintainer runs `gh pr diff --name-only` and `gh pr diff `. An external PR that touches `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned repository automation/security infrastructure is not admitted to the queue. Such a change must be rebuilt as a Maintainer-owned infrastructure PR and reviewed separately. ### `dco` job @@ -54,11 +58,11 @@ The merge-group `dco` job has no checkout, token use, or repository mutation. It The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, builds the static preview, and uploads an artifact named with `github.run_id`. It does not depend on a pull-request number. -Running the repository preview tooling is safe at this stage because an active merge-group entry has already passed the pull-request contribution-scope gate. External contributors cannot place modified infrastructure into an eligible merge group. +Running the repository preview tooling is accepted at this stage because the write-access Maintainer has completed the manual admission review in addition to the automated pull-request checks. The queue does not independently prove that the candidate workflow is trustworthy. ### `validate` job -The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, and runs `npm run check`. This validates the combined tree containing current `main`, all earlier queue entries, and the current entry. The root `npm test` command is explicitly scoped to `tests/*.test.mjs`; a Demo sentinel regression proves that `demos/**/test.js` is not discovered or executed. +The job checks out the merge-group tree, installs dependencies with `npm ci --ignore-scripts`, and runs `npm run check`. This validates the combined tree containing current `main`, all earlier queue entries, and the current entry. The root `npm test` command invokes `scripts/run-tests.mjs`, which enumerates only top-level `tests/*.test.mjs` files without a shell glob. A fixture copies that same runner into a temporary repository and proves that `demos/**/test.js` and nested test-like files are not discovered or executed on Node.js 20-compatible platforms, including Windows. ## Automated regression checks @@ -67,10 +71,10 @@ Repository tests will statically assert that: - the merge-queue workflow listens to `merge_group` and not `pull_request` or `push`; - the workflow exposes jobs named `dco`, `preview`, and `validate`; - permissions remain read-only and no Secrets or write permissions are referenced; -- the DCO job contains only the approved queue-admission attestation, while the pull-request DCO workflow remains authoritative; -- preview and validate operate on the merge-group SHA and do not depend on `github.event.pull_request.*`; +- the DCO job contains only the approved queue-admission attestation, while a structural contract locks the authoritative pull-request DCO workflow to the PR base/head SHAs, trusted base tooling, complete submission history, and the exact DCO command; +- preview and validate each check out exactly `github.event.merge_group.head_sha` and do not depend on `github.event.pull_request.*`; - Demo source is not executed, including through Node's automatic test discovery; -- `npm test` runs only `tests/*.test.mjs`, and an allowed `demos/**/test.js` sentinel remains unexecuted. +- `npm test` uses the cross-platform Node runner to execute only top-level `tests/*.test.mjs`, and allowed `demos/**/test.js` and nested sentinels remain unexecuted. The complete repository check remains `npm run check`. @@ -94,9 +98,11 @@ After the infrastructure PR is merged, save the complete current Ruleset JSON an The update is performed with `gh api`, followed by an independent GET readback that compares all Ruleset conditions, rules, required checks, review settings, and bypass actors with the intended payload. +Immediately before mutation, also read back repository settings and stop unless `allow_auto_merge=false`. Confirm that the Ruleset still has an empty bypass list, still requires `dco`, `preview`, and `validate`, and still has the approved single-maintainer review parameters: zero required approvals, no required Code Owner review, and no last-push approval. These values are deliberate operational constraints, not substitutes for the manual admission review. + ## Live acceptance test -Use the already-open PR #11 as the first queue entry after confirming it remains open, mergeable, has no unresolved review requirement, and has passing PR checks. +Use the already-open PR #11 as the first queue entry after confirming it remains open, mergeable, has no unresolved review requirement, and has passing PR checks. Before enqueueing, a Maintainer must inspect `gh pr diff 11 --name-only` and `gh pr diff 11` and confirm the PR contains only the expected content-translation scope under `content/**`, with no workflow, script, test, package, configuration, Schema, or Maintainer-documentation changes. Add #11 through `gh pr merge 11 --squash`. Because the Ruleset requires Merge Queue, this command must enqueue the PR instead of directly merging it. Acceptance requires: @@ -108,6 +114,8 @@ Add #11 through `gh pr merge 11 --squash`. Because the Ruleset requires Merge Qu No bypass option or direct push to `main` is allowed during acceptance. +Only a Maintainer with write access performs the queue command. Auto-merge remains disabled; green status checks alone never authorize adding PR #11 or any future PR to the queue. + ## Failure handling and rollback Before changing the Ruleset, write its complete API response and the exact update payload to a temporary local validation directory outside the repository. Do not include tokens or response headers. @@ -122,5 +130,6 @@ The configuration is complete only when: 1. The infrastructure PR is merged with DCO and all required checks passing. 2. The active Ruleset contains the exact single-entry squash Merge Queue configuration and no unintended changes. -3. PR #11 receives all three successful contexts from a real merge-group run and is automatically squash-merged. -4. The final `main` and Ruleset states are independently read back through GitHub CLI. +3. Repository Auto-merge remains disabled and the empty-bypass, manual Maintainer admission contract is documented and verified. +4. PR #11 is confirmed to contain only the expected content translation, receives all three successful contexts from a real merge-group run, and is automatically squash-merged. +5. The final `main` and Ruleset states are independently read back through GitHub CLI. diff --git a/package.json b/package.json index 65de40e..54668a8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=20" }, "scripts": { - "test": "node --test tests/*.test.mjs", + "test": "node scripts/run-tests.mjs", "validate": "node scripts/validate.mjs", "validate:demos": "node scripts/validate-demos.mjs", "build": "node scripts/build-catalog.mjs", diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..1178af4 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testsDirectoryUrl = new URL('../tests/', import.meta.url); +const testsDirectory = fileURLToPath(testsDirectoryUrl); +const entries = await readdir(testsDirectoryUrl, { withFileTypes: true }); +const testFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.test.mjs')) + .map((entry) => path.join(testsDirectory, entry.name)) + .sort(); + +if (testFiles.length === 0) { + console.error(`No top-level .test.mjs files found in ${testsDirectory}`); + process.exitCode = 1; +} else { + try { + const child = spawn(process.execPath, ['--test', ...testFiles], { stdio: 'inherit' }); + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + + if (result.signal) process.kill(process.pid, result.signal); + else process.exitCode = result.code ?? 1; + } catch (error) { + console.error(`Unable to start Node's test runner: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/tests/automation.test.mjs b/tests/automation.test.mjs index d175e6a..5d48c44 100644 --- a/tests/automation.test.mjs +++ b/tests/automation.test.mjs @@ -72,22 +72,74 @@ test('merge queue validates the synthetic group with the existing check contexts assert.match(workflow.jobs.dco.steps[0].run, /printf/); assert.match(workflow.jobs.dco.steps[0].run, /required pull-request checks, including dco, passed/i); assert.doesNotMatch(source, /--no-merges|check-dco/); + for (const jobName of ['preview', 'validate']) { + const checkout = workflow.jobs[jobName].steps.find((step) => step.name === 'Check out merge-group tree'); + assert.ok(checkout, `${jobName} must check out the merge-group tree`); + assert.equal(checkout.with.ref, '${{ github.event.merge_group.head_sha }}'); + } assert.match(source, /node submission\/scripts\/build-preview\.mjs --root submission --contract-root submission --out-dir artifacts\/preview/); assert.match(source, /cookbook-preview-\${{ github\.run_id }}/); assert.match(source, /working-directory: submission\n\s+run: npm run check/); assert.doesNotMatch(source, /working-directory:\s*submission\/demos|npm\s+--prefix\s+demos|docker\s+build|make\s+(?:-[^\s]+\s+)*demos/i); }); +test('pull-request DCO remains the authoritative contributor sign-off check', async () => { + const source = await readFile(path.join(repoRoot, '.github', 'workflows', 'dco.yml'), 'utf8'); + const workflow = YAML.parse(source); + assert.deepEqual(workflow.on, { pull_request: { branches: ['main'] } }); + assert.deepEqual(Object.keys(workflow.jobs), ['dco']); + + const job = workflow.jobs.dco; + assert.deepEqual(job.env, { + BASE_SHA: '${{ github.event.pull_request.base.sha }}', + HEAD_SHA: '${{ github.event.pull_request.head.sha }}' + }); + + const trustedCheckout = job.steps.find((step) => step.name === 'Check out trusted tooling'); + assert.deepEqual(trustedCheckout.with, { + ref: '${{ env.BASE_SHA }}', + path: 'trusted', + 'persist-credentials': false + }); + + const submissionCheckout = job.steps.find((step) => step.name === 'Check out pull request history'); + assert.deepEqual(submissionCheckout.with, { + ref: '${{ env.HEAD_SHA }}', + path: 'submission', + 'fetch-depth': 0, + 'persist-credentials': false + }); + + const dcoCheck = job.steps.find((step) => step.name === 'Check every commit sign-off'); + assert.equal(dcoCheck.run, 'node trusted/scripts/check-dco.mjs --repo submission --base "$BASE_SHA" --head "$HEAD_SHA"'); + assert.doesNotMatch(dcoCheck.run, /--no-merges/); +}); + test('npm test discovers only repository tests and never executes Demo source', async () => { const packageJson = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + const runnerSource = await readFile(path.join(repoRoot, 'scripts', 'run-tests.mjs'), 'utf8'); + assert.equal(packageJson.scripts.test, 'node scripts/run-tests.mjs'); + assert.match(runnerSource, /new URL\('\.\.\/tests\/', import\.meta\.url\)/); + assert.match(runnerSource, /entry\.isFile\(\) && entry\.name\.endsWith\('\.test\.mjs'\)/); + assert.match(runnerSource, /\.sort\(\)/); + assert.match(runnerSource, /spawn\(process\.execPath, \['--test', \.\.\.testFiles\]/); + assert.match(runnerSource, /child\.once\('error', reject\)/); + assert.match(runnerSource, /child\.once\('exit'/); + assert.match(runnerSource, /process\.kill\(process\.pid, result\.signal\)/); + assert.match(runnerSource, /process\.exitCode = result\.code \?\? 1/); + assert.doesNotMatch(runnerSource, /demos|recursive:\s*true/); + const root = await mkdtemp(path.join(tmpdir(), 'qca-cookbook-test-discovery-')); await mkdir(path.join(root, 'tests'), { recursive: true }); + await mkdir(path.join(root, 'tests', 'nested'), { recursive: true }); await mkdir(path.join(root, 'demos', 'example'), { recursive: true }); + await mkdir(path.join(root, 'scripts'), { recursive: true }); await writeFile(path.join(root, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { test: packageJson.scripts.test } })); + await writeFile(path.join(root, 'scripts', 'run-tests.mjs'), runnerSource); await writeFile(path.join(root, 'tests', 'safe.test.mjs'), ` import test from 'node:test'; test('SAFE_FIXTURE_TEST', () => {}); @@ -95,13 +147,18 @@ test('npm test discovers only repository tests and never executes Demo source', await writeFile(path.join(root, 'demos', 'example', 'test.js'), ` throw new Error('DEMO_EXECUTED_SENTINEL'); `); + await writeFile(path.join(root, 'tests', 'nested', 'nested.test.mjs'), ` + throw new Error('NESTED_TEST_EXECUTED_SENTINEL'); + `); const env = { ...process.env }; delete env.NODE_TEST_CONTEXT; - const result = await execFileAsync('npm', ['test'], { cwd: root, env }).catch((error) => error); + const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const result = await execFileAsync(npmExecutable, ['test'], { cwd: root, env }).catch((error) => error); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; assert.match(output, /SAFE_FIXTURE_TEST/); assert.doesNotMatch(output, /DEMO_EXECUTED_SENTINEL/); + assert.doesNotMatch(output, /NESTED_TEST_EXECUTED_SENTINEL/); assert.equal(result.code ?? 0, 0, output); }); From 3ad8e6a5979c193fb48f283d7626b9793f491b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 16:17:46 +0800 Subject: [PATCH 8/9] fix: bind queue merges to reviewed heads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- docs/automated-checks.md | 8 +- docs/maintainers/implementation-plan.md | 6 +- docs/maintainers/repository-design.md | 6 +- docs/maintainers/repository-settings.md | 10 +- docs/repository-governance.md | 6 +- .../plans/2026-08-21-merge-queue.md | 93 ++++++++++++++----- .../specs/2026-08-21-merge-queue-design.md | 18 ++-- tests/automation.test.mjs | 3 +- 8 files changed, 105 insertions(+), 45 deletions(-) diff --git a/docs/automated-checks.md b/docs/automated-checks.md index bd85020..d84b1be 100644 --- a/docs/automated-checks.md +++ b/docs/automated-checks.md @@ -42,11 +42,15 @@ Automated checks do not determine factual correctness, public product status, De ## Merge Queue admission -Auto-merge must remain disabled. Only a Maintainer with write access may manually add a pull request to the queue, and green checks alone are never sufficient authorization. Before enqueueing, review both outputs in full: +Auto-merge must remain disabled. Only a Maintainer with write access may manually add a pull request to the queue, and green checks alone are never sufficient authorization. Capture the PR's `headRefOid` before reviewing both outputs in full: ```bash +TASK_REVIEWED_SHA="$(gh pr view --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" gh pr diff --name-only gh pr diff +TASK_CURRENT_SHA="$(gh pr view --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" +test "$TASK_CURRENT_SHA" = "$TASK_REVIEWED_SHA" +gh pr merge --repo QoderAI/cloud-agents-cookbook --match-head-commit "$TASK_REVIEWED_SHA" --squash ``` -Do not queue an external pull request that touches `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned automation/security infrastructure. Recreate and review that work as a Maintainer-owned infrastructure pull request. The Ruleset keeps zero required approvals only because the repository currently has a single Maintainer; it compensates with an empty bypass list and this explicit manual admission boundary. When a second Maintainer is available, require approval, Code Owner review, and latest-push approval. +Replace the `TASK_` prefix with a name unique to the operation. Read `headRefOid` again immediately before enqueueing and require strict equality with the reviewed SHA. If the head changes, stop and repeat the complete review; `--match-head-commit` is mandatory. Do not queue an external pull request that touches `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned automation/security infrastructure. Recreate and review that work as a Maintainer-owned infrastructure pull request. The Ruleset keeps zero required approvals only because the repository currently has a single Maintainer; it compensates with an empty bypass list and this explicit manual admission boundary. When a second Maintainer is available, require approval, Code Owner review, and latest-push approval. diff --git a/docs/maintainers/implementation-plan.md b/docs/maintainers/implementation-plan.md index 018b401..ce68e89 100644 --- a/docs/maintainers/implementation-plan.md +++ b/docs/maintainers/implementation-plan.md @@ -1,5 +1,7 @@ # Qoder Cloud Agents Cookbook Public Repository Implementation Plan +> **Historical design note:** This plan records the repository's initial build sequence and is not the source of truth for live GitHub settings. The current single-Maintainer Merge Queue, zero-approval review parameters, SHA-bound manual admission gate, and future second-Maintainer upgrade are defined in `docs/maintainers/repository-settings.md`. If this historical plan conflicts with that document, follow `repository-settings.md`. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build, verify, and publish a production-grade public content-source repository for Qoder Cloud Agents Cookbook. @@ -20,7 +22,7 @@ - Mermaid supports only flowchart, sequenceDiagram, and stateDiagram-v2 with no click, external resource, HTML label, or init directive. - Content and documentation use CC BY 4.0; executable code uses Apache-2.0; every contributed commit requires DCO sign-off. - No fabricated launch article is stored under `content/`; examples belong under `tests/fixtures/`. -- External pull requests never receive secrets and never execute contributor-supplied code. +- External pull requests never receive secrets; intended content validation treats contributor content and Demo source as data. Because a candidate can modify check-producing workflow infrastructure, the live SHA-bound manual queue-admission policy in `repository-settings.md` remains authoritative. --- @@ -146,7 +148,7 @@ **Interfaces:** - Produces: exact remote setup, branch protection, secrets, preview, publication acknowledgement, rollback, and incident procedures. -- [ ] Document required GitHub settings: `main`, pull requests, one approval, CODEOWNERS, resolved conversations, required checks, no force push, no deletion, and Actions budget controls. +- [ ] Document live GitHub settings in `repository-settings.md`: `main`, pull requests, current single-Maintainer zero-approval parameters, informational CODEOWNERS, resolved conversations, required checks, empty bypass list, conservative Merge Queue, disabled Auto-merge, SHA-bound manual admission, no force push, no deletion, and Actions budget controls. Record the future upgrade to approval, Code Owner review, and latest-push approval after a second Maintainer is available. - [ ] Define preview and publish payloads, expected acknowledgement, idempotency key, source commit, checksum, and failure behavior. - [ ] Document release rollback by revert and republish, with slug redirects and lifecycle state behavior. - [ ] Run the repository link checker and full `npm run check`. diff --git a/docs/maintainers/repository-design.md b/docs/maintainers/repository-design.md index 6c1edd0..d5827a2 100644 --- a/docs/maintainers/repository-design.md +++ b/docs/maintainers/repository-design.md @@ -57,11 +57,11 @@ dist/ generated output; never committed Validation is deterministic and runs locally with `npm run check` and in GitHub Actions for every pull request. A public contribution may change valid article paths and `demos//**`; repository infrastructure changes are handled in separate Maintainer pull requests. -For public content pull requests, the workflow checks out trusted tooling from the default branch and treats the contributor tree strictly as input data. It uses read-only permissions, receives no secrets, and does not execute contributor-supplied scripts. Infrastructure changes are restricted to repository owners, organization members, and collaborators; those trusted pull requests additionally install, test, and exercise the proposed tooling, still without secrets or write permissions. Required status checks block merge when validation, tests, DCO, or preview generation fails. +For public content pull requests, the intended validation path checks out trusted tooling from the default branch and treats content and Demo files as input data. It uses read-only permissions, receives no secrets, and never executes Demo source. A candidate PR can still propose changes to the workflow orchestration that produces status checks, so green checks are not authorization; the SHA-bound manual admission gate prevents an external infrastructure change from entering the queue. Maintainer-owned infrastructure pull requests additionally install, test, and exercise the proposed tooling, still without secrets or write permissions. Required status checks block merge when validation, tests, DCO, or preview generation fails. Automated checks cover metadata schema, global slug uniqueness, path consistency, taxonomy, article structure, required sections, images, links, Markdown fences, footnotes, Mermaid syntax and safety, unsupported elements, common secret patterns, Demo binding and static safety, configuration references, and deterministic catalog generation. Submitted Demo commands and source are never executed. -Automated checks do not decide factual accuracy, Demo runtime correctness or operational safety, publication value, copyright ownership, customer authorization, or whether a statement describes a public product capability. Maintainers review these areas and Demo source manually and merge approved pull requests. +Automated checks do not decide factual accuracy, Demo runtime correctness or operational safety, publication value, copyright ownership, customer authorization, or whether a statement describes a public product capability. Maintainers review these areas and Demo source manually. In the current single-Maintainer configuration, Auto-merge is disabled and only a write-access Maintainer may manually queue a change after binding the full file-list and diff review to the PR's immutable `headRefOid`. The head SHA is read again immediately before `gh pr merge --match-head-commit`; any change requires a complete re-review. Green checks are evidence, not queue authorization. After a second Maintainer is available, require approval, Code Owner review, and latest-push approval while retaining the SHA-bound manual infrastructure review. ## Preview and publication @@ -85,7 +85,7 @@ The final PRD marks launch content as pending. Therefore `content/` initially co ## Operational safety -- `main` is protected and requires pull requests, required checks, resolved conversations, and maintainer approval. +- `main` is protected and requires pull requests, required checks, resolved conversations, an empty bypass list, and a conservative single-entry Merge Queue. The present single-Maintainer Ruleset requires zero approvals; admission instead uses the SHA-bound manual gate documented in `docs/maintainers/repository-settings.md`. - Fork pull requests receive read-only tokens and no repository secrets. - Preview artifacts have short retention and standard GitHub-hosted runners only. - Publication secrets are available only to the trusted `push` workflow on `main`. diff --git a/docs/maintainers/repository-settings.md b/docs/maintainers/repository-settings.md index bbb306c..802f49f 100644 --- a/docs/maintainers/repository-settings.md +++ b/docs/maintainers/repository-settings.md @@ -28,14 +28,20 @@ The initial CODEOWNER is `@anchenqlw`, but CODEOWNERS is currently routing infor ## Manual queue admission -Green checks show that the candidate produced the expected contexts; they do not authorize a merge. A pull request can propose changes to the workflows, scripts, and tests that produce those contexts. Before every enqueue operation, the write-access Maintainer must run: +Green checks show that the candidate produced the expected contexts; they do not authorize a merge. A pull request can propose changes to the workflows, scripts, and tests that produce those contexts. Every admission review is bound to one immutable pull-request head SHA. Use a task-specific variable name when operating on a real PR: ```bash +TASK_REVIEWED_SHA="$(gh pr view --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" gh pr diff --name-only gh pr diff +TASK_CURRENT_SHA="$(gh pr view --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" +test "$TASK_CURRENT_SHA" = "$TASK_REVIEWED_SHA" +gh pr merge --repo QoderAI/cloud-agents-cookbook --match-head-commit "$TASK_REVIEWED_SHA" --squash ``` -Review the complete file list and full diff, confirm the change is expected, and only then use the queue command. Do not queue an external pull request that changes `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned repository automation/security infrastructure. Recreate such work on a Maintainer-owned branch and submit it as a separate infrastructure pull request. +Replace the `TASK_` prefix with a name unique to the operation, such as `INFRA_` or `PR11_`. Capture the reviewed SHA before inspecting the complete file list and full diff. Immediately before the queue command, read `headRefOid` again and require strict equality. If it changed for any reason, stop and restart the review against the new SHA. `--match-head-commit` is mandatory and prevents the enqueue operation from racing with a later push. + +Do not queue an external pull request that changes `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned repository automation/security infrastructure. Recreate such work on a Maintainer-owned branch and submit it as a separate infrastructure pull request. The first queue acceptance case, PR #11, must contain only the expected content translation under `content/**`. Any other path is a stop condition, even if all checks are green. diff --git a/docs/repository-governance.md b/docs/repository-governance.md index 4829d8c..f272c9c 100644 --- a/docs/repository-governance.md +++ b/docs/repository-governance.md @@ -26,7 +26,11 @@ Introducing or removing a Demo requires changing its owner article in the same p ## Merge and publication -Required checks, one maintainer approval, resolved review conversations, and a successful preview are required before merge. Maintainers merge manually. A merge to `main` is the publication event; there is no later content-import step. +Required checks, resolved review conversations, and a successful preview are required before merge. While the repository has only one Maintainer, the Ruleset requires zero approvals, no Code Owner review, and no last-push approval because GitHub does not allow an author to approve their own pull request. Auto-merge is disabled and the bypass list is empty. + +Only a Maintainer with write access may manually add a PR to Merge Queue. Before doing so, the Maintainer captures `headRefOid` in a task-specific variable, reviews `gh pr diff --name-only` and the complete `gh pr diff `, reads `headRefOid` again and requires exact equality, then uses `gh pr merge --match-head-commit "$TASK_REVIEWED_SHA" --squash`. Any head change invalidates the review and requires a complete re-review. Green checks alone never authorize queue admission. External infrastructure changes are rebuilt as separate Maintainer-owned pull requests. + +When a second Maintainer or Maintainer team has write access, upgrade the Ruleset to require at least one approval, Code Owner review, and approval of the latest push. The SHA-bound infrastructure diff review remains required. A merge to `main` is the publication event; there is no later content-import step. The publication workflow must preserve the last successful version when validation, build, upload, or downstream acknowledgement fails. diff --git a/docs/superpowers/plans/2026-08-21-merge-queue.md b/docs/superpowers/plans/2026-08-21-merge-queue.md index a0cdde6..bff25d1 100644 --- a/docs/superpowers/plans/2026-08-21-merge-queue.md +++ b/docs/superpowers/plans/2026-08-21-merge-queue.md @@ -17,7 +17,7 @@ - Workflows use only SHA-pinned official GitHub Actions, `permissions: contents: read`, bounded timeouts, no Secrets, no write tokens, and no Demo source execution. - Existing `pull_request` DCO behavior and `scripts/check-dco.mjs` remain unchanged and continue checking every contributor commit. - The pull-request `dco` context must remain required before and after queue enablement; the merge-group job named `dco` only attests that this admission gate passed. -- Auto-merge remains disabled. Green checks are not authorization; only a Maintainer with write access may manually queue a PR after running `gh pr diff --name-only` and reviewing `gh pr diff ` in full. +- Auto-merge remains disabled. Green checks are not authorization; only a Maintainer with write access may manually queue a PR after capturing its `headRefOid`, reviewing `gh pr diff --name-only` and `gh pr diff ` in full, re-reading the head with strict equality, and using `--match-head-commit` for that reviewed SHA. Any head change requires a complete re-review. - Never queue an external PR that changes `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned automation/security infrastructure. Recreate it as a Maintainer-owned infrastructure PR. - Preserve the approved single-maintainer Ruleset review parameters: zero required approvals, no required Code Owner review, and no last-push approval. Preserve the empty bypass list. - The root test command is exactly `node scripts/run-tests.mjs`; the runner enumerates sorted, top-level `tests/*.test.mjs` paths without a shell glob. Demo and nested sentinels must remain unexecuted. @@ -82,8 +82,7 @@ test('npm test discovers only repository tests and never executes Demo source', const env = { ...process.env }; delete env.NODE_TEST_CONTEXT; - const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const result = await execFileAsync(npmExecutable, ['test'], { cwd: root, env }).catch((error) => error); + const result = await execFileAsync(process.execPath, [path.join(root, 'scripts', 'run-tests.mjs')], { cwd: root, env }).catch((error) => error); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; assert.match(output, /SAFE_FIXTURE_TEST/); assert.doesNotMatch(output, /DEMO_EXECUTED_SENTINEL/); @@ -372,7 +371,7 @@ Create a temporary PR body containing: - add dedicated `merge_group` validation with the existing `dco`, `preview`, and `validate` contexts - keep pull-request DCO authoritative and use a queue-admission attestation for the merge-group `dco` context - use a cross-platform Node test runner and prove Demo and nested test-like files stay inert -- document the single-Maintainer manual diff-review and queue-admission boundary +- document the single-Maintainer SHA-bound diff-review and queue-admission boundary - preserve the existing pull-request trust boundary and Demo-as-data policy ## Validation @@ -380,6 +379,7 @@ Create a temporary PR body containing: - `npm run check` - authoritative PR-DCO and exact merge-group `head_sha` checkout contract tests - temporary-repository test-runner fixture +- infrastructure and content queue admission bound to reviewed `headRefOid` with `--match-head-commit` - real Merge Queue acceptance will run after this PR lands and Ruleset `20582196` is updated Signed-off-by: 安陈 @@ -395,16 +395,18 @@ Expected: a non-draft PR URL. - [ ] **Step 4: Verify the PR diff and checks** -Run: +Resolve the infrastructure PR number and capture its immutable head before reviewing: ```bash -gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,files,commits,statusCheckRollup,url -gh pr diff codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --name-only -gh pr diff codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook -gh pr checks codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook +INFRA_PR_NUMBER="$(gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json number --jq .number)" +INFRA_REVIEWED_SHA="$(gh pr view "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" +gh pr view "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,headRefOid,files,commits,statusCheckRollup,url +gh pr diff "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook --name-only +gh pr diff "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook +gh pr checks "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook ``` -Review the complete changed-file list and full diff before considering the PR eligible. Poll the checks command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; files are limited to the approved workflow, package script, Node test runner, automation test, design/plan, repository-settings, and automated-checks documentation. `scripts/check-dco.mjs` and `.github/workflows/dco.yml` must remain unchanged. Green checks do not replace this diff review. +Review the complete changed-file list and full diff for `INFRA_REVIEWED_SHA` before considering the PR eligible. Poll the checks command at intervals no shorter than 15 seconds. Expected: `dco`, `preview`, and `validate` all conclude `SUCCESS`; files are limited to the approved workflow, package script, Node test runner, automation test, design/plan, governance, repository-design, implementation-plan, repository-settings, and automated-checks documentation. `scripts/check-dco.mjs` and `.github/workflows/dco.yml` must remain unchanged. Green checks do not replace this SHA-bound diff review. - [ ] **Step 5: Handle a newly stale infrastructure PR if necessary** @@ -417,17 +419,19 @@ npm run check git push origin codex/enable-merge-queue ``` -Then wait again for all three PR checks. Do not use GitHub's unqualified force-update or bypass options. +Then wait again for all three PR checks. The merge commit changes `headRefOid`, so discard `INFRA_REVIEWED_SHA` and repeat Step 4 from the capture before any merge attempt. Do not use GitHub's unqualified force-update or bypass options. - [ ] **Step 6: Squash-merge the infrastructure PR through the current Ruleset** -Resolve the PR number from the current feature branch. Re-run `gh pr diff --name-only` and review `gh pr diff ` in full, then merge that exact Maintainer-owned infrastructure PR through the current strict Ruleset: +Immediately before merging, re-read the infrastructure head and require it to equal the SHA reviewed in Step 4. Merge that exact Maintainer-owned infrastructure PR through the current strict Ruleset with the server-side head guard: ```bash -gh pr merge "$(gh pr view codex/enable-merge-queue --repo QoderAI/cloud-agents-cookbook --json number --jq .number)" --repo QoderAI/cloud-agents-cookbook --squash +INFRA_CURRENT_SHA="$(gh pr view "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" +test "$INFRA_CURRENT_SHA" = "$INFRA_REVIEWED_SHA" +gh pr merge "$INFRA_PR_NUMBER" --repo QoderAI/cloud-agents-cookbook --match-head-commit "$INFRA_REVIEWED_SHA" --squash ``` -Expected: the branch lookup returns the newly created infrastructure PR and it merges without bypass after all required checks pass. Never reuse a historical PR number. +Expected: strict equality succeeds and GitHub merges the reviewed head without bypass after all required checks pass. If equality or `--match-head-commit` fails, stop and repeat Step 4 against the new head. Never reuse a historical PR number or a reviewed SHA from another task. - [ ] **Step 7: Verify the workflow is on `main`** @@ -620,6 +624,8 @@ Stop and restore immediately if any field differs. **Files:** - Read only: `/private/tmp/qca-merge-queue-20260821/ruleset-before.json` - Read only: `/private/tmp/qca-merge-queue-20260821/ruleset-restore.json` +- Create outside repository on rollback: `/private/tmp/qca-merge-queue-20260821/pr11-queue-before-rollback.json` +- Create outside repository on rollback: `/private/tmp/qca-merge-queue-20260821/pr11-queue-after-dequeue.json` **Interfaces:** - Consumes: the active Merge Queue Ruleset and merged workflow. @@ -628,37 +634,45 @@ Stop and restore immediately if any field differs. - [ ] **Step 1: Revalidate PR #11 immediately before enqueueing** ```bash -gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefName,baseRefName,url +PR11_REVIEWED_SHA="$(gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" +gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json number,state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefName,headRefOid,baseRefName,url gh pr diff 11 --repo QoderAI/cloud-agents-cookbook --name-only gh pr diff 11 --repo QoderAI/cloud-agents-cookbook gh api repos/QoderAI/cloud-agents-cookbook --jq .allow_auto_merge ``` -Expected: `state=OPEN`, `isDraft=false`, `mergeable=MERGEABLE`, base `main`, no unresolved review requirement, and the PR-level `dco`, `preview`, and `validate` conclusions are `SUCCESS`. Review the complete file list and full diff: PR #11 must contain only the expected content translation under `content/**`; any `.github/**`, `scripts/**`, `tests/**`, package, configuration, Schema, documentation, or otherwise unexpected file is a stop condition. Confirm `allow_auto_merge=false`. A `BEHIND` status is acceptable because the Merge Queue now validates the synthetic group. +Expected: `state=OPEN`, `isDraft=false`, `mergeable=MERGEABLE`, base `main`, no unresolved review requirement, and the PR-level `dco`, `preview`, and `validate` conclusions are `SUCCESS`. Review the complete file list and full diff for `PR11_REVIEWED_SHA`: PR #11 must contain only the expected content translation under `content/**`; any `.github/**`, `scripts/**`, `tests/**`, package, configuration, Schema, documentation, or otherwise unexpected file is a stop condition. Confirm `allow_auto_merge=false`. A `BEHIND` status is acceptable because the Merge Queue now validates the synthetic group. - [ ] **Step 2: Submit PR #11 to the queue** ```bash -gh pr merge 11 --repo QoderAI/cloud-agents-cookbook --squash +PR11_CURRENT_SHA="$(gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json headRefOid --jq .headRefOid)" +test "$PR11_CURRENT_SHA" = "$PR11_REVIEWED_SHA" +PR11_ENQUEUE_UTC="$(node -e 'process.stdout.write(new Date().toISOString())')" +gh pr merge 11 --repo QoderAI/cloud-agents-cookbook --match-head-commit "$PR11_REVIEWED_SHA" --squash ``` -This command must be run by the write-access Maintainer only after completing Step 1. Expected: GitHub queues the PR rather than directly merging it. Green checks alone do not authorize the command. +This command must be run by the write-access Maintainer only after completing Step 1. The head read occurs immediately before enqueueing and must match exactly; if it differs or `--match-head-commit` rejects it, stop and repeat Step 1 against the new head. Expected: GitHub queues the reviewed PR rather than directly merging it. Green checks alone do not authorize the command. - [ ] **Step 3: Locate and monitor the real merge-group run** Poll no more frequently than every 15 seconds: ```bash -gh run list --repo QoderAI/cloud-agents-cookbook --workflow merge-queue.yml --event merge_group --limit 5 --json databaseId,status,conclusion,headSha,event,createdAt,url +gh run list --repo QoderAI/cloud-agents-cookbook --workflow merge-queue.yml --event merge_group --limit 10 --json databaseId,status,conclusion,headBranch,headSha,event,createdAt,url ``` -After a newly created run appears, resolve the newest merge-queue run ID directly and poll it: +Accept only a run whose `createdAt` is strictly later than `PR11_ENQUEUE_UTC` and whose `headBranch` starts with `gh-readonly-queue/main/pr-11-`. Resolve that matching run, not merely the newest merge-group run: ```bash -gh run view "$(gh run list --repo QoderAI/cloud-agents-cookbook --workflow merge-queue.yml --event merge_group --limit 1 --json databaseId --jq '.[0].databaseId')" --repo QoderAI/cloud-agents-cookbook --json databaseId,status,conclusion,jobs,headSha,event,url +PR11_QUEUE_RUN_ID="$(gh run list --repo QoderAI/cloud-agents-cookbook --workflow merge-queue.yml --event merge_group --limit 10 --json databaseId,createdAt,headBranch --jq "[.[] | select(.createdAt > \"$PR11_ENQUEUE_UTC\" and ((.headBranch // \"\") | startswith(\"gh-readonly-queue/main/pr-11-\")))][0].databaseId")" +test -n "$PR11_QUEUE_RUN_ID" +test "$PR11_QUEUE_RUN_ID" != "null" +PR11_QUEUE_RUN_JSON="$(gh run view "$PR11_QUEUE_RUN_ID" --repo QoderAI/cloud-agents-cookbook --json databaseId,status,conclusion,jobs,headBranch,headSha,createdAt,event,url)" +printf '%s\n' "$PR11_QUEUE_RUN_JSON" ``` -Expected within the configured 10-minute response window: event `merge_group`; jobs `dco`, `preview`, and `validate`; all three conclude `success`. Confirm the run `createdAt` is later than the enqueue action before treating it as acceptance evidence. +Expected within the configured 10-minute response window: event `merge_group`; `createdAt > PR11_ENQUEUE_UTC`; `headBranch` matches `gh-readonly-queue/main/pr-11-...`; jobs `dco`, `preview`, and `validate`; all three conclude `success`. Record `PR11_QUEUE_RUN_ID`, `headBranch`, and `headSha` as acceptance evidence. A run for another queue entry must never satisfy PR #11 acceptance. - [ ] **Step 4: Verify automatic squash merge and final state** @@ -674,15 +688,44 @@ Expected: PR #11 is `MERGED`, `mergedAt` and `mergeCommit` are non-null, `origin - [ ] **Step 5: Roll back on any acceptance failure** -If the merge-group run does not appear, remains incomplete beyond 10 minutes, or any required job fails, do not bypass or directly merge PR #11. Run once: +If the merge-group run does not appear, remains incomplete beyond 10 minutes, or any required job fails, do not bypass or directly merge PR #11. Dequeue it before changing the Ruleset. Query the exact PR node and queue entry: + +```bash +PR11_NODE_ID="$(gh pr view 11 --repo QoderAI/cloud-agents-cookbook --json id --jq .id)" +gh api graphql \ + -f query='query($id: ID!) { node(id: $id) { ... on PullRequest { id number state mergeQueueEntry { position } } } }' \ + -F id="$PR11_NODE_ID" \ + > /private/tmp/qca-merge-queue-20260821/pr11-queue-before-rollback.json +jq '.data.node | {id, number, state, mergeQueueEntry}' /private/tmp/qca-merge-queue-20260821/pr11-queue-before-rollback.json +``` + +If `mergeQueueEntry` is non-null, call the official dequeue mutation exactly once: + +```bash +gh api graphql \ + -f query='mutation($id: ID!) { dequeuePullRequest(input: {pullRequestId: $id}) { clientMutationId } }' \ + -F id="$PR11_NODE_ID" +``` + +If the mutation fails, stop and do not change the Ruleset. After a successful mutation, or when the first query showed no entry, query again and prove the PR is still open and no longer queued: + +```bash +gh api graphql \ + -f query='query($id: ID!) { node(id: $id) { ... on PullRequest { id number state mergeQueueEntry { position } } } }' \ + -F id="$PR11_NODE_ID" \ + > /private/tmp/qca-merge-queue-20260821/pr11-queue-after-dequeue.json +jq -e '.data.node.state == "OPEN" and .data.node.mergeQueueEntry == null' /private/tmp/qca-merge-queue-20260821/pr11-queue-after-dequeue.json +``` + +Only if that assertion succeeds may the Ruleset be restored: ```bash gh api --method PUT repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 --input /private/tmp/qca-merge-queue-20260821/ruleset-restore.json gh api repos/QoderAI/cloud-agents-cookbook/rulesets/20582196 ``` -Expected: no `merge_queue` rule, `strict_required_status_checks_policy=true`, required checks still exactly `dco`, `preview`, and `validate`, and every other rule unchanged. Report the failed run URL and leave the merged workflow inert on `main` for a follow-up repair PR. +Expected: PR #11 remains `OPEN` with `mergeQueueEntry=null`; the restored Ruleset has no `merge_queue` rule, `strict_required_status_checks_policy=true`, required checks still exactly `dco`, `preview`, and `validate`, and every other rule unchanged. If either dequeue proof fails, leave the queue Ruleset unchanged and report the blocking state. Otherwise report the failed run URL and leave the merged workflow inert on `main` for a follow-up repair PR. - [ ] **Step 6: Report completion evidence** -Report the infrastructure PR URL and merge SHA, real merge-group Actions run URL and three job conclusions, PR #11 merge SHA, final `origin/main` SHA, Ruleset ID and exact queue parameters, and whether rollback was needed. +Report the infrastructure PR URL, reviewed head SHA, and merge SHA; PR #11's reviewed head SHA and enqueue UTC time; the accepted merge-group run ID, URL, `headBranch`, `headSha`, and three job conclusions; PR #11 merge SHA; final `origin/main` SHA; Ruleset ID and exact queue parameters; and whether rollback/dequeue was needed. diff --git a/docs/superpowers/specs/2026-08-21-merge-queue-design.md b/docs/superpowers/specs/2026-08-21-merge-queue-design.md index 9695a30..848cf6b 100644 --- a/docs/superpowers/specs/2026-08-21-merge-queue-design.md +++ b/docs/superpowers/specs/2026-08-21-merge-queue-design.md @@ -42,7 +42,7 @@ on: It grants only `contents: read`, uses no repository Secrets, never writes repository state, and never executes Demo source. A PR may be marked "Merge when ready" while its pull-request checks are still running, but it becomes an active, buildable queue entry only after satisfying the existing branch requirements. -Green checks are not authorization to publish or enqueue a change. A pull request can propose changes to the workflows and tests that produce those checks, so this single-maintainer configuration relies on a separate human admission boundary: Auto-merge remains disabled, and only a Maintainer with write access may manually add a PR to the queue after reviewing both its complete changed-file list and full diff. Before enqueueing, the Maintainer runs `gh pr diff --name-only` and `gh pr diff `. An external PR that touches `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned repository automation/security infrastructure is not admitted to the queue. Such a change must be rebuilt as a Maintainer-owned infrastructure PR and reviewed separately. +Green checks are not authorization to publish or enqueue a change. A pull request can propose changes to the workflows and tests that produce those checks, so this single-maintainer configuration relies on a separate human admission boundary: Auto-merge remains disabled, and only a Maintainer with write access may manually add a PR to the queue. The Maintainer captures `headRefOid` in a task-specific reviewed-SHA variable before reviewing the complete `gh pr diff --name-only` output and full `gh pr diff `, reads the head again immediately before enqueueing, and requires strict equality. The queue command includes `--match-head-commit "$TASK_REVIEWED_SHA"`; any head change invalidates the review and requires a complete re-review. An external PR that touches `.github/**`, `scripts/**`, `tests/**`, root `package*.json`, `config/**`, `schema/**`, `docs/**`, or other Maintainer-owned repository automation/security infrastructure is not admitted to the queue. Such a change must be rebuilt as a Maintainer-owned infrastructure PR and reviewed separately. ### `dco` job @@ -80,7 +80,7 @@ The complete repository check remains `npm run check`. ## Infrastructure pull request -The workflow, package test command, regression tests, and design changes are committed on `codex/enable-merge-queue` with DCO sign-off and submitted as a repository-infrastructure pull request. The PR is merged through the current strict, squash-only process after `dco`, `preview`, and `validate` pass. +The workflow, package test command, regression tests, and design changes are committed on `codex/enable-merge-queue` with DCO sign-off and submitted as a repository-infrastructure pull request. Before merging, capture that PR's head as `INFRA_REVIEWED_SHA`, inspect its complete changed-file list and full diff, then re-read the head and require equality. Merge through the current strict, squash-only process with `--match-head-commit "$INFRA_REVIEWED_SHA"` only after `dco`, `preview`, and `validate` pass. Any intervening push requires a new review. ## Ruleset update @@ -102,12 +102,13 @@ Immediately before mutation, also read back repository settings and stop unless ## Live acceptance test -Use the already-open PR #11 as the first queue entry after confirming it remains open, mergeable, has no unresolved review requirement, and has passing PR checks. Before enqueueing, a Maintainer must inspect `gh pr diff 11 --name-only` and `gh pr diff 11` and confirm the PR contains only the expected content-translation scope under `content/**`, with no workflow, script, test, package, configuration, Schema, or Maintainer-documentation changes. +Use the already-open PR #11 as the first queue entry after confirming it remains open, mergeable, has no unresolved review requirement, and has passing PR checks. Before reviewing, capture its `headRefOid` as `PR11_REVIEWED_SHA`. Inspect `gh pr diff 11 --name-only` and `gh pr diff 11` and confirm the PR contains only the expected content-translation scope under `content/**`, with no workflow, script, test, package, configuration, Schema, or documentation changes. Re-read the head and stop unless it still equals `PR11_REVIEWED_SHA`. -Add #11 through `gh pr merge 11 --squash`. Because the Ruleset requires Merge Queue, this command must enqueue the PR instead of directly merging it. Acceptance requires: +Record a UTC enqueue timestamp, then add #11 through `gh pr merge 11 --match-head-commit "$PR11_REVIEWED_SHA" --squash`. Because the Ruleset requires Merge Queue, this command must enqueue the PR instead of directly merging it. Acceptance requires: -- a `merge_group` workflow run is created; +- a `merge_group` workflow run is created after the recorded enqueue time and its `headBranch` matches `gh-readonly-queue/main/pr-11-...`; - `dco`, `preview`, and `validate` report for the merge-group run and pass; `preview` and `validate` operate on the merge-group SHA, while `dco` records the admission invariant; +- the accepted run ID, queue `headBranch`, and `headSha` are recorded so an unrelated merge-group run cannot satisfy acceptance; - #11 is automatically squash-merged by the queue; - `main` advances to the queued result; - the Ruleset readback remains unchanged after the merge. @@ -120,7 +121,7 @@ Only a Maintainer with write access performs the queue command. Auto-merge remai Before changing the Ruleset, write its complete API response and the exact update payload to a temporary local validation directory outside the repository. Do not include tokens or response headers. -If the Ruleset update is rejected, stop without retrying a mutated payload until the error and saved payload are compared. If merge-group checks do not start, remain pending, or fail because of workflow configuration, restore the original Ruleset with `gh api` so normal strict PR merging is available again. Do not bypass checks or merge #11 directly. +If the Ruleset update is rejected, stop without retrying a mutated payload until the error and saved payload are compared. If merge-group checks do not start, remain pending, or fail because of workflow configuration, first query PR #11's GraphQL `mergeQueueEntry`. If it is non-null, call `dequeuePullRequest`, then query again and require `mergeQueueEntry=null` and PR state `OPEN`. Only after that proof may the original Ruleset be restored with `gh api`. If dequeue fails or the second query does not prove those invariants, stop without changing the Ruleset. Do not bypass checks or merge #11 directly. The merged infrastructure workflow may remain on `main` after a Ruleset rollback because it is inert unless GitHub dispatches `merge_group`. @@ -131,5 +132,6 @@ The configuration is complete only when: 1. The infrastructure PR is merged with DCO and all required checks passing. 2. The active Ruleset contains the exact single-entry squash Merge Queue configuration and no unintended changes. 3. Repository Auto-merge remains disabled and the empty-bypass, manual Maintainer admission contract is documented and verified. -4. PR #11 is confirmed to contain only the expected content translation, receives all three successful contexts from a real merge-group run, and is automatically squash-merged. -5. The final `main` and Ruleset states are independently read back through GitHub CLI. +4. PR #11's reviewed head remains unchanged through its SHA-bound enqueue, and the accepted run is uniquely tied to PR #11 by enqueue time and `gh-readonly-queue/main/pr-11-...` head branch. +5. PR #11 is confirmed to contain only the expected content translation, receives all three successful contexts from that recorded merge-group run, and is automatically squash-merged. +6. The final `main` and Ruleset states are independently read back through GitHub CLI; any failed acceptance dequeues PR #11 before Ruleset rollback. diff --git a/tests/automation.test.mjs b/tests/automation.test.mjs index 5d48c44..c0a0c9a 100644 --- a/tests/automation.test.mjs +++ b/tests/automation.test.mjs @@ -153,8 +153,7 @@ test('npm test discovers only repository tests and never executes Demo source', const env = { ...process.env }; delete env.NODE_TEST_CONTEXT; - const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const result = await execFileAsync(npmExecutable, ['test'], { cwd: root, env }).catch((error) => error); + const result = await execFileAsync(process.execPath, [path.join(root, 'scripts', 'run-tests.mjs')], { cwd: root, env }).catch((error) => error); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; assert.match(output, /SAFE_FIXTURE_TEST/); assert.doesNotMatch(output, /DEMO_EXECUTED_SENTINEL/); From 7a36f4e114a973b07a6f8047c1f213b361293f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E9=99=88?= Date: Fri, 21 Aug 2026 16:20:05 +0800 Subject: [PATCH 9/9] docs: fix merge queue dequeue mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 安陈 --- docs/superpowers/plans/2026-08-21-merge-queue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-21-merge-queue.md b/docs/superpowers/plans/2026-08-21-merge-queue.md index bff25d1..afbac52 100644 --- a/docs/superpowers/plans/2026-08-21-merge-queue.md +++ b/docs/superpowers/plans/2026-08-21-merge-queue.md @@ -703,7 +703,7 @@ If `mergeQueueEntry` is non-null, call the official dequeue mutation exactly onc ```bash gh api graphql \ - -f query='mutation($id: ID!) { dequeuePullRequest(input: {pullRequestId: $id}) { clientMutationId } }' \ + -f query='mutation($id: ID!) { dequeuePullRequest(input: {id: $id}) { clientMutationId } }' \ -F id="$PR11_NODE_ID" ```