From b11802651b2b90d9e39e334b9f969b47ad583dc8 Mon Sep 17 00:00:00 2001 From: show <10173746+showxu@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:06:07 +0800 Subject: [PATCH 1/2] Publish catalog generations through pull requests The Pages workflow pushes a changed catalog to automation/plugin-catalog, opens or updates its pull request, enables squash auto-merge and dispatches Website CI on the branch. It deploys only after master is exactly the squash of that proposal onto the verified source, so master accepts changes only through checked pull requests. --- .github/workflows/pages.yml | 46 ++++++++- README.md | 6 +- docs/plugin-catalog.md | 46 +++++---- scripts/plugin_catalog_publication.py | 53 ++++++++--- scripts/test_plugin_catalog_publication.py | 106 ++++++++++++++------- 5 files changed, 182 insertions(+), 75 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1857fac..a96e047 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -19,10 +19,12 @@ jobs: build: if: github.repository_id == '1353589608' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 45 permissions: + actions: write contents: write pages: read + pull-requests: write steps: - name: Check out repository @@ -82,11 +84,51 @@ jobs: - name: Run accessibility and smoke tests run: npm test - - name: Persist verified catalog generation + - name: Prepare verified catalog generation + id: catalog env: CATALOG_SOURCE_COMMIT: ${{ steps.source.outputs.commit }} run: python3 scripts/plugin_catalog_publication.py --expected-head "$CATALOG_SOURCE_COMMIT" + - name: Merge catalog proposal + if: steps.catalog.outputs.proposal != '' + env: + BRANCH: automation/plugin-catalog + GH_TOKEN: ${{ github.token }} + PROPOSAL: ${{ steps.catalog.outputs.proposal }} + RUN_URL: + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title=$(git log -1 --format=%s "$PROPOSAL") + body="Generated from current official plugin releases by [the Pages workflow]($RUN_URL). The catalog successor, publisher policy and built site were verified before this proposal. Website CI runs on this branch, the pull request merges automatically once it passes, and the same run then deploys the merged generation." + number=$(gh pr list --head "$BRANCH" --base master --state open --json number --jq '.[0].number // empty') + if [[ -n "$number" ]]; then + gh pr edit "$number" --title "$title" --body "$body" + else + number=$(gh pr create --head "$BRANCH" --base master --title "$title" --body "$body" | sed 's#.*/##') + fi + if [[ "$(gh pr view "$number" --json autoMergeRequest --jq '.autoMergeRequest == null')" == true ]]; then + gh pr merge "$number" --auto --squash + fi + gh workflow run ci.yml --ref "$BRANCH" + for _ in $(seq 80); do + state=$(gh pr view "$number" --json state,headRefOid --jq '"\(.state) \(.headRefOid)"') + [[ "$state" == "MERGED $PROPOSAL" ]] && exit 0 + [[ "$state" == "OPEN $PROPOSAL" ]] || break + sleep 15 + done + echo "Catalog proposal #$number is not merged ($state); the deployed site is retained." >&2 + exit 1 + + - name: Verify merged catalog generation + if: steps.catalog.outputs.proposal != '' + env: + CATALOG_SOURCE_COMMIT: ${{ steps.source.outputs.commit }} + PROPOSAL: ${{ steps.catalog.outputs.proposal }} + run: + python3 scripts/plugin_catalog_publication.py --expected-head "$CATALOG_SOURCE_COMMIT" + --merged "$PROPOSAL" + - name: Upload Pages artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: diff --git a/README.md b/README.md index 4a91669..4900ba5 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ verifies release archives without running package code. `npm run catalog:check-p `npm run catalog:test` run offline; `npm run catalog:check` validates a generated snapshot. Automatic publication requires a complete verified snapshot committed to `master`; a missing seed fails Pages publication before upload and preserves the deployed site. Runs reconcile hourly, on -source pushes and on manual workflow dispatch, persist changed generations and deploy one complete -artifact. See [the plugin catalog contract](docs/plugin-catalog.md) for provenance, withdrawals, -resource bounds, atomic updates and installation trust. +source pushes and on manual workflow dispatch, merge changed generations through an auto-merged pull +request and deploy one complete artifact. See [the plugin catalog contract](docs/plugin-catalog.md) +for provenance, withdrawals, resource bounds, atomic updates and installation trust. ## Content boundaries diff --git a/docs/plugin-catalog.md b/docs/plugin-catalog.md index a24eb02..0bab59b 100644 --- a/docs/plugin-catalog.md +++ b/docs/plugin-catalog.md @@ -162,23 +162,30 @@ missed notifications and replaced pending runs are repaired by the next successf After generation, the workflow validates and tests the complete site. The publication helper checks the catalog successor, exact built index bytes and unchanged source, then commits only the new index -as a child of the checked-out commit. It uses a normal fast-forward push, never a force push. A -concurrent source update rejects publication; the next run starts from current `master`. Identical -catalog content creates no commit. Unrelated local or staged changes are rejected and preserved. - -Only then does Pages upload and deploy the complete artifact. Generation, validation or commit -failure prevents upload and preserves the deployed artifact. If deployment fails after the verified -index commit, the next run uses that committed generation and can deploy it without incrementing -generation. A local generated file alone does not deploy anything. Publisher tests also run in -read-only website CI, without contacting release sources. - -The central build job uses its short-lived GitHub job token with `contents: write` to persist the -index; the deployment job has Pages and identity-token permissions. Plugin notification senders -should invoke `pages.yml` on `master` using `workflow_dispatch`, with Actions write access scoped to -this receiving repository. They do not need website Contents write access. Notification payloads -provide no metadata or policy overrides. Existing GitHub authentication is an operator input; this -repository does not create credentials. If notifications are unavailable, scheduled and manual -reconciliation still use the same complete verification path. +as a child of the checked-out commit. It pushes that commit to `automation/plugin-catalog`, +replacing any earlier unmerged proposal, and `master` changes only through the resulting pull +request. The workflow opens or updates the pull request, enables squash auto-merge and dispatches +Website CI on the branch, because pull requests opened with the job token do not trigger workflows. +It then waits up to 20 minutes for the merge and requires `master` to be exactly the squash of the +proposal onto the checked-out commit. A concurrent source update rejects publication; the next run +starts from current `master`. Identical catalog content creates no proposal. Unrelated local or +staged changes are rejected and preserved. + +Only then does Pages upload and deploy the complete artifact. Generation, validation, proposal or +merge failure prevents upload and preserves the deployed artifact. If the proposal merges after the +run stops waiting, or deployment fails after the merge, the next run uses that committed generation +and can deploy it without incrementing generation. A local generated file alone does not deploy +anything. Publisher tests also run in read-only website CI, without contacting release sources. + +The repository allows auto-merge and lets GitHub Actions create pull requests. The central build job +uses its short-lived GitHub job token with contents, pull-request and Actions write access to push +the proposal branch, open and auto-merge its pull request and dispatch Website CI; the deployment +job has Pages and identity-token permissions. Plugin notification senders should invoke `pages.yml` +on `master` using `workflow_dispatch`, with Actions write access scoped to this receiving +repository. They do not need website Contents write access. Notification payloads provide no +metadata or policy overrides. Existing GitHub authentication is an operator input; this repository +does not create credentials. If notifications are unavailable, scheduled and manual reconciliation +still use the same complete verification path. ## Plugin release notifications @@ -242,5 +249,6 @@ are generation failure bounds, not permission to truncate the catalog or omit ol Tests cover provenance rejection, immutable identities, explicit withdrawal, failed writes, concurrent publishers, duplicate events, invalid/oversized/truncated input, bounded retries, manifest/archive agreement, safe manifest reads, credential-free redirects, committed generation -continuity, concurrent source pushes and deployment retry. Network discovery in the host remains a -separate consumer; installation must retain its exact GitHub revalidation. +continuity, proposal replacement, merge verification, concurrent source pushes and deployment retry. +Network discovery in the host remains a separate consumer; installation must retain its exact GitHub +revalidation. diff --git a/scripts/plugin_catalog_publication.py b/scripts/plugin_catalog_publication.py index 850b76c..c5e4016 100644 --- a/scripts/plugin_catalog_publication.py +++ b/scripts/plugin_catalog_publication.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Commit one verified catalog before Pages upload, rejecting concurrent source updates.""" +"""Propose one verified catalog for merge and confirm the merged source before Pages upload.""" import argparse import os @@ -16,6 +16,7 @@ INDEX = "public/plugins/index.json" POLICY = "scripts/plugin-catalog-policy.json" BRANCH = "refs/heads/master" +PROPOSAL = "refs/heads/automation/plugin-catalog" REMOTE = "https://github.com/computer-mcp/computer-mcp.github.io" @@ -27,10 +28,10 @@ def git(root, *arguments, data=None, environment=None): return result.stdout -def remote_head(root): - fields = git(root, "ls-remote", "--exit-code", "origin", BRANCH).decode().split() - catalog.require(len(fields) == 2 and fields[1] == BRANCH - and re.fullmatch(r"[0-9a-f]{40}", fields[0]), "Invalid remote master identity") +def remote_head(root, ref=BRANCH): + fields = git(root, "ls-remote", "--exit-code", "origin", ref).decode().split() + catalog.require(len(fields) == 2 and fields[1] == ref + and re.fullmatch(r"[0-9a-f]{40}", fields[0]), f"Invalid remote {ref} identity") return fields[0] @@ -55,7 +56,7 @@ def verify_seed(root, expected_head): return previous -def publish_commit(root, expected_head): +def propose_commit(root, expected_head): catalog.require(re.fullmatch(r"[0-9a-f]{40}", expected_head) is not None, "Invalid source commit") catalog.require(git(root, "rev-parse", "HEAD").decode().strip() == expected_head, "Local source changed during publication") @@ -76,7 +77,7 @@ def publish_commit(root, expected_head): if current == previous: return expected_head # Construct the tree from the verified bytes and parent, without touching the caller's index - # or committing unrelated working-tree files. A concurrent remote child rejects this push. + # or committing unrelated working-tree files. Each run replaces any earlier unmerged proposal. blob = git(root, "hash-object", "-w", "--stdin", data=data).decode().strip() with tempfile.TemporaryDirectory(prefix="computer-mcp-catalog-index-") as directory: environment = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")} @@ -88,18 +89,34 @@ def publish_commit(root, expected_head): "GIT_AUTHOR_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com", "GIT_COMMITTER_EMAIL": "41898282+github-actions[bot]@users.noreply.github.com"} commit = git(root, "-c", "commit.gpgsign=false", "commit-tree", tree, "-p", expected_head, - "-m", f"chore: publish plugin catalog generation {current['generation']}", + "-m", f"Publish plugin catalog generation {current['generation']}", environment=identity).decode().strip() - git(root, "push", "--porcelain", "origin", commit + ":" + BRANCH) - catalog.require(remote_head(root) == commit, "Catalog commit changed before Pages upload") + git(root, "push", "--porcelain", "--force", "origin", commit + ":" + PROPOSAL) + catalog.require(remote_head(root, PROPOSAL) == commit, "Catalog proposal changed before merge") return commit +def verify_merged(root, expected_head, proposal): + for value in (expected_head, proposal): + catalog.require(re.fullmatch(r"[0-9a-f]{40}", value) is not None, "Invalid source commit") + git(root, "fetch", "--no-tags", "origin", BRANCH) + merged = git(root, "rev-parse", "FETCH_HEAD").decode().strip() + catalog.require(merged != expected_head, "Catalog proposal is not merged") + catalog.require(git(root, "rev-list", "--parents", "-n", "1", merged).decode().split() + == [merged, expected_head], + "Master changed before the catalog proposal merged; reconcile from current master") + catalog.require(git(root, "rev-parse", merged + "^{tree}") == git(root, "rev-parse", proposal + "^{tree}"), + "Merged source differs from the verified catalog proposal") + return merged + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--expected-head", required=True) parser.add_argument("--check-seed", action="store_true", help="Verify committed seed before reconciliation, without publication") + parser.add_argument("--merged", metavar="PROPOSAL", + help="Verify that master is the squash merge of this catalog proposal") args = parser.parse_args() try: catalog.require(os.environ.get("GITHUB_ACTIONS") == "true" @@ -112,11 +129,17 @@ def main(): snapshot = verify_seed(ROOT, args.expected_head) print(f"Verified committed catalog seed: generation {snapshot['generation']}") return - commit = publish_commit(ROOT, args.expected_head) - if output := os.environ.get("GITHUB_OUTPUT"): - with open(output, "a", encoding="utf-8") as file: - file.write(f"commit={commit}\n") - print(f"Verified catalog source for Pages: {commit}") + if args.merged: + commit = verify_merged(ROOT, args.expected_head, args.merged) + output, message = f"commit={commit}", f"Verified merged catalog source for Pages: {commit}" + elif (commit := propose_commit(ROOT, args.expected_head)) == args.expected_head: + output, message = f"commit={commit}", f"Verified catalog source for Pages: {commit}" + else: + output, message = f"proposal={commit}", f"Proposed catalog generation for merge: {commit}" + if path := os.environ.get("GITHUB_OUTPUT"): + with open(path, "a", encoding="utf-8") as file: + file.write(output + "\n") + print(message) except (catalog.CatalogError, OSError, ValueError, subprocess.SubprocessError) as error: message = str(error) if isinstance(error, catalog.CatalogError) else type(error).__name__ print(f"Catalog publication failed: {message}", file=sys.stderr) diff --git a/scripts/test_plugin_catalog_publication.py b/scripts/test_plugin_catalog_publication.py index a7e8173..d37033d 100644 --- a/scripts/test_plugin_catalog_publication.py +++ b/scripts/test_plugin_catalog_publication.py @@ -4,7 +4,6 @@ import subprocess import tempfile import unittest -from unittest.mock import patch import plugin_catalog as catalog import plugin_catalog_publication as publication @@ -54,30 +53,78 @@ def write_candidate(self): artifact.parent.mkdir(parents=True, exist_ok=True) artifact.write_bytes(data) + def clone(self): + directory = Path(tempfile.mkdtemp(dir=self.temporary.name)) / "clone" + self.command(directory.parent, "clone", "--branch", "master", str(self.remote), str(directory)) + return directory + def advance_remote(self): - other = Path(self.temporary.name) / "other" - self.command(other.parent, "clone", "--branch", "master", str(self.remote), str(other)) + other = self.clone() (other / "concurrent.txt").write_text("Concurrent source change\n") self.command(other, "add", "concurrent.txt") self.commit(other) self.command(other, "push", "origin", "master") return self.command(other, "rev-parse", "HEAD").strip() - def test_commits_exact_snapshot_without_changing_worktree_head_or_index(self): + def squash_merge(self): + other = self.clone() + self.command(other, "fetch", "origin", publication.PROPOSAL) + self.command(other, "merge", "--squash", "FETCH_HEAD") + self.commit(other) + self.command(other, "push", "origin", "master") + return self.command(other, "rev-parse", "HEAD").strip() + + def proposals(self): + return self.command(self.remote, "for-each-ref", "--format=%(objectname)", publication.PROPOSAL).split() + + def test_proposes_exact_snapshot_without_changing_master_worktree_head_or_index(self): index = (self.root / ".git/index").read_bytes() - commit = publication.publish_commit(self.root, self.head) + commit = publication.propose_commit(self.root, self.head) self.assertNotEqual(commit, self.head) - self.assertEqual(publication.remote_head(self.root), commit) + self.assertEqual(publication.remote_head(self.root, publication.PROPOSAL), commit) + self.assertEqual(publication.remote_head(self.root), self.head) self.assertEqual(self.command(self.root, "rev-parse", "HEAD").strip(), self.head) self.assertEqual((self.root / ".git/index").read_bytes(), index) + self.assertEqual(self.command(self.root, "rev-parse", commit + "^"), self.head + "\n") self.assertEqual(self.command(self.root, "diff", "--name-only", self.head, commit).strip(), publication.INDEX) self.assertEqual(json.loads(self.command(self.root, "show", commit + ":" + publication.INDEX)), self.current) def test_duplicate_reconciliation_keeps_commit_and_generation(self): self.current = self.previous self.write_candidate() - self.assertEqual(publication.publish_commit(self.root, self.head), self.head) + self.assertEqual(publication.propose_commit(self.root, self.head), self.head) self.assertEqual(publication.remote_head(self.root), self.head) + self.assertEqual(self.proposals(), []) + + def test_new_proposal_replaces_unmerged_proposal(self): + stale = self.advance_remote() + self.command(self.remote, "update-ref", publication.PROPOSAL, stale) + self.command(self.remote, "update-ref", publication.BRANCH, self.head) + commit = publication.propose_commit(self.root, self.head) + self.assertEqual(self.proposals(), [commit]) + + def test_squash_merged_proposal_is_the_deployable_source(self): + commit = publication.propose_commit(self.root, self.head) + merged = self.squash_merge() + self.assertEqual(publication.verify_merged(self.root, self.head, commit), merged) + + def test_unmerged_proposal_is_not_deployable(self): + commit = publication.propose_commit(self.root, self.head) + with self.assertRaisesRegex(catalog.CatalogError, "not merged"): + publication.verify_merged(self.root, self.head, commit) + + def test_master_update_before_merge_prevents_deployment(self): + commit = publication.propose_commit(self.root, self.head) + self.advance_remote() + self.squash_merge() + with self.assertRaisesRegex(catalog.CatalogError, "Master changed"): + publication.verify_merged(self.root, self.head, commit) + + def test_merged_source_must_match_proposal(self): + commit = publication.propose_commit(self.root, self.head) + self.advance_remote() + with self.assertRaisesRegex(catalog.CatalogError, "differs"): + publication.verify_merged(self.root, self.head, commit) def test_missing_committed_seed_cannot_reset_generation(self): (self.root / publication.INDEX).write_bytes(catalog.canonical(self.previous)) @@ -89,7 +136,7 @@ def test_missing_committed_seed_cannot_reset_generation(self): with self.assertRaises(catalog.CatalogError): publication.committed_snapshot(self.root, head) with self.assertRaises(catalog.CatalogError): - publication.publish_commit(self.root, head) + publication.propose_commit(self.root, head) self.assertEqual(publication.remote_head(self.root), head) def test_missing_seed_stops_generation_before_network_requests(self): @@ -121,20 +168,21 @@ def test_new_withdrawal_policy_reconciles_from_committed_seed(self): self.assertTrue(changed) self.assertTrue(current["releases"][0]["withdrawn"]) (self.root / "dist/plugins/index.json").write_bytes(catalog.canonical(current)) - commit = publication.publish_commit(self.root, head) + commit = publication.propose_commit(self.root, head) self.assertEqual(json.loads(self.command(self.remote, "show", commit + ":" + publication.INDEX)), current) def test_artifact_mismatch_prevents_remote_commit(self): (self.root / "dist/plugins/index.json").write_bytes(catalog.canonical(self.previous)) with self.assertRaisesRegex(catalog.CatalogError, "artifact"): - publication.publish_commit(self.root, self.head) + publication.propose_commit(self.root, self.head) self.assertEqual(publication.remote_head(self.root), self.head) + self.assertEqual(self.proposals(), []) def test_unrelated_work_is_preserved_and_not_committed(self): unrelated = self.root / "notes.txt" unrelated.write_text("Unrelated local work") with self.assertRaisesRegex(catalog.CatalogError, "Only the generated"): - publication.publish_commit(self.root, self.head) + publication.propose_commit(self.root, self.head) self.assertEqual(unrelated.read_text(), "Unrelated local work") self.assertEqual(publication.remote_head(self.root), self.head) @@ -143,7 +191,7 @@ def test_unrelated_staged_changes_are_preserved(self): self.command(self.root, "add", "index.html") index = (self.root / ".git/index").read_bytes() with self.assertRaises(catalog.CatalogError): - publication.publish_commit(self.root, self.head) + publication.propose_commit(self.root, self.head) self.assertEqual((self.root / ".git/index").read_bytes(), index) self.assertEqual(publication.remote_head(self.root), self.head) @@ -153,7 +201,7 @@ def test_verified_history_cannot_be_rewritten(self): self.current["revision"] = hashlib.sha256(catalog.canonical(content)).hexdigest() self.write_candidate() with self.assertRaisesRegex(catalog.CatalogError, "identity"): - publication.publish_commit(self.root, self.head) + publication.propose_commit(self.root, self.head) self.assertEqual(publication.remote_head(self.root), self.head) def test_generation_cannot_reset_or_skip(self): @@ -162,39 +210,25 @@ def test_generation_cannot_reset_or_skip(self): self.current["generation"] = generation self.write_candidate() with self.assertRaisesRegex(catalog.CatalogError, "generation"): - publication.publish_commit(self.root, self.head) + publication.propose_commit(self.root, self.head) self.assertEqual(publication.remote_head(self.root), self.head) def test_remote_update_before_publication_is_not_overwritten(self): other = self.advance_remote() with self.assertRaisesRegex(catalog.CatalogError, "Remote master changed"): - publication.publish_commit(self.root, self.head) + publication.propose_commit(self.root, self.head) self.assertEqual(publication.remote_head(self.root), other) + self.assertEqual(self.proposals(), []) - def test_remote_update_after_comparison_rejects_non_fast_forward_push(self): - real_git = publication.git - other = [] - - def racing_git(root, *arguments, **kwargs): - if arguments[0] == "push": - other.append(self.advance_remote()) - return real_git(root, *arguments, **kwargs) - - with patch.object(publication, "git", racing_git): - with self.assertRaisesRegex(catalog.CatalogError, "push failed"): - publication.publish_commit(self.root, self.head) - self.assertEqual(publication.remote_head(self.root), other[0]) - self.assertEqual(json.loads(self.command(self.remote, "show", "master:" + publication.INDEX)), self.previous) - - def test_deployment_failure_can_retry_same_committed_snapshot(self): - commit = publication.publish_commit(self.root, self.head) - retry = Path(self.temporary.name) / "retry" - self.command(retry.parent, "clone", "--branch", "master", str(self.remote), str(retry)) + def test_deployment_failure_can_retry_same_merged_snapshot(self): + publication.propose_commit(self.root, self.head) + merged = self.squash_merge() + retry = self.clone() artifact = retry / "dist/plugins/index.json" artifact.parent.mkdir(parents=True) artifact.write_bytes((retry / publication.INDEX).read_bytes()) - self.assertEqual(publication.publish_commit(retry, commit), commit) - self.assertEqual(publication.remote_head(retry), commit) + self.assertEqual(publication.propose_commit(retry, merged), merged) + self.assertEqual(publication.remote_head(retry), merged) if __name__ == "__main__": From af775b548f9e14e776c76c0710692f807dda0185 Mon Sep 17 00:00:00 2001 From: show <10173746+showxu@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:16:51 +0800 Subject: [PATCH 2/2] Give the squash-merge fixture an explicit identity Git requires a committer identity before a real merge, and CI runners provide none. --- scripts/test_plugin_catalog_publication.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/test_plugin_catalog_publication.py b/scripts/test_plugin_catalog_publication.py index d37033d..7f35071 100644 --- a/scripts/test_plugin_catalog_publication.py +++ b/scripts/test_plugin_catalog_publication.py @@ -41,9 +41,12 @@ def command(self, root, *arguments): return subprocess.check_output(["git", "-C", str(root), *arguments], stderr=subprocess.PIPE, timeout=10).decode() + def fixture_command(self, root, *arguments): + return self.command(root, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", *arguments) + def commit(self, root): - self.command(root, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", - "-c", "commit.gpgsign=false", "commit", "-m", "Fixture") + self.fixture_command(root, "commit", "-m", "Fixture") def write_candidate(self): data = catalog.canonical(self.current) @@ -69,7 +72,7 @@ def advance_remote(self): def squash_merge(self): other = self.clone() self.command(other, "fetch", "origin", publication.PROPOSAL) - self.command(other, "merge", "--squash", "FETCH_HEAD") + self.fixture_command(other, "merge", "--squash", "FETCH_HEAD") self.commit(other) self.command(other, "push", "origin", "master") return self.command(other, "rev-parse", "HEAD").strip()