From 66f42070a49973a7763546492ce3219dec079341 Mon Sep 17 00:00:00 2001 From: Thomas Berdy Date: Wed, 7 Oct 2026 12:31:20 +0200 Subject: [PATCH] ci: stop re-checking the published files here The skills, install.sh and the other published files only change here through ci-bot's sync pull request, copied as of a release from the CLI's source, where the same checks already ran against the binary that release ships: the skill references, shellcheck, install.sh end to end and the spell check. Running them again here checked the same files against the same binary, on every pull request. CI keeps only `synced-files`, which stops a pull request other than the sync from editing the published files, behind `ci-gate`. scripts/check-skills.py goes with the `skills` job (the CLI's source checks the skills with its own test), and _typos.toml with `typos`: the files left that this repository owns are its CI and merge configuration, AGENTS.md and LICENSE. Co-Authored-By: Claude Opus 5.5 Change-Id: I1bf5814b100a69d6ef0696fe174b8362d845629d --- .github/workflows/ci.yaml | 128 +------------------------------------- AGENTS.md | 16 ++--- _typos.toml | 6 -- scripts/check-skills.py | 105 ------------------------------- 4 files changed, 6 insertions(+), 249 deletions(-) delete mode 100644 _typos.toml delete mode 100755 scripts/check-skills.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2463ddd5..6c43b955 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,134 +8,13 @@ on: - devs/** # Cancel superseded runs on the same ref — a force-push or a new -# commit makes the in-flight matrix obsolete, so don't keep runners +# commit makes the in-flight run obsolete, so don't keep runners # busy finishing it. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - # Spellcheck source, comments, and docs. Allow-list of project - # nouns lives in _typos.toml; reproduce locally with `typos`. - typos: - name: typos - timeout-minutes: 5 - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 - - # The agent skills against the binary people actually get: the latest - # release's. The CLI's source isn't in this repository any more, so there is - # no build to check them against. See scripts/check-skills.py. - skills: - name: skills reference real commands - timeout-minutes: 5 - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Download the latest release's Linux binary - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh release download --repo "${GITHUB_REPOSITORY}" \ - --pattern 'mergify-*-x86_64-unknown-linux-gnu.tar.gz' --dir release - tar -C release -xzf release/mergify-*-x86_64-unknown-linux-gnu.tar.gz - release/mergify --version - - name: Check the skills - run: python3 scripts/check-skills.py release/mergify - - # End to end, the one-liner people run: install.sh against the latest - # release's real binaries and SHA256SUMS, served from a local fixture so the - # script's own download, checksum and install paths all run. The release is - # the only place binaries come from now, so this is also what proves the - # current release installs. Plus the two ways a tampered SHA256SUMS must fail - # closed. Windows runs the same POSIX script under git-bash. - install-script-release: - name: install.sh against the latest release (${{ matrix.os }}) - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - os: [ubuntu-26.04, macos-15, windows-2025] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install shellcheck (Linux only) - if: matrix.os == 'ubuntu-26.04' - run: sudo apt-get install -y --no-install-recommends shellcheck - - name: Lint install.sh - if: matrix.os == 'ubuntu-26.04' - run: shellcheck install.sh - # Every binary of the release, so install.sh picks its own target the - # way it does against GitHub; latest-release.json stands in for the - # releases/latest redirect it resolves the version from. - - name: Fetch the latest release as the fixture - id: release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="$(gh release view --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName)" - mkdir -p fixture/release - gh release download "${tag}" --repo "${GITHUB_REPOSITORY}" --dir fixture/release \ - --pattern 'mergify-*' --pattern SHA256SUMS - printf '{"tag_name":"%s"}\n' "${tag}" > fixture/release/latest-release.json - ls -la fixture/release - echo "tag=${tag}" >> "${GITHUB_OUTPUT}" - - name: Serve the fixture - shell: bash - run: | - cd fixture/release - # `python3` on the Unix runners, `python` on the Windows one. - py=python3; command -v "$py" > /dev/null 2>&1 || py=python - "$py" -m http.server 8765 --bind 127.0.0.1 > /tmp/server.log 2>&1 & - echo $! > /tmp/server.pid - for _ in $(seq 1 50); do - curl -fsS http://127.0.0.1:8765/SHA256SUMS > /dev/null && break - sleep 0.1 - done - - name: install.sh installs the latest release - shell: bash - env: - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - MERGIFY_BASE_URL=http://127.0.0.1:8765 MERGIFY_INSTALL_DIR=/tmp/mergify-install ./install.sh - case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) bin=mergify.exe ;; *) bin=mergify ;; esac - out="$("/tmp/mergify-install/${bin}" --version)" - echo "${out}" - [ "${out}" = "mergify ${TAG}" ] || { echo "expected 'mergify ${TAG}', got '${out}'"; exit 1; } - # sha256sum -c only warns on a malformed line, so install.sh checks the - # line shape itself: both cases must refuse to install. - - name: install.sh rejects a tampered SHA256SUMS - shell: bash - run: | - set -euo pipefail - cd fixture/release - cp SHA256SUMS SHA256SUMS.orig - assets=(mergify-*) - echo "Case 1: wrong but well-formed hashes" - for asset in "${assets[@]}"; do printf '%064d %s\n' 0 "${asset}"; done > SHA256SUMS - if MERGIFY_BASE_URL=http://127.0.0.1:8765 MERGIFY_INSTALL_DIR=/tmp/mergify-bad1 ../../install.sh > /tmp/case1.log 2>&1; then - cat /tmp/case1.log; echo "install.sh accepted wrong checksums"; exit 1 - fi - grep -q 'checksum verification failed' /tmp/case1.log - echo "Case 2: malformed lines" - for asset in "${assets[@]}"; do echo "bogus ${asset}"; done > SHA256SUMS - if MERGIFY_BASE_URL=http://127.0.0.1:8765 MERGIFY_INSTALL_DIR=/tmp/mergify-bad2 ../../install.sh > /tmp/case2.log 2>&1; then - cat /tmp/case2.log; echo "install.sh accepted malformed checksums"; exit 1 - fi - grep -q 'malformed checksum entry' /tmp/case2.log - mv SHA256SUMS.orig SHA256SUMS - - name: Stop the fixture server - if: always() - shell: bash - run: | - if [ -f /tmp/server.pid ]; then kill "$(cat /tmp/server.pid)" || true; fi - # The published files (see AGENTS.md) come from the CLI's private source, # copied by ci-bot's public-releases-sync as of each release: an edit made # here would be silently overwritten by the next sync. Only that sync's own @@ -155,7 +34,7 @@ jobs: run: | set -euo pipefail changed="$(git diff --name-only "${BASE_SHA}...HEAD" -- \ - README.md install.sh skills .claude-plugin .codex-plugin assets scripts/check-skills.py)" + README.md install.sh skills .claude-plugin .codex-plugin assets)" if [ -n "${changed}" ]; then for file in ${changed}; do echo "::error file=${file}::${file} is published from the CLI's source and synced here at each release; edit it there (see AGENTS.md)" @@ -167,9 +46,6 @@ jobs: ci-gate: if: ${{ !cancelled() }} needs: - - typos - - skills - - install-script-release - synced-files runs-on: ubuntu-26.04 steps: diff --git a/AGENTS.md b/AGENTS.md index 1a079076..4fb97c90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,6 @@ differ: | `install.sh` | the installer, served from `main` to `curl … \| sh` | | `skills/` | the agent skills (`npx skills add Mergifyio/mergify-cli`) | | `.claude-plugin/`, `.codex-plugin/`, `assets/` | the plugin manifests and icon | -| `scripts/check-skills.py` | the skill check CI runs | A change made to them here is overwritten by the next sync, so CI's `synced-files` check fails any pull request that edits them, unless it comes @@ -27,7 +26,7 @@ that Mergify carries over to the CLI's source. ## Owned here - `.github/workflows/ci.yaml`, `.mergify.yml`, `renovate.json`, - `_typos.toml`, this `AGENTS.md` and `LICENSE`. + this `AGENTS.md` and `LICENSE`. - The releases, written by ci-bot's mirror, never by hand. A release carrying the CLI binaries is Latest, which install.sh, `mergify self-update`, the Homebrew tap and the docs' CLI reference all follow. @@ -35,15 +34,8 @@ that Mergify carries over to the CLI's source. ## Checks -CI (`.github/workflows/ci.yaml`, gated by `ci-gate`) runs against the latest -release, since nothing is built here: +The published files are checked in the CLI's source before each release, +against the binary that release ships, so they aren't checked again here. CI +(`.github/workflows/ci.yaml`, gated by `ci-gate`) runs: -- `skills`: `scripts/check-skills.py` checks every skill's front matter and - that every `mergify ` it mentions exists in the latest - release's binary. -- `install-script-release`: shellcheck, then install.sh end to end against - the latest release's real files served from a local fixture - (`MERGIFY_BASE_URL`), on Linux, macOS and Windows, including the tampered - `SHA256SUMS` cases. - `synced-files`: the published files only change through the sync. -- `typos`, with the allow-list in `_typos.toml`. diff --git a/_typos.toml b/_typos.toml deleted file mode 100644 index b37e9ad1..00000000 --- a/_typos.toml +++ /dev/null @@ -1,6 +0,0 @@ -# typos spell-check allow-list. Reproduce CI locally with `typos`. - -[default.extend-words] -# The CLI's spelling (its exit-code table in the README says "unparseable"), -# kept as is rather than diverging from the binary's own messages. -unparseable = "unparseable" diff --git a/scripts/check-skills.py b/scripts/check-skills.py deleted file mode 100755 index 0e8914a3..00000000 --- a/scripts/check-skills.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""Check the agent skills under skills/ against a mergify binary. - -Usage: check-skills.py - -The skills tell an agent which `mergify` commands to run, so a skill that names -a command the binary doesn't have sends the agent into an error. The CLI's -source lives in a private repository now; this runs against a released binary -(CI uses the latest release's), the one people actually get. - -For every skills//SKILL.md: -- YAML front matter between `---` lines, with `name: ` matching the - directory and a `description`, which is what agent skill loaders read; -- every `mergify ` it mentions, for a group the binary - has, is a subcommand the binary lists in `--list-native-commands`. -The merge-queue skill must also keep its required sections. -""" - -import re -import subprocess -import sys -from pathlib import Path - -SKILLS = Path(__file__).resolve().parent.parent / "skills" - -REQUIRED_SECTIONS = { - "mergify-merge-queue": [ - "## Commands", - "## Checking Queue Status", - "## Inspecting a PR", - "## Queue States", - "## Troubleshooting", - ], -} - -FRONT_MATTER = re.compile(r"\A---\n(.+?)\n---\n", re.DOTALL) -REFERENCE = re.compile(r"\bmergify ([a-z][\w-]*) ([a-z][\w-]*)") - - -def native_commands(binary: str) -> dict[str, set[str]]: - output = subprocess.run( - [binary, "--list-native-commands"], - check=True, - capture_output=True, - text=True, - ).stdout - commands: dict[str, set[str]] = {} - for line in output.splitlines(): - group, _, sub = line.partition(" ") - if sub: - commands.setdefault(group, set()).add(sub.strip()) - return commands - - -def check_skill(path: Path, commands: dict[str, set[str]]) -> list[str]: - name = path.parent.name - content = path.read_text(encoding="utf-8") - problems = [] - - match = FRONT_MATTER.match(content) - if not match: - return [f"{name}: no YAML front matter between --- lines"] - keys = {} - for line in match.group(1).splitlines(): - key, sep, value = line.partition(":") - if sep and not line.startswith((" ", "\t")): - keys[key.strip()] = value.strip() - if keys.get("name") != name: - problems.append(f"{name}: front matter name is {keys.get('name')!r}, expected {name!r}") - if not keys.get("description"): - problems.append(f"{name}: front matter has no description") - - for section in REQUIRED_SECTIONS.get(name, []): - if section not in content: - problems.append(f"{name}: missing required section {section!r}") - - for group, sub in sorted(set(REFERENCE.findall(content))): - if group in commands and sub not in commands[group]: - problems.append( - f"{name}: mentions `mergify {group} {sub}`, which the binary doesn't have" - f" (it has: {', '.join(sorted(commands[group]))})" - ) - return problems - - -def main() -> int: - if len(sys.argv) != 2: - print(__doc__.splitlines()[2], file=sys.stderr) - return 2 - commands = native_commands(sys.argv[1]) - skills = sorted(SKILLS.glob("*/SKILL.md")) - if not skills: - print(f"::error::no skills found under {SKILLS}") - return 1 - problems = [p for skill in skills for p in check_skill(skill, commands)] - for problem in problems: - print(f"::error::{problem}") - if problems: - return 1 - print(f"ok: {len(skills)} skills checked against {sum(map(len, commands.values()))} commands") - return 0 - - -if __name__ == "__main__": - sys.exit(main())