diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml new file mode 100644 index 0000000..88da0da --- /dev/null +++ b/.github/workflows/merge-queue.yml @@ -0,0 +1,73 @@ +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 + steps: + - 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 + 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/docs/automated-checks.md b/docs/automated-checks.md index dfd0267..d84b1be 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,27 @@ 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. 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 +``` + +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 2d60812..802f49f 100644 --- a/docs/maintainers/repository-settings.md +++ b/docs/maintainers/repository-settings.md @@ -13,17 +13,37 @@ 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. 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 +``` + +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. ## Fork pull-request Actions @@ -53,4 +73,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/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 new file mode 100644 index 0000000..afbac52 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-merge-queue.md @@ -0,0 +1,731 @@ +# 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 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. + +## 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 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 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. +- 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 `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`. +- 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, 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** + +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('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', () => {}); + `); + 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(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/); + assert.doesNotMatch(output, /NESTED_TEST_EXECUTED_SENTINEL/); + assert.equal(result.code ?? 0, 0, output); +}); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +node --test --test-name-pattern='npm test discovers only repository tests' tests/automation.test.mjs +``` + +Expected: FAIL because `scripts/run-tests.mjs` does not exist and the old package command does not satisfy the contract. + +- [ ] **Step 3: Add the cross-platform runner and update the package command** + +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: + +```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 +npm test +``` + +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 scripts/run-tests.mjs tests/automation.test.mjs +git commit -s -m "test: scope node test discovery" +``` + +Expected trailer: `Signed-off-by: 安陈 `. + +### Task 2: Add the dedicated merge-group workflow and security contract + +**Files:** +- Modify: `tests/automation.test.mjs` +- Create: `.github/workflows/merge-queue.yml` + +**Interfaces:** +- 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** + +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.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/); + 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); +}); +``` + +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** + +Run: + +```bash +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. + +- [ ] **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 + steps: + - 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 + 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|pull-request DCO|trusted automation' tests/automation.test.mjs +``` + +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** + +Run: + +```bash +git diff --check +npm run check +``` + +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** + +```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: `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. +- 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 +- 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 SHA-bound 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 +- 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: 安陈 +``` + +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** + +Resolve the infrastructure PR number and capture its immutable head before reviewing: + +```bash +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 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** + +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. 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** + +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 +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: 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`** + +```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 +gh api repos/QoderAI/cloud-agents-cookbook --jq '{allow_auto_merge,allow_squash_merge,allow_merge_commit,allow_rebase_merge}' +``` + +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** + +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_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. + +### 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` +- 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. +- 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 +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 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 +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. 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 10 --json databaseId,status,conclusion,headBranch,headSha,event,createdAt,url +``` + +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 +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`; `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** + +```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 +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, 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** + +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: {id: $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: 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, 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 new file mode 100644 index 0000000..848cf6b --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-merge-queue-design.md @@ -0,0 +1,137 @@ +# 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 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 + +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. + +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 + +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 existing pull-request workflow and `scripts/check-dco.mjs` remain unchanged. + +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 + +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 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 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 + +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 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` 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`. + +## 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. 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 + +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. + +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. 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`. + +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 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. + +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. + +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`. + +## 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. Repository Auto-merge remains disabled and the empty-bypass, manual Maintainer admission contract is documented and verified. +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/package.json b/package.json index 97d9ceb..54668a8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=20" }, "scripts": { - "test": "node --test", + "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 e7013cd..c0a0c9a 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 { mkdir, 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 { 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 ' }, @@ -32,7 +37,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'); @@ -45,14 +50,117 @@ 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.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/); + 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', () => {}); + `); + 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(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/); + assert.doesNotMatch(output, /NESTED_TEST_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/); @@ -80,7 +188,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); });