diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 8f45ccf9b8..99090c68c2 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -256,12 +256,14 @@ This is the one PR in the repository that targets `main`. ⚠️ **It is merged with "Create a merge commit", never squashed or rebased.** A squash writes one new commit that `v2/main` does not contain, so the two branches stop sharing history and the next milestone merge conflicts with -itself. +itself. `main`'s ruleset allows only merge commits, so the other two buttons +are not offered. ⚠️ **Do not click "Update branch" on the merge PR.** GitHub always offers it here, because `main` always holds commits the merge branch lacks (its own earlier merge commits), so it does not mean the PR is out of date. Clicking it -merges `main` into the merge branch: the PR gains a commit of its own, `main` +merges `main` into the merge branch: the PR gains a commit of its own (which +fails the required **Source is v2/main** check, `main-source.yml`), `main` receives two merge commits, and the branch head is no longer the commit the ledger names. If it was clicked, put the branch back on its head from before the click: the update merge's **first parent**, which is the commit the ledger @@ -281,6 +283,55 @@ reaches `main` through `v2/main`, so there is nothing on `main` to bring back except the merge commits themselves. `v2/main` ahead of `main` means a release is in flight; that is the normal state. +### A release that cannot be a pure merge + +Sometimes `v2/main` holds work that must not ship yet, and the release has to +come from a branch of its own: a release branch with cherry-picks (1.0.0 +shipped that way, from `v2/release/1.0.0` in #5090), or a hotfix. The required +**Source is v2/main** check fails that PR, by design, and nobody can bypass +it, admins included. The only way through is a deliberate act by a repo +admin: disable the `main: milestone merges only` ruleset for that one merge, +and turn it straight back on. + +The disable, the merge and the re-enable run as one command, in a subshell +whose exit trap re-enables the ruleset however it ends: the merge failing, an +error, or Ctrl-C. Approve the PR first; the other rulesets on `main` still +require that. + +```sh +PR= +RS=$(gh api repos/modelcontextprotocol/servers/rulesets \ + --jq '.[] | select(.name == "main: milestone merges only") | .id') +[ -n "$RS" ] || echo "ruleset not found by name; list them and look" >&2 +[ -n "$RS" ] && ( + set -e + restore() { + gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" \ + -f enforcement=active --jq '"ruleset: \(.enforcement)"' + } + trap restore EXIT + trap 'exit 130' INT TERM HUP # exit, so the EXIT trap restores + gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" \ + -f enforcement=disabled >/dev/null + # --merge: with the ruleset off, squash and rebase are allowed again. + gh pr merge "$PR" --repo modelcontextprotocol/servers --merge +) +gh api "repos/modelcontextprotocol/servers/rulesets/$RS" --jq .enforcement # must print "active" +``` + +A trap cannot run if the machine itself dies mid-command, so the last line +is not optional: if it prints anything but `active`, set it back by hand +(Settings → Rules → Rulesets) before doing anything else. + +- **Only a human admin does this**, and only for a PR whose diff has been + reviewed as the release. Never disable the ruleset outside the command + above. +- **Record it in the release ledger**: the PR, why it could not be a pure + merge, and the times the ruleset was disabled and re-enabled. +- **Then merge the release branch back into `v2/main`** through a PR of its + own (#5118 did this for 1.0.0), so its commits are on `v2/main` and the + next milestone merge is a pure one again. + ## 4. Verify the release candidate, and write the ledger Work from a **dedicated worktree** with its own install, so nothing stale from diff --git a/.github/workflows/main-source.yml b/.github/workflows/main-source.yml new file mode 100644 index 0000000000..7a2ee9e5e6 --- /dev/null +++ b/.github/workflows/main-source.yml @@ -0,0 +1,59 @@ +# A pull request into `main` brings nothing that is not already on `v2/main` +# (#5119). `main` only ever receives a milestone's merge PR, a pure merge of +# `v2/main`; GitHub rulesets cannot restrict a PR's source branch, so this +# check, required by the `main` ruleset, is what does. The rule and why it is +# on the head commit rather than the branch name are in +# `scripts/verify-main-source.mjs`. +# +# On `pull_request_target`, unlike dco.yml: it runs `main`'s copy of this +# workflow and of the script, so a PR cannot edit the guard into passing. That +# is safe here because nothing from the PR runs: the checkout is `main`, and +# the PR's head is only fetched as git objects for `git merge-base` to read. +# Never add a step that checks out, installs or runs the PR's code. +# `edited` is there so retargeting a PR onto `main` runs the check. +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + branches: [main] + +# Read-only, and handed no secret, so this job holds no credential and stays +# outside the SHA-pin rule (`verify:action-pins`). +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + main-source: + timeout-minutes: 5 + name: Source is v2/main + runs-on: ubuntu-latest + steps: + # The base branch (`main`), which `pull_request_target` checks out by + # default; never the PR's ref. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # The PR's head and `v2/main`, as objects only. A fork PR's head is + # reachable here through `refs/pull//head`. Full history, so + # `git merge-base` can walk both. + - name: Fetch the PR head and v2/main + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git fetch --no-tags --unshallow origin \ + "+refs/heads/v2/main:refs/remotes/origin/v2/main" \ + "+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head" + + - uses: actions/setup-node@v7 + with: + node-version: 22 + + # Node built-ins and git only, so no `npm ci`. + - name: Check the head is already on v2/main + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/verify-main-source.mjs --head "$HEAD_SHA" diff --git a/AGENTS.md b/AGENTS.md index 7b4abbd327..9f90d72e78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ servers/ ├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release), │ version-packages.yml (the changesets PR), prepare-python-release.yml (the CalVer PR), │ claude.yml (@claude mentions), everything-mcp-diff.yml (everything's interface diff on PRs), +│ main-source.yml (a PR into main must bring nothing that is not on v2/main), │ and the scheduled sweeps that file issues: dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml ├── .github/ISSUE_TEMPLATE/ Bug and feature issue forms; config.yml routes security and new servers away ├── .github/pull_request_template.md The "issues, not PRs" banner that turns outside PRs away @@ -153,6 +154,10 @@ diagnosing a red stage is the `pre-push-gate` skill. publish artifact into an empty directory and boots it, which needs the network. `release.yml` runs it before anything publishes, and the `release` skill runs it by hand for the ledger. +- **`verify-main-source` is not a gate stage either.** It applies only to a + pull request into `main` (see the merge PR exception under + [Contributing](#contributing)), and every local push targets a v2 branch. + `main-source.yml` runs it. - **`local:*` scripts never run in a workflow, and every CI job declares `timeout-minutes`.** The script tests enforce both (`scripts/lib/workflow-gate.mjs`). A check added to CI is added to @@ -353,8 +358,14 @@ holds for a maintainer's own one-line fix as much as for a feature. default branch a closing keyword would close that issue at merge, before anything is published. The release issue is closed by hand once the Release is published. In place of the checklist it links the **release ledger**. It - is merged with a merge commit, never squashed. The flow is the `release` - skill. + is merged with a merge commit, never squashed. Both are enforced on `main`: + its ruleset allows only merge commits, and requires the **Source is + v2/main** check (`main-source.yml`), which fails a PR whose head carries + any commit not already on `v2/main`. That ruleset has no bypass actors; a + release that cannot be a pure merge (a release branch, a hotfix) goes + through only by a repo admin disabling it for that one merge and + re-enabling it at once, which is never automated. The flow is the + `release` skill. - **Exception: the two version PRs that automation opens.** Each changes version metadata only, so neither answers the PR checklist below, and each is tied to its milestone's **release issue** rather than to an issue of its diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 7c197a5b05..aa952d2f24 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -13,7 +13,7 @@ out; everything else points here. | --- | --- | --- | --- | | Inner loop | `npm run validate -w src/`, `npm run validate:py -- ` | Your machine | While iterating on one server | | Pre-push gate | `npm run local:gate` | Your machine, under a lease | Before every push | -| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only | Before merge | +| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml`; `main-source.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only; `main-source.yml` on pull requests into `main` only, and not mirrored by a gate stage, since no local push targets `main` | Before merge | **`npm run local:gate` runs every check CI runs.** CI splits the same checks into parallel jobs on a fresh install; the gate runs them in sequence on your @@ -22,13 +22,18 @@ your machine. It is the strongest predictor of a green CI there is here, though not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 22 and each Python server's pinned interpreter. -**The one exception is the Windows legs** (#1149): `test-windows` in both +**There are two exceptions.** The first is the Windows legs (#1149): `test-windows` in both workflows runs each server's test suite (`npm test`, `pytest`) on a `windows-latest` runner. The gate runs the same suites, but only on your machine's OS, so a Windows-only failure (a drive letter, a UNC path, a missing `O_NOFOLLOW`, a locked file) shows up first in CI. When a PR touches platform-specific code, wait for those legs, not just the gate. +The second is **Source is v2/main** (`main-source.yml`), which runs only on a +pull request into `main` and checks that its head is already on `v2/main`. No +local push targets `main`, so the gate has nothing to check there and no stage +mirrors it. + The inner-loop commands are subsets. They are for speed while working, and are never a substitute for the gate. diff --git a/scripts/verify-main-source.mjs b/scripts/verify-main-source.mjs new file mode 100644 index 0000000000..280f16db1c --- /dev/null +++ b/scripts/verify-main-source.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Guard: a pull request into `main` brings nothing that is not already on +// `v2/main` (#5119). +// +// `main` is the release line. The one PR that may target it is a milestone's +// merge PR, a pure merge of `v2/main` (AGENTS.md, "Contributing"). GitHub +// rulesets can require a PR and its approvals, but cannot say where a PR comes +// from, so before this guard a PR from any branch could merge into `main`. +// +// The rule is on the COMMIT, not the branch name: the PR's head must be an +// ancestor of the source (`origin/v2/main`), or the source itself. The +// `release` skill opens the merge PR from `v2/chore/-release-`, +// a branch pushed from `origin/v2/main`, so a check on the head's name would +// refuse the one PR the rule exists to allow. An ancestor rather than only an +// equal commit, because `v2/main` keeps moving while the merge PR is open, and +// the PR is still a pure merge of what it was opened with. +// +// CI runs it from `main-source.yml` on `pull_request_target`, which runs +// `main`'s copy of the workflow and of this script, so a PR cannot edit the +// guard into passing. The PR's head is only fetched as git objects; none of +// its code runs. It is not a `local:gate` stage: every local push targets a +// v2 branch, where the rule does not apply. + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** The branch every commit on `main` must already be on. */ +export const DEFAULT_SOURCE = "origin/v2/main"; + +/** How many of the offending commits the report lists. */ +export const MAX_LISTED = 20; + +/** + * Pure: `--head ` (required) and `--source `. + * + * @param {string[]} argv + */ +export function parseArgs(argv) { + const opts = { source: DEFAULT_SOURCE, head: "" }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag !== "--source" && flag !== "--head") + throw new Error(`unknown argument: ${flag}`); + const value = argv[++i]; + if (!value) throw new Error(`${flag} needs a revision`); + opts[flag.slice(2)] = value; + } + if (!opts.head) throw new Error("--head is required"); + return opts; +} + +/** + * Pure: the report for a head that carries commits the source does not. + * + * @param {{ source: string, head: string, commits: string[] }} args + * `commits` is `git log --oneline ..`, one per entry + */ +export function formatReport({ source, head, commits }) { + const n = commits.length; + const listed = commits.slice(0, MAX_LISTED).map((c) => ` ${c}`); + if (n > MAX_LISTED) listed.push(` … and ${n - MAX_LISTED} more`); + return [ + `verify:main-source: ${head} carries ${n} commit${n === 1 ? "" : "s"} that ${source} does not:`, + ...listed, + "", + "A pull request into `main` must be a pure merge of `v2/main`: its head", + "is a commit already on `v2/main`. Land the change on `v2/main` through", + "its own PR, and it reaches `main` with the next milestone merge (the", + "`release` skill).", + ].join("\n"); +} + +function git(args, cwd) { + const r = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (r.error) throw r.error; + return r; +} + +/** + * Check that `head` is `source` or an ancestor of it, in the repository at + * `cwd`. + * + * @returns {{ code: number, output: string }} + */ +export function verifyMainSource({ source, head, cwd }) { + for (const rev of [source, head]) { + if ( + git(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], cwd).status + ) + return { + code: 1, + output: + `verify:main-source: cannot resolve ${rev}.` + + (rev === DEFAULT_SOURCE + ? " Fetch it (`git fetch origin v2/main`), or name another source with --source." + : ""), + }; + } + const anc = git(["merge-base", "--is-ancestor", head, source], cwd); + if (anc.status === 0) + return { + code: 0, + output: `verify:main-source: ${head} is on ${source}; nothing else comes with it.`, + }; + if (anc.status !== 1) + return { + code: 1, + output: `verify:main-source: git merge-base failed:\n${anc.stderr}`, + }; + const log = git( + ["log", "--oneline", "--no-decorate", `${source}..${head}`], + cwd, + ); + if (log.status) + return { + code: 1, + output: `verify:main-source: git log failed:\n${log.stderr}`, + }; + const commits = log.stdout.split("\n").filter((l) => l.trim() !== ""); + return { code: 1, output: formatReport({ source, head, commits }) }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href +) { + let opts; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error(`verify:main-source: ${e.message}`); + process.exit(2); + } + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ); + const { code, output } = verifyMainSource({ ...opts, cwd: repoRoot }); + (code === 0 ? console.log : console.error)(output); + process.exit(code); +} diff --git a/scripts/verify-main-source.test.mjs b/scripts/verify-main-source.test.mjs new file mode 100644 index 0000000000..2f457a7c4c --- /dev/null +++ b/scripts/verify-main-source.test.mjs @@ -0,0 +1,219 @@ +// Tests for `verify-main-source.mjs` (#5119): the arguments and the report, +// the guard end to end against throwaway repositories shaped like the +// release flow, a merge PR from a branch pushed off `v2/main` passes, as does +// one `v2/main` has since moved past; a branch with a commit of its own, or a +// merge of `v2/main` into something else, fails. Last, the properties of +// `main-source.yml` its safety rests on. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { devNull, tmpdir } from "node:os"; +import path from "node:path"; +import { parse } from "yaml"; +import { + DEFAULT_SOURCE, + MAX_LISTED, + formatReport, + parseArgs, + verifyMainSource, +} from "./verify-main-source.mjs"; + +test("parseArgs: --head is required, --source defaults to origin/v2/main", () => { + assert.deepEqual(parseArgs(["--head", "abc"]), { + source: DEFAULT_SOURCE, + head: "abc", + }); + assert.deepEqual(parseArgs(["--source", "v2/main", "--head", "abc"]), { + source: "v2/main", + head: "abc", + }); + assert.throws(() => parseArgs([]), /--head is required/); + assert.throws(() => parseArgs(["--head"]), /--head needs a revision/); + assert.throws(() => parseArgs(["abc"]), /unknown argument/); +}); + +test("formatReport: names the commits, caps the list, and says where the change belongs", () => { + const commits = Array.from( + { length: MAX_LISTED + 3 }, + (_, i) => `${String(i).padStart(7, "0")} commit ${i}`, + ); + const out = formatReport({ source: "origin/v2/main", head: "abc", commits }); + assert.match( + out, + new RegExp( + `abc carries ${MAX_LISTED + 3} commits that origin/v2/main does not`, + ), + ); + assert.match(out, /0000000 commit 0/); + assert.doesNotMatch(out, new RegExp(`commit ${MAX_LISTED}\\b`)); + assert.match(out, /… and 3 more/); + assert.match(out, /pure merge of `v2\/main`/); + assert.match( + formatReport({ source: "s", head: "h", commits: ["1234567 one"] }), + /carries 1 commit that s does not/, + ); +}); + +// End to end, against a real repository: `main` and `v2` stand in for `main` +// and `origin/v2/main`, sharing a root. + +function repo() { + const dir = mkdtempSync(path.join(tmpdir(), "verify-main-source-")); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Ada Lovelace", + GIT_AUTHOR_EMAIL: "ada@example.com", + GIT_COMMITTER_NAME: "Ada Lovelace", + GIT_COMMITTER_EMAIL: "ada@example.com", + }; + const git = (...args) => { + const r = spawnSync("git", args, { cwd: dir, env, encoding: "utf8" }); + assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`); + return r.stdout.trim(); + }; + git("init", "-q", "-b", "main"); + git("commit", "-q", "--allow-empty", "-m", "root"); + git("switch", "-q", "-c", "v2"); + git("commit", "-q", "--allow-empty", "-m", "v2 work"); + return { + dir, + git, + check: (head) => verifyMainSource({ source: "v2", head, cwd: dir }), + done: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +test("verifyMainSource: a release branch pushed from v2 passes", (t) => { + const r = repo(); + t.after(r.done); + r.git("branch", "release", "v2"); + const { code, output } = r.check("release"); + assert.equal(code, 0, output); + assert.match(output, /release is on v2/); +}); + +test("verifyMainSource: a head v2 has since moved past still passes", (t) => { + const r = repo(); + t.after(r.done); + r.git("branch", "release", "v2"); + r.git("commit", "-q", "--allow-empty", "-m", "later v2 work"); + assert.equal(r.check("release").code, 0); +}); + +test("verifyMainSource: a branch with a commit of its own fails, naming it", (t) => { + const r = repo(); + t.after(r.done); + r.git("switch", "-q", "-c", "topic", "v2"); + r.git("commit", "-q", "--allow-empty", "-m", "sneaky fix"); + const sha = r.git("rev-parse", "--short", "HEAD"); + const { code, output } = r.check("topic"); + assert.equal(code, 1); + assert.match(output, /topic carries 1 commit that v2 does not/); + assert.ok(output.includes(`${sha} sneaky fix`), output); + assert.doesNotMatch(output, /v2 work/); +}); + +test("verifyMainSource: a branch cut from main fails", (t) => { + const r = repo(); + t.after(r.done); + r.git("switch", "-q", "-c", "hotfix", "main"); + r.git("commit", "-q", "--allow-empty", "-m", "hotfix"); + const { code, output } = r.check("hotfix"); + assert.equal(code, 1); + assert.match(output, /hotfix carries 1 commit/); +}); + +test("verifyMainSource: v2 merged into another branch fails, merge commit and all", (t) => { + const r = repo(); + t.after(r.done); + r.git("switch", "-q", "-c", "mixed", "main"); + r.git("commit", "-q", "--allow-empty", "-m", "side work"); + r.git("merge", "-q", "--no-ff", "--no-edit", "v2"); + const { code, output } = r.check("mixed"); + assert.equal(code, 1); + assert.match(output, /mixed carries 2 commits/); + assert.match(output, /side work/); +}); + +test("verifyMainSource: an unresolvable source or head fails and says so", (t) => { + const r = repo(); + t.after(r.done); + const noSource = verifyMainSource({ + source: DEFAULT_SOURCE, + head: "HEAD", + cwd: r.dir, + }); + assert.equal(noSource.code, 1); + assert.match(noSource.output, /cannot resolve origin\/v2\/main\. Fetch it/); + const noHead = r.check("no-such-branch"); + assert.equal(noHead.code, 1); + assert.match(noHead.output, /cannot resolve no-such-branch\.$/); +}); + +test("verifyMainSource: run as a script, a bad argument exits 2", () => { + const r = spawnSync( + process.execPath, + [path.join(import.meta.dirname, "verify-main-source.mjs")], + { encoding: "utf8" }, + ); + assert.equal(r.status, 2); + assert.match(r.stderr, /--head is required/); +}); + +// The workflow's safety rests on properties of its YAML; pin them, so an edit +// that would run the PR's code, or stop wiring the guard, fails here. +const workflow = parse( + readFileSync( + path.join(import.meta.dirname, "..", ".github/workflows/main-source.yml"), + "utf8", + ), +); +const job = workflow.jobs["main-source"]; + +test("main-source.yml: runs main's copy, on PRs into main only, read-only", () => { + assert.deepEqual(Object.keys(workflow.on), ["pull_request_target"]); + assert.deepEqual(workflow.on.pull_request_target.branches, ["main"]); + assert.ok(workflow.on.pull_request_target.types.includes("edited")); + assert.deepEqual(workflow.permissions, { contents: "read" }); + assert.equal(job.permissions, undefined); + assert.deepEqual(Object.keys(workflow.jobs), ["main-source"]); + assert.equal(job.name, "Source is v2/main"); + assert.ok(job["timeout-minutes"]); +}); + +test("main-source.yml: checks out main, never the PR, and fetches the PR as objects only", () => { + const checkout = job.steps.filter((s) => + /actions\/checkout/.test(s.uses ?? ""), + ); + assert.equal(checkout.length, 1); + assert.equal(checkout[0].with?.ref, undefined); + assert.equal(checkout[0].with?.repository, undefined); + assert.equal(checkout[0].with["persist-credentials"], false); + const runs = job.steps.map((s) => s.run ?? "").join("\n"); + const refspec = '"+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head"'; + assert.ok(runs.includes(refspec), "the PR head is fetched"); + // Past that fetch, nothing names the PR's ref, or switches to, installs or + // executes a tree. + const rest = runs.replace(refspec, ""); + assert.doesNotMatch(rest, /pr\/head|refs\/pull/); + assert.doesNotMatch(rest, /git (checkout|switch|reset|worktree)|npm |npx /); + // No step reads a secret; expressions reach the shell only through env. + for (const step of job.steps) { + assert.doesNotMatch(JSON.stringify(step), /secrets\./); + assert.doesNotMatch(step.run ?? "", /\$\{\{/); + } +}); + +test("main-source.yml: hands the event's head SHA to the guard", () => { + const step = job.steps.find((s) => + /verify-main-source\.mjs/.test(s.run ?? ""), + ); + assert.ok(step, "a step runs the guard"); + assert.equal(step.env.HEAD_SHA, "${{ github.event.pull_request.head.sha }}"); + assert.match(step.run, /--head "\$HEAD_SHA"/); + assert.doesNotMatch(step.run, /--source/); +});