diff --git a/.github/workflows/brew-smoke-intel.yml b/.github/workflows/brew-smoke-intel.yml deleted file mode 100644 index 340e889..0000000 --- a/.github/workflows/brew-smoke-intel.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: brew-smoke (Intel) - -# Best-effort validation of `brew install lambdatest/rook/rook` on real -# Intel x86_64 macOS. Kept OUT of brew-smoke.yml's auto-triggered matrix, -# and dispatched manually (or scheduled) instead. -# -# Why a separate workflow: GitHub's Intel macOS runner queue routinely -# starves (30+ min typical, sometimes hours). build-bottles.yml's publish -# job auto-dispatches brew-smoke.yml right after a release; putting Intel -# validation in that same matrix would hold the post-publish signal "in -# progress" indefinitely waiting on a runner that may not show up for -# hours. `continue-on-error: true` doesn't help — it only masks a job that -# ran and failed, not one still queued waiting for allocation. -# -# rook does not publish a bottle for darwin-x64 at all (build-bottles.yml's -# matrix only builds macos-14/arm64 and ubuntu-latest) — every Intel Mac -# install is a source build, exercising Formula/rook.rb's `def install` -# platform-detection branch (Hardware::CPU.intel? on macOS) that neither of -# brew-smoke.yml's two jobs ever reaches. - -on: - workflow_dispatch: - inputs: - expected-version: - description: "Version to validate (e.g. 0.1.0). REQUIRED — this workflow refuses to run without an explicit pin." - required: true - type: string - -permissions: {} - -concurrency: - group: brew-smoke-intel-${{ github.ref }} - cancel-in-progress: false - -jobs: - darwin-x64: - runs-on: macos-15-intel - # Caps the job once a runner allocates. Doesn't help with queue - # starvation itself (GHA queue time before allocation is unbounded) — - # limits damage if a runner does pick this up and then hangs. - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - with: - ref: main - - # Same drift check as brew-smoke.yml's guard job, and the same - # env:-routing reason: EXPECTED_VERSION is untrusted (workflow_dispatch - # input) and must never be spliced into a run: body. - - name: Verify expected version matches Formula/rook.rb on main - env: - EXPECTED_VERSION: ${{ inputs.expected-version }} - run: | - FORMULA_VERSION=$(grep '^ version' Formula/rook.rb | awk -F'"' '{print $2}') - if [ "$EXPECTED_VERSION" != "$FORMULA_VERSION" ]; then - echo "::error::version mismatch: dispatched with expected-version=${EXPECTED_VERSION}, but Formula/rook.rb on main is ${FORMULA_VERSION}. Re-dispatch with the current version once main settles." - exit 1 - fi - - - run: brew tap lambdatest/rook https://github.com/LambdaTest/rook.git - - - name: Install rook - # set -o pipefail: see the identical comment in brew-smoke.yml — - # without it this line's exit status is tee's, never brew - # install's, under GHA's default `bash -e {0}` (no pipefail). - run: | - set -o pipefail - brew install lambdatest/rook/rook 2>&1 | tee install.log - - - name: Assert a source build was taken, not a bottle - run: | - if grep -q "Pouring rook-" install.log; then - echo "::warning::a bottle was poured on Intel macOS — unexpected, since build-bottles.yml does not build a darwin-x64 bottle. Investigate whether one now exists before treating this as fine." - else - echo "source-build path taken, as expected on Intel" - fi - - - name: Check installed version - env: - EXPECTED_VERSION: ${{ inputs.expected-version }} - run: | - OUT="$(rook --version)" - case "$OUT" in - *"$EXPECTED_VERSION"*) ;; - *) echo "::error::got '$OUT'"; exit 1 ;; - esac - - # Same rationale as brew-smoke.yml: reuses Formula/rook.rb's own - # `test do` block rather than re-deriving the bundled-binary path - # logic here a third time. - - name: brew test (bundled runtime binary + version pin) - run: brew test lambdatest/rook/rook - - - name: Upload install log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: install-log-darwin-x64 - path: install.log - if-no-files-found: ignore diff --git a/.github/workflows/brew-smoke.yml b/.github/workflows/brew-smoke.yml deleted file mode 100644 index bb685b9..0000000 --- a/.github/workflows/brew-smoke.yml +++ /dev/null @@ -1,205 +0,0 @@ -name: brew-smoke - -on: - workflow_dispatch: - inputs: - expected-version: - description: "Version to validate (e.g. 0.1.0). REQUIRED — this workflow refuses to run without an explicit pin." - required: true - type: string - -# Serializes concurrent dispatches so two overlapping releases can't produce -# interleaved, confusing runs against a shared main. -concurrency: - group: brew-smoke-${{ github.ref }} - cancel-in-progress: false - -# Neither job touches the GitHub API — they tap over https and run brew. An -# empty permissions block keeps the job token from inheriting whatever the -# repo/org default happens to be on a public repo. -permissions: {} - -jobs: - guard: - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - version: ${{ steps.check.outputs.version }} - steps: - - uses: actions/checkout@v4 - with: - ref: main - - # EXPECTED_VERSION comes in through env: rather than being spliced - # into the shell body — same discipline as every other step in this - # pipeline that handles an attacker-shaped value. - # - # Why this exists: build-bottles.yml's publish job dispatches this - # workflow right after pushing the bottle-block commit, carrying the - # exact version it just built. If a second release dispatch landed in - # between (or a human re-ran update-formula.yml), main could have - # moved past that version by the time this job actually starts. - # Failing here — before spending ~10 minutes installing on two - # platforms — turns a confusing "wrong version installed" failure - # deep in the matrix into an immediate, clear one. - - name: Verify expected version matches Formula/rook.rb on main - id: check - env: - EXPECTED_VERSION: ${{ inputs.expected-version }} - run: | - FORMULA_VERSION=$(grep '^ version' Formula/rook.rb | awk -F'"' '{print $2}') - if [ "$EXPECTED_VERSION" != "$FORMULA_VERSION" ]; then - echo "::error::version mismatch: dispatched with expected-version=${EXPECTED_VERSION}, but Formula/rook.rb on main is ${FORMULA_VERSION}." - echo "This is often a benign race: another release advanced main between the dispatch that queued this run and this job actually starting." - echo "Re-dispatch brew-smoke with expected-version=${FORMULA_VERSION} once main settles." - exit 1 - fi - echo "version=${EXPECTED_VERSION}" >> "$GITHUB_OUTPUT" - - macos-arm: - needs: guard - runs-on: macos-14 - timeout-minutes: 10 - steps: - # No setup-homebrew here: GitHub's macOS runner images ship Homebrew - # preinstalled. The Linux job below does need it. - - run: brew tap lambdatest/rook https://github.com/LambdaTest/rook.git - - # node must stay :build-only. def install now writes its own - # #!/bin/sh launcher that execs the bundled node binary directly - # (see Formula/rook.rb), instead of relying on npm's published - # `env node`-shebang trampoline plus Homebrew's shebang-rewrite — - # so nothing in the installed tree needs a *required* node - # dependency to resolve at runtime anymore. `brew deps` (without - # --include-build) only lists required/recommended deps, so this - # fails if node is ever unscoped from :build again. The structural - # check that the launcher itself doesn't reference node is in the - # formula's own `test do` block, run later in this job via `brew - # test` — this step only covers the dependency declaration. - - name: Assert node is not a resolved runtime dependency of the bottle - run: | - DEPS="$(brew deps lambdatest/rook/rook)" - if echo "$DEPS" | grep -qx node; then - echo "::error::lambdatest/rook/rook resolves 'node' as a runtime dependency — Formula/rook.rb's 'depends_on \"node\"' should be '=> :build' now that def install writes its own launcher. See the comment on that line." - echo "$DEPS" - exit 1 - fi - - - name: Install rook - # set -o pipefail: GHA's default shell for a step with no explicit - # shell: is `bash -e {0}` — pipefail is NOT on by default. Without - # it, this line's exit status is tee's, never brew install's, so a - # failure after the bottle pours (a caveat-rendering error, a link - # failure) would go undetected by every check below: the bottle - # marker is already in install.log, `rook --version` still works, - # and the job goes green despite brew install having exited - # nonzero. - run: | - set -o pipefail - brew install lambdatest/rook/rook 2>&1 | tee install.log - - # Bottled on this platform (build-bottles.yml's matrix includes - # macos-14) — a source build here means the bottle either failed to - # publish or Homebrew's platform-tag matching regressed. - - name: Assert a bottle was poured, not built from source - run: | - if ! grep -q "Pouring rook-" install.log; then - echo "::error::expected a bottle pour on macos-14 (arm64) but got a source build. Bottle publish or platform-tag matching may have regressed." - exit 1 - fi - - # EXPECTED_VERSION comes in through env: for the same reason as the - # guard job above: a spliced value would be live shell syntax inside - # the `case` pattern below — `x"*) ;; *) ;; esac #` closes the - # pattern list early and runs . As an env var it is only data. - - name: Check installed version - env: - EXPECTED_VERSION: ${{ needs.guard.outputs.version }} - run: | - OUT="$(rook --version)" - case "$OUT" in - *"$EXPECTED_VERSION"*) ;; - *) echo "::error::got '$OUT'"; exit 1 ;; - esac - - # Runs Formula/rook.rb's own `test do` block: bundled node binary - # exists, is executable (a plain --version pass doesn't catch a - # missed install-time chmod — rook's launcher only needs the binary - # to be present to attempt a spawn, not to succeed), and the bundled - # binary's own --version matches the exact nodeRuntimeVersion pin. - # Calling brew test here instead of re-deriving that path logic in - # bash keeps the platform-package-name mapping in def install as the - # only copy, rather than adding a second one here that could drift - # from it silently. - - name: brew test (bundled runtime binary + version pin) - run: brew test lambdatest/rook/rook - - - name: Upload install log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: install-log-macos-arm - path: install.log - if-no-files-found: ignore - - linux-x64: - needs: guard - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - # Homebrew is NOT preinstalled on the ubuntu-latest runner image — - # without this the job fails at `brew: command not found`. Pinned to - # the same commit as build-bottles.yml; keep the two in step. - - uses: Homebrew/actions/setup-homebrew@b35a29a0f83ee8b8ca07b219fc8db3d7bb88ab49 # 2026.08.10.2 - - run: brew tap lambdatest/rook https://github.com/LambdaTest/rook.git - - # See the identical step in macos-arm above. - - name: Assert node is not a resolved runtime dependency of the bottle - run: | - DEPS="$(brew deps lambdatest/rook/rook)" - if echo "$DEPS" | grep -qx node; then - echo "::error::lambdatest/rook/rook resolves 'node' as a runtime dependency — Formula/rook.rb's 'depends_on \"node\"' should be '=> :build' now that def install writes its own launcher. See the comment on that line." - echo "$DEPS" - exit 1 - fi - - - name: Install rook - # set -o pipefail: GHA's default shell for a step with no explicit - # shell: is `bash -e {0}` — pipefail is NOT on by default. Without - # it, this line's exit status is tee's, never brew install's, so a - # failure after the bottle pours (a caveat-rendering error, a link - # failure) would go undetected by every check below: the bottle - # marker is already in install.log, `rook --version` still works, - # and the job goes green despite brew install having exited - # nonzero. - run: | - set -o pipefail - brew install lambdatest/rook/rook 2>&1 | tee install.log - - - name: Assert a bottle was poured, not built from source - run: | - if ! grep -q "Pouring rook-" install.log; then - echo "::error::expected a bottle pour on ubuntu-latest (x86_64_linux) but got a source build. Bottle publish or platform-tag matching may have regressed." - exit 1 - fi - - - name: Check installed version - env: - EXPECTED_VERSION: ${{ needs.guard.outputs.version }} - run: | - OUT="$(rook --version)" - case "$OUT" in - *"$EXPECTED_VERSION"*) ;; - *) echo "::error::got '$OUT'"; exit 1 ;; - esac - - - name: brew test (bundled runtime binary + version pin) - run: brew test lambdatest/rook/rook - - - name: Upload install log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: install-log-linux-x64 - path: install.log - if-no-files-found: ignore diff --git a/.github/workflows/build-bottles.yml b/.github/workflows/build-bottles.yml deleted file mode 100644 index 760642a..0000000 --- a/.github/workflows/build-bottles.yml +++ /dev/null @@ -1,330 +0,0 @@ -name: Build bottles - -on: - workflow_dispatch: - inputs: - version: - description: "Formula version to (re)build bottles for. Leave blank to use whatever's in Formula/rook.rb on main." - required: false - type: string - -permissions: - contents: write - actions: write - -concurrency: - group: build-bottles-${{ github.ref }} - cancel-in-progress: false - -jobs: - guard: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.read.outputs.version }} - steps: - - uses: actions/checkout@v4 - with: - ref: main - - # INPUT_VERSION is routed through env: rather than spliced into the - # shell body. Actions expands ${{ }} before bash parses the script, so - # a spliced value's quotes, $(...) and backticks would run as shell - # syntax — and they would run BEFORE the drift comparison below, which - # therefore protects nothing against a hostile input. - - name: Resolve version - id: read - env: - INPUT_VERSION: ${{ inputs.version }} - run: | - FORMULA_VERSION=$(grep '^ version' Formula/rook.rb | awk -F'"' '{print $2}') - if [ -n "$INPUT_VERSION" ]; then - VERSION="$INPUT_VERSION" - else - VERSION="$FORMULA_VERSION" - fi - - # Validate whichever source it came from, and before either - # comparison below or GITHUB_OUTPUT: this value goes on to name a - # release tag, glob artifacts, generate Ruby, author a commit and - # dispatch another workflow. Validating only the inputs.version - # branch would leave the formula-derived one — a hand edit to - # Formula/rook.rb on main — driving all of that unchecked. Running - # it after resolution also means a malformed input says what is - # wrong with it rather than only "!= what is on main". - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then - echo "::error::version '${VERSION}' is not a valid semver-ish string" - exit 1 - fi - - # Only reachable from the input branch — the else branch above - # assigns FORMULA_VERSION, which makes this trivially equal. - if [ "$VERSION" != "$FORMULA_VERSION" ]; then - echo "::error::inputs.version=${VERSION} but Formula/rook.rb on main is ${FORMULA_VERSION}." - exit 1 - fi - - # The prerelease suffix is part of the version: matching only - # `rook-` reduces a root_url of rook-0.2.0-beta.1 to - # rook-0.2.0, which then fails the comparison against the correct - # current version and reports a stale bottle block that isn't - # stale. `grep -Fxv` compares the whole line literally, so a - # version containing regex metacharacters can't match loosely - # either. - STALE_ROOT=$(grep -E '^[[:space:]]*root_url[[:space:]]+"' Formula/rook.rb \ - | grep -oE 'rook-[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*' \ - | grep -Fxv "rook-${VERSION}" | head -1 || true) - if [ -n "$STALE_ROOT" ]; then - echo "::error::Formula has a stale bottle block referencing ${STALE_ROOT} but version is ${VERSION}." - exit 1 - fi - - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - build: - needs: guard - name: Build (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: - - macos-14 - - ubuntu-latest - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - ref: main - - - uses: Homebrew/actions/setup-homebrew@b35a29a0f83ee8b8ca07b219fc8db3d7bb88ab49 # 2026.08.10.2 - - - name: Tap self into Homebrew - run: | - # Homebrew resolves the short name "lambdatest/rook" to the local - # directory Taps/lambdatest/homebrew-rook regardless of the actual - # remote repo's name — the "homebrew-" prefix is mandatory on disk, - # not just a remote-naming convention (confirmed directly against - # a real failed run: `brew install --build-bottle lambdatest/rook/ - # rook` fell through to auto-tap and its own clone attempt targeted - # exactly ".../Taps/lambdatest/homebrew-rook", not ".../rook"). - # Symlinking to the unprefixed path here left it invisible to that - # resolution, so `brew install` never found this self-tap: it fell - # through to a real network clone of the default remote URL, - # https://github.com/lambdatest/homebrew-rook — a repo that - # doesn't exist, since this tap's actual source, per D3, is - # LambdaTest/rook itself, only ever reached by explicit URL - # (brew-smoke.yml's `brew tap lambdatest/rook https://github.com/ - # LambdaTest/rook.git`). That auto-tap attempt then failed on - # `terminal prompts disabled`, breaking both bottle-build jobs. - TAP_DIR="$(brew --repository)/Library/Taps/lambdatest/homebrew-rook" - rm -rf "$TAP_DIR" - mkdir -p "$(dirname "$TAP_DIR")" - ln -snf "$GITHUB_WORKSPACE" "$TAP_DIR" - brew tap | grep lambdatest/rook - - - name: Pre-install node dependency - env: - HOMEBREW_NO_AUTO_UPDATE: "1" - run: brew install node - - - name: Build bottle - env: - HOMEBREW_NO_AUTO_UPDATE: "1" - HOMEBREW_NO_SANDBOX_LINUX: "1" - VERSION: ${{ needs.guard.outputs.version }} - run: | - ROOT_URL="https://github.com/LambdaTest/rook/releases/download/rook-${VERSION}" - - brew install --build-bottle lambdatest/rook/rook - - if [ ! -d "$(brew --cellar rook)/$VERSION" ]; then - echo "::error::Expected rook ${VERSION} in the Cellar, but it is not there." - exit 1 - fi - - brew bottle \ - --no-rebuild \ - --json \ - --root-url="${ROOT_URL}" \ - lambdatest/rook/rook - - for f in rook--*.bottle.*; do - [ -e "$f" ] || continue - mv "$f" "${f/--/-}" - done - - - name: Verify bottle integrity - run: | - set -e - for bottle in rook-*.bottle.tar.gz; do - [ -e "$bottle" ] || continue - if ! gzip -t "$bottle" 2>&1; then - echo "::error::$bottle failed gzip integrity check"; exit 1 - fi - if ! tar -tzf "$bottle" >/dev/null 2>&1; then - echo "::error::$bottle: gzip OK but tar listing failed"; exit 1 - fi - - case "$bottle" in - *.x86_64_linux.bottle.*) NODE_PKG="@testmuai/rook-node-linux-x64" ;; - *.arm64_*.bottle.*) NODE_PKG="@testmuai/rook-node-darwin-arm64" ;; - *.x86_64_*.bottle.*) NODE_PKG="@testmuai/rook-node-darwin-x64" ;; - *) echo "::error::$bottle: unrecognized bottle label"; exit 1 ;; - esac - RUNNER_SUFFIX="node_modules/${NODE_PKG}/bin/node" - - TMP=$(mktemp -d) - tar -xzf "$bottle" -C "$TMP" - RUNNER=$(find "$TMP" -path "*/${RUNNER_SUFFIX}" -type f | head -1) - if [ -z "$RUNNER" ]; then - echo "::error::$bottle is missing ${RUNNER_SUFFIX}"; rm -rf "$TMP"; exit 1 - fi - BYTES=$(wc -c < "$RUNNER" | tr -d ' ') - if [ "${BYTES:-0}" -lt 1000000 ]; then - echo "::error::$bottle: bundled node present but only ${BYTES} bytes"; rm -rf "$TMP"; exit 1 - fi - if [ ! -x "$RUNNER" ]; then - echo "::error::$bottle: bundled node not executable"; rm -rf "$TMP"; exit 1 - fi - rm -rf "$TMP" - done - - - uses: actions/upload-artifact@v4 - with: - name: bottle-${{ matrix.os }} - path: | - rook-*.bottle.tar.gz - rook-*.bottle.json - if-no-files-found: error - - publish: - needs: [guard, build] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: main - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Re-verify formula version hasn't drifted since guard - env: - EXPECTED: ${{ needs.guard.outputs.version }} - run: | - CURRENT=$(grep '^ version' Formula/rook.rb | awk -F'"' '{print $2}') - if [ "$CURRENT" != "$EXPECTED" ]; then - echo "::error::Formula version drifted during build: guard saw ${EXPECTED}, main is now ${CURRENT}." - exit 1 - fi - - - uses: actions/download-artifact@v4 - with: - path: bottles - merge-multiple: true - - - name: Create or update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.guard.outputs.version }} - run: | - TAG="rook-${VERSION}" - if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" bottles/*.bottle.tar.gz --clobber - else - # --latest=false: this repo carries two tag namespaces. The curl - # installer's releases are tagged v and install.sh - # resolves them through /releases/latest when no --version is - # given; these bottle releases are tagged rook- and are - # only ever fetched by their direct asset URL (the formula's - # root_url). Without this flag `gh release create` marks the - # bottle release "latest", so the default `curl … | bash` install - # would resolve against a rook- tag instead. - gh release create "$TAG" bottles/*.bottle.tar.gz \ - --title "$TAG" \ - --latest=false \ - --notes "Homebrew bottles for rook ${VERSION}" - fi - - - name: Compute sha256s and patch formula - env: - VERSION: ${{ needs.guard.outputs.version }} - run: | - python3 - <<'PY' - import hashlib, glob, os, re, sys - - version = os.environ["VERSION"] - shas = {} - for f in sorted(glob.glob(f"bottles/rook-{version}.*.bottle.tar.gz")): - m = re.search(rf"rook-{re.escape(version)}\.([^.]+)\.bottle\.tar\.gz$", f) - if not m: - continue - label = m.group(1) - with open(f, "rb") as fp: - shas[label] = hashlib.sha256(fp.read()).hexdigest() - - if not shas: - sys.exit("No bottle artifacts found — aborting") - - macos_rank = {"tahoe": 0, "sequoia": 1, "sonoma": 2, "ventura": 3, "monterey": 4} - def sort_key(label): - if label == "x86_64_linux": - return (3, 0, label) - if label.startswith("arm64_"): - name = label[len("arm64_"):] - return (0, macos_rank.get(name, 99), label) - return (1, macos_rank.get(label, 99), label) - ordered = sorted(shas.keys(), key=sort_key) - - root_url = f"https://github.com/LambdaTest/rook/releases/download/rook-{version}" - width = max(len(k) for k in ordered) + 1 - lines = [" bottle do", f' root_url "{root_url}"'] - for label in ordered: - lines.append(f' sha256 cellar: :any_skip_relocation, {label}:{" " * (width - len(label))}"{shas[label]}"') - lines.append(" end") - block = "\n".join(lines) + "\n" - - with open("Formula/rook.rb") as f: - src = f.read() - src = re.sub(r" bottle do\n(?:.*\n)*? end\n+", "", src) - # Coupled to Formula/rook.rb's field order: the block goes directly - # after that file's version line, which its own comment says must - # not be reordered (doing so also moves this insertion point). - # Both guards on this pattern are load-bearing, and neither implies - # the other. `(?m)^` pins the match to the start of a line, so a - # comment that mentions the anchor mid-line ( # see version "x" ) - # is not a candidate. `[^"\n]` stops the class from crossing - # newlines — Python's negated classes match them — so a match that - # does start at a line's beginning cannot run on to a closing quote - # several lines below. - src, n = re.subn(r'(?m)^( version "[^"\n]+"\n)\n*', r"\1\n" + block + "\n", src, count=1) - if n != 1: - sys.exit("Could not locate version line for bottle block insertion") - with open("Formula/rook.rb", "w") as f: - f.write(src) - PY - - # The step above rewrites Ruby source and the step below pushes it to - # the tap's main branch. Without this check in between, a bad insertion - # breaks `brew install` for every user of the tap until a human notices. - - name: Validate the patched formula is still valid Ruby - run: ruby -c Formula/rook.rb - - - name: Commit bottle block - env: - VERSION: ${{ needs.guard.outputs.version }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/rook.rb - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "Add bottle block for rook ${VERSION}" - git push origin main - - - name: Trigger brew smoke test - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.guard.outputs.version }} - run: | - gh workflow run "brew-smoke" \ - --ref main \ - -f expected-version="${VERSION}" diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml index 496560e..8955f95 100644 --- a/.github/workflows/test-scripts.yml +++ b/.github/workflows/test-scripts.yml @@ -1,7 +1,7 @@ name: test-scripts -# The harnesses under scripts/ extract the real sed/Python/bash logic -# out of the committed workflow files and install.sh and run it, so +# The harnesses under scripts/ extract the real bash logic out of +# install.sh and the skill installer and run it, so # they only stay honest if something runs them. Before this, they ran when # a human remembered to. on: @@ -26,8 +26,6 @@ jobs: - name: Install the current rook run: npm install -g @testmuai/rook@latest - # ubuntu-latest ships GNU sed, so test-formula-patch.sh's gsed - # fallback (for BSD sed on macOS) is simply unused here. - name: Run the script harnesses env: # Exercises the flag harness's self-test (a `profile test --what` @@ -39,10 +37,10 @@ jobs: shopt -s nullglob harnesses=(scripts/test-*.sh) # A glob that quietly matches nothing is a green run that checked - # nothing. There are 11 harnesses; fewer means one was renamed or + # nothing. There are 7 harnesses; fewer means one was renamed or # deleted, and that should be a red run, not a silent gap. - if [ "${#harnesses[@]}" -lt 11 ]; then - echo "::error::expected at least 11 harnesses matching scripts/test-*.sh, found ${#harnesses[@]}" + if [ "${#harnesses[@]}" -lt 7 ]; then + echo "::error::expected at least 7 harnesses matching scripts/test-*.sh, found ${#harnesses[@]}" exit 1 fi for t in "${harnesses[@]}"; do diff --git a/.github/workflows/update-formula.yml b/.github/workflows/update-formula.yml deleted file mode 100644 index 900e235..0000000 --- a/.github/workflows/update-formula.yml +++ /dev/null @@ -1,187 +0,0 @@ -name: Update Homebrew Formula - -on: - repository_dispatch: - types: [formula-update] - workflow_dispatch: - inputs: - version: - description: "Version to update the formula to (e.g. 0.1.0)" - required: true - type: string - -permissions: - contents: write - actions: write - -jobs: - update: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - # Every ${{ }} in this job is routed through an env: var rather than - # spliced directly into a run: body. Actions expands ${{ }} before bash - # ever parses the script, so a spliced value's quotes, $(...) and - # backticks arrive as live shell syntax; as an env var the same bytes - # are only ever data. Both version sources here are untrusted: - # client_payload crosses a repo boundary (the private repo's - # dispatch-homebrew job forwards a human-typed string), and a - # workflow_dispatch input has no format constraint at all. This job - # holds contents:write + actions:write on the public tap's main branch. - - name: Determine version - id: version - env: - INPUT_VERSION: ${{ github.event.inputs.version }} - DISPATCH_VERSION: ${{ github.event.client_payload.version }} - run: | - if [ -n "$INPUT_VERSION" ]; then - VERSION="$INPUT_VERSION" - else - VERSION="$DISPATCH_VERSION" - fi - # Validate here, before any later step spends time or writes - # anything: this value goes on to build a registry URL, three sed - # replacements against Ruby source, a commit message and a - # workflow dispatch. - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then - echo "::error::version '$VERSION' is not a valid semver-ish string" - exit 1 - fi - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - # @testmuai/rook itself is published at $VERSION (the CLI's own - # semver). The @testmuai/rook-node-* runtime packages are NOT — they - # version independently, pinned to a Node.js release via - # scripts/ci/node-runtime.json in the private repo (e.g. "24.19.0"), - # unrelated to rook's own "0.1.0". A prior version of this step polled - # all three packages under $VERSION — on every real release, the two - # rook-node-* checks would 404 forever and this job would fail after a - # silent 2-minute timeout, well after npm/curl/changelog already - # shipped, and Homebrew would never actually get updated. - # - # Fix: wait for @testmuai/rook@$VERSION first, then read the real - # runtime version out of ITS OWN published package.json. - # cli-release-public.yml's publish-npm job sets - # optionalDependencies["@testmuai/rook-node-"] to the exact - # runtime version for all 5 platform tags, unconditionally, on every - # publish — the same field Formula/rook.rb's own install logic already - # reads to resolve the bundled runtime, so this is the same source of - # truth, not a second one to keep in sync. Deriving it here (rather - # than threading it through the private repo's dispatch payload) works - # identically for both repository_dispatch and workflow_dispatch - # triggers, with no second manual input needed. - - name: Wait for npm package availability - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - # RETRY_COUNT/RETRY_SLEEP are overridable only so a test harness - # can exercise the "never becomes available" branch in seconds - # instead of the real 2-minute budget. GitHub Actions never sets - # these, so production always gets the real values. - RETRY_COUNT="${RETRY_COUNT:-24}" - RETRY_SLEEP="${RETRY_SLEEP:-5}" - - wait_for_pkg() { - local pkg="$1" ver="$2" - echo "Waiting for ${pkg}@${ver} on npmjs.com..." - for i in $(seq 1 "$RETRY_COUNT"); do - # --max-time + the || fallback: a single stalled/failed - # connection should cost one poll attempt, same as an - # ordinary HTTP 404 does, not the entire retry budget under - # `set -e` aborting the step outright. The fallback - # reassigns STATUS rather than appending to it — curl's -w - # output is already captured by the time curl exits - # nonzero, so `|| echo "000"` here would leave STATUS as - # "000000" instead of "000". - STATUS=$(curl -s --max-time 10 -o /dev/null -w "%{http_code}" \ - "https://registry.npmjs.org/${pkg}/${ver}") || STATUS="000" - if [ "$STATUS" = "200" ]; then - return 0 - fi - echo " attempt ${i}/${RETRY_COUNT} — HTTP ${STATUS}, retrying in ${RETRY_SLEEP}s..." - sleep "$RETRY_SLEEP" - done - echo "ERROR: ${pkg}@${ver} not visible on npmjs.com after ${RETRY_COUNT} attempts" - return 1 - } - - wait_for_pkg "@testmuai/rook" "$VERSION" || exit 1 - - PKG_META=$(curl -fsSL --max-time 10 "https://registry.npmjs.org/@testmuai/rook/${VERSION}") - RUNTIME_VERSION=$(printf '%s' "$PKG_META" \ - | jq -r '.optionalDependencies["@testmuai/rook-node-darwin-arm64"] // empty') - if ! [[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::could not resolve a valid runtime version from @testmuai/rook@${VERSION}'s optionalDependencies (got '${RUNTIME_VERSION}')" - exit 1 - fi - echo "Resolved runtime version: ${RUNTIME_VERSION}" - - wait_for_pkg "@testmuai/rook-node-darwin-arm64" "$RUNTIME_VERSION" || exit 1 - wait_for_pkg "@testmuai/rook-node-linux-x64" "$RUNTIME_VERSION" || exit 1 - - - name: Compute tarball sha256 - id: tarball - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - TARBALL_URL="https://registry.npmjs.org/@testmuai/rook/-/rook-${VERSION}.tgz" - curl -fsSL "$TARBALL_URL" -o rook.tgz - SHA256=$(sha256sum rook.tgz | awk '{print $1}') - echo "tarball-url=${TARBALL_URL}" >> "$GITHUB_OUTPUT" - echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" - - - name: Update formula - env: - VERSION: ${{ steps.version.outputs.version }} - TARBALL_URL: ${{ steps.tarball.outputs.tarball-url }} - SHA256: ${{ steps.tarball.outputs.sha256 }} - run: | - FORMULA="Formula/rook.rb" - - sed -i "s|^ url \".*\"| url \"${TARBALL_URL}\"|" "$FORMULA" - sed -i "s|^ sha256 \".*\"| sha256 \"${SHA256}\"|" "$FORMULA" - sed -i "s|^ version \".*\"| version \"${VERSION}\"|" "$FORMULA" - - python3 - "$FORMULA" <<'PY' - import re, sys - path = sys.argv[1] - with open(path) as f: - src = f.read() - new = re.sub(r" bottle do\n(?:.*\n)*? end\n+", "", src) - with open(path, "w") as f: - f.write(new) - PY - - # Nothing between the transform above and the push below inspects what - # the transform produced. Without this, a bad substitution ships broken - # Ruby to the tap's main branch and every `brew install` fails until a - # human notices. - - name: Validate the patched formula is still valid Ruby - run: ruby -c Formula/rook.rb - - - name: Commit and push - id: commit - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/rook.rb - if git diff --cached --quiet; then - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - git commit -m "Update rook to ${VERSION}" - git push - echo "committed=true" >> "$GITHUB_OUTPUT" - - - name: Trigger Build bottles for the new version - if: steps.commit.outputs.committed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - run: | - gh workflow run "Build bottles" \ - --ref main \ - -f version="${VERSION}" diff --git a/Formula/rook.rb b/Formula/rook.rb deleted file mode 100644 index 6197868..0000000 --- a/Formula/rook.rb +++ /dev/null @@ -1,229 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "json" - -class Rook < Formula - desc "Agent assurance from the terminal" - homepage "https://github.com/LambdaTest/rook" - url "https://registry.npmjs.org/@testmuai/rook/-/rook-0.1.4.tgz" - # KNOWN, DELIBERATE: brew style reports FormulaAudit/ComponentsOrder on the - # version line below (it wants version before sha256). Do NOT reorder these - # three lines, and do NOT run `brew style --fix` on them. The release - # pipeline is anchored to this layout: build-bottles.yml inserts the bottle - # block directly after the version line, and update-formula.yml patches - # url/sha256/version with three start-of-line-anchored sed substitutions. - # Reordering without re-deriving those anchors silently breaks bottle - # publishing, and it does not even buy a clean run — the same cop then - # reports on the bottle block's position instead. A scoped - # `rubocop:disable` is not available either: Homebrew's own config enables - # Style/DisableCopsWithinSourceCodeDirective over every formula ("Don't - # allow cops to be disabled in casks and formulae"), so the directive is - # itself an offense. The shipping lambdatest/homebrew-kane tap carries this - # same offense on the same lines. - sha256 "7fb088e3f08d41fcdee9f47f0d5ad36db5bbf16186632dc94928893743eb7eba" # patched by update-formula.yml (Task 9) - license "Apache-2.0" - version "0.1.4" - - # :build, safely this time. node/npm are only invoked inside `def - # install` below. A previous attempt at this same scoping (reverted — - # see git history) broke real installs: the npm-published launcher's - # `#!/usr/bin/env node` shebang only gets rewritten to a working - # absolute node path by Homebrew's own Cleaner#rewrite_shebangs when - # `node` is a *required* dependency, and without that rewrite `env - # node` has nothing to resolve on a machine with no system Node. `def - # install` below now installs its own `#!/bin/sh` launcher instead of - # that npm shim in the success path, so nothing in the installed tree - # has a node-shebang left for Homebrew to rewrite (or fail to) — see - # the comment there. - depends_on "node" => :build - - def install - # `.reject` drops --build-from-source, which brew injects unconditionally - # (Library/Homebrew/language/node.rb) and which would force a node-gyp - # compile instead of using the prebuilt — same as homebrew-kane does. - # Kept inline rather than via a local: FormulaAudit/StdNpmArgs matches on - # the `system` call's own source text, so hoisting it into `args` reads as - # "npm install without std_npm_args" to the cop and flags a false positive - # that cannot be suppressed in a formula (Homebrew forbids - # `# rubocop:disable` under **/Formula/**/*.rb). - system "npm", "install", *std_npm_args.reject { |arg| arg == "--build-from-source" } - - node_pkg = - if OS.mac? - Hardware::CPU.arm? ? "@testmuai/rook-node-darwin-arm64" : "@testmuai/rook-node-darwin-x64" - elsif OS.linux? && Hardware::CPU.intel? - "@testmuai/rook-node-linux-x64" - elsif OS.linux? && Hardware::CPU.arm? - "@testmuai/rook-node-linux-arm64" - end - odie "rook does not ship a bundled Node runtime for this platform." if node_pkg.nil? - - pkg_dir = libexec/"lib/node_modules/@testmuai/rook" - node_pkg_version = JSON.parse((pkg_dir/"package.json").read) - .dig("optionalDependencies", node_pkg) - if node_pkg_version.nil? - odie "#{node_pkg} is not in the meta's optionalDependencies — " \ - "this formula requires a node-bundled rook release." - end - - binary = pkg_dir/"node_modules/#{node_pkg}/bin/node" - complete = -> { binary.exist? && binary.size > 1_000_000 } - - cd pkg_dir do - [0, 15, 45].each do |backoff| - sleep backoff if backoff.positive? - quiet_system "npm", "install", "--no-save", - "--ignore-scripts", "--audit=false", "--fund=false", - "--loglevel=error", "--prefer-online", - "--cache=#{HOMEBREW_CACHE}/npm_cache", - "#{node_pkg}@#{node_pkg_version}" - break if complete.call - end - end - - if complete.call - chmod 0755, binary - - # Replace npm's installed launcher (a symlink to the published - # package's bin/rook.cjs — a JS trampoline whose `#!/usr/bin/env - # node` shebang needs *some* node just to bootstrap, before it - # re-execs into this same bundled binary anyway) with a plain shell - # script that execs the bundled binary directly. `exec` replaces - # the shell's own process image, so signals land on the bundled - # node process natively — no manual SIGINT/SIGTERM/SIGHUP relay - # needed, unlike the JS trampoline's spawn()-based approach, which - # forwards signals to a genuine child process specifically because - # it doesn't have this option. - # - # Absolute paths baked into an installed script are ordinary, - # relocatable Homebrew practice (any formula that writes a wrapper - # referencing `#{libexec}` does the same) — not something specific - # to this fix. - # - # Deliberately only in this success branch: the opoo fallback below - # bottles a tree with no working bundled binary, where npm's - # original trampoline — which falls back to whatever system Node - # is present — is exactly the right behavior, not this. - entry = pkg_dir/"dist/cli.js" - launcher = libexec/"bin/rook" - launcher.unlink if launcher.exist? || launcher.symlink? - launcher.write <<~SH - #!/bin/sh - exec "#{binary}" "#{entry}" "$@" - SH - chmod 0755, launcher - else - msg = "#{node_pkg}@#{node_pkg_version} did not install a usable bundled Node runtime" - # ENV.build_bottle? is Homebrew's real API for this (there is no - # HOMEBREW_BUILD_BOTTLE environment variable — zero hits in the - # Homebrew source). ENV["CI"] still covers CI, which Homebrew does - # not clear; build_bottle? is what makes a maintainer's local - # `brew install --build-bottle` hard-fail here too, rather than - # silently degrading to the opoo path and bottling a node-less tree. - if ENV["CI"] || ENV.build_bottle? - odie msg - else - opoo "#{msg}. rook is installed but will fall back to system Node — " \ - "re-run `brew reinstall rook` later." - end - end - - # The keg's own identity record, read by `rook update`'s provenance - # detection at the fixed position this formula's layout dictates - # (`/libexec/lib/node_modules/@testmuai/rook` is the package root, - # so the marker sits five levels above it). Homebrew's own - # INSTALL_RECEIPT.json cannot serve this purpose: it carries no - # formula-name field, and for an API-loaded formula `source.path` is the - # shared formula.jws.json cache — so without this file, "is this keg - # rook's?" is only answerable by inferring from the Cellar path, which - # the reader refuses to do. Written unconditionally (the opoo fallback - # above still installs rook, and the keg is rook's either way), and - # deliberately containing no absolute paths: bottles are poured into - # whatever prefix the host uses, and `:any_skip_relocation` means - # nothing would rewrite one. - # Written to a temp file in the same directory first, then renamed into - # place: same-filesystem `mv` is atomic, so a crash, signal, or full - # disk mid-write can never leave a present-but-truncated marker — the - # worst case is a leftover .tmp file and no real one, which the reader - # already treats the same as "no marker" (round 8, #42's `indeterminate` - # path) — same reasoning as install.sh's manifest write (#15). - marker_path = prefix/"rook-keg-marker.json" - marker_tmp = prefix/"rook-keg-marker.json.tmp" - marker_tmp.write JSON.generate( - { v: 1, formula: name, version: version.to_s }, - ) + "\n" - mv marker_tmp, marker_path - - bin.install_symlink libexec.glob("bin/*") - end - - def caveats - <<~EOS - rook runs on its own bundled Node runtime; the `node` dependency is - only used at install time (npm). - EOS - end - - test do - assert_match version.to_s, shell_output("#{bin}/rook --version") - node_pkg = - if OS.mac? - Hardware::CPU.arm? ? "@testmuai/rook-node-darwin-arm64" : "@testmuai/rook-node-darwin-x64" - elsif OS.linux? && Hardware::CPU.intel? - "@testmuai/rook-node-linux-x64" - elsif OS.linux? && Hardware::CPU.arm? - "@testmuai/rook-node-linux-arm64" - end - node_root = libexec/"lib/node_modules/@testmuai/rook/node_modules/#{node_pkg}" - binary = node_root/"bin/node" - assert_path_exists binary, "bundled node binary missing" - assert_predicate binary, :executable?, "bundled node binary not executable — install-time chmod missed" - pin = JSON.parse((node_root/"package.json").read)["nodeRuntimeVersion"] - refute_nil pin, "node package carries no nodeRuntimeVersion stamp" - assert_equal "v#{pin}", shell_output("#{binary} --version").strip - - # Structural guard on the actual mechanism, not just its outcome: the - # `rook --version` call above already proves the launcher works, but - # not that it's independent of Homebrew's own `node`. A regression - # here (e.g. `bin.install_symlink` picking up npm's original - # `env node`-shebang trampoline again) would only surface on a - # machine with no system Node — this catches it on every machine. - shebang = (libexec/"bin/rook").readlines.first.to_s - refute_match(/node/, shebang, "launcher shells out through node again") - - # Independently re-derives the write-side comment's "the marker sits - # five levels above pkg_dir" claim, rather than only reading back - # through `prefix` (which def install also wrote through — a - # positionally circular check on its own). A future edit that moves the - # marker or the npm package nesting out of that five-level relationship - # fails here, instead of only silently breaking the reader's own - # independently hard-coded path walk (the same failure class round 8's - # #42 already catalogued for a sibling bug in that reader). - pkg_dir = libexec/"lib/node_modules/@testmuai/rook" - assert_equal prefix, pkg_dir.dirname.dirname.dirname.dirname.dirname, - "the marker's \"five levels above pkg_dir\" layout assumption broke" - - # The keg marker `rook update` identifies this keg by — asserted on - # contents, not just presence, so a change that breaks its shape fails - # here (and in brew-smoke, which calls `brew test`) rather than in - # every installed copy's provenance detection. - # - # Guarded on existence: `bottle do`'s sha256 and `version` are both - # unchanged by this PR, so `brew install` keeps pouring the pre-marker - # 0.1.0 bottle until a rebuild (a release, or a manual - # build-bottles.yml dispatch) lands one that has it. Without this - # guard, any brew-smoke run dispatched against 0.1.0 in that window - # hits an uncaught Errno::ENOENT instead of a clean pass. - marker_path = prefix/"rook-keg-marker.json" - if marker_path.exist? - marker = JSON.parse(marker_path.read) - assert_equal 1, marker["v"], "keg marker v drifted" - assert_equal name, marker["formula"], "keg marker names the wrong formula" - assert_equal version.to_s, marker["version"], "keg marker version disagrees with the keg" - else - opoo "rook-keg-marker.json missing — this keg predates the marker " \ - "(pre-rebuild bottle); skipping the marker assertions." - end - end -end diff --git a/README.md b/README.md index 8efc9a8..5fc7677 100644 --- a/README.md +++ b/README.md @@ -65,19 +65,29 @@ What a run gives you: Three ways, on macOS and Linux, x64 and arm64. Each one carries its own Node runtime, so none of them needs Node installed. -**Homebrew** +**Homebrew** — the formula lives in [LambdaTest/homebrew-rook](https://github.com/LambdaTest/homebrew-rook). ``` -brew tap LambdaTest/rook https://github.com/LambdaTest/rook.git brew install lambdatest/rook/rook ``` +Homebrew automatically adds the tap for a fresh installation. + Install by the full `lambdatest/rook/rook` name, not just `rook` — Homebrew requires third-party-tap formulae to be explicitly trusted before loading them, and naming the tap in full is what satisfies that automatically. `brew install rook` (after the same tap) hits `Error: Refusing to load formula lambdatest/rook/rook from untrusted tap lambdatest/rook.` +If you tapped before the formula moved, with `brew tap LambdaTest/rook https://github.com/LambdaTest/rook.git`, +re-point that tap once so upgrades keep arriving. The installed `rook` is untouched. Do not +`brew untap --force`, which uninstalls it. + +``` +brew tap --custom-remote lambdatest/rook https://github.com/LambdaTest/homebrew-rook +git -C "$(brew --repository lambdatest/rook)" reset --hard origin/main +``` + **Shell installer** — downloads the archive matching your platform, verifies its checksum, and links `rook` into `~/.local/bin`. Pass `--dir` to put it somewhere else, or `--version X.Y.Z` to pin one. ``` diff --git a/install.sh b/install.sh index 2f5d002..5f1af19 100755 --- a/install.sh +++ b/install.sh @@ -42,7 +42,8 @@ resolve_version() { # `sed -n ... p` (print only on a match), NOT a bare `s///`: a plain # substitution passes a non-matching line through UNCHANGED, so a # `tag_name` that isn't a `v` tag — e.g. the `rook-` - # tag build-bottles.yml publishes bottles under — would make $latest the + # tags Homebrew bottles were released under here before the formula + # moved to LambdaTest/homebrew-rook — would make $latest the # whole raw JSON line, sail past the `-z` guard below, and fail much # later as a confusing 404 on a garbage download URL. With `-n`+`p` a # non-match produces empty output and the guard does its job. diff --git a/scripts/fixtures/rook-formula-no-bottle.rb b/scripts/fixtures/rook-formula-no-bottle.rb deleted file mode 100644 index 44ff705..0000000 --- a/scripts/fixtures/rook-formula-no-bottle.rb +++ /dev/null @@ -1,28 +0,0 @@ -# typed: false -# frozen_string_literal: true - -# Fixture for scripts/test-formula-patch.sh — shaped like the first-ever -# Formula/rook.rb (the real one on this branch): no Homebrew bottle stanza -# yet, because no bottle has been built. Verifies the strip regex is a -# true no-op here — it must not eat anything when there's nothing to strip. - -require "json" - -class Rook < Formula - desc "Agent assurance from the terminal" - homepage "https://github.com/LambdaTest/rook" - url "https://registry.npmjs.org/@testmuai/rook/-/rook-0.1.0.tgz" - sha256 "REPLACE_ON_FIRST_RELEASE" # patched by update-formula.yml (Task 9) - license "Apache-2.0" - version "0.1.0" - - depends_on "node" - - def install - system "npm", "install", *std_npm_args - end - - test do - assert_match version.to_s, shell_output("#{bin}/rook --version") - end -end diff --git a/scripts/fixtures/rook-formula-with-bottle.rb b/scripts/fixtures/rook-formula-with-bottle.rb deleted file mode 100644 index ea54c77..0000000 --- a/scripts/fixtures/rook-formula-with-bottle.rb +++ /dev/null @@ -1,42 +0,0 @@ -# typed: false -# frozen_string_literal: true - -# Fixture for scripts/test-formula-patch.sh — shaped like a post-release -# Formula/rook.rb that already carries a stale Homebrew bottle stanza from -# a previous version (i.e. the state update-formula.yml sees on every -# version bump after the first). Not the real formula; trimmed to just the -# lines the sed/python transform in .github/workflows/update-formula.yml -# actually touches, plus enough real structure (depends_on/install/test) to -# prove the strip regex doesn't eat anything past the stanza's `end`. -# -# The stale root_url below is in the `rook-` shape the real -# generator emits (build-bottles.yml's Python transform), not a `v` -# one — build-bottles.yml's stale-bottle-block guard greps for exactly that -# shape, so a fixture in any other format makes that guard's tests vacuous. - -require "json" - -class Rook < Formula - desc "Agent assurance from the terminal" - homepage "https://github.com/LambdaTest/rook" - url "https://registry.npmjs.org/@testmuai/rook/-/rook-0.1.0.tgz" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" # patched by update-formula.yml (Task 9) - license "Apache-2.0" - version "0.1.0" - - bottle do - root_url "https://github.com/LambdaTest/rook/releases/download/rook-0.1.0" - sha256 cellar: :any_skip_relocation, arm64_sequoia: "1111111111111111111111111111111111111111111111111111111111111111" - sha256 cellar: :any_skip_relocation, x86_64_linux: "2222222222222222222222222222222222222222222222222222222222222222" - end - - depends_on "node" - - def install - system "npm", "install", *std_npm_args - end - - test do - assert_match version.to_s, shell_output("#{bin}/rook --version") - end -end diff --git a/scripts/test-bottle-insert.sh b/scripts/test-bottle-insert.sh deleted file mode 100755 index 07b1daf..0000000 --- a/scripts/test-bottle-insert.sh +++ /dev/null @@ -1,391 +0,0 @@ -#!/usr/bin/env bash -# Regression test for the "Compute sha256s and patch formula" step in -# .github/workflows/build-bottles.yml — the Python logic that hashes the -# built bottle artifacts and inserts a `bottle do...end` block into -# Formula/rook.rb immediately after the `version "..."` line. -# -# Sibling to scripts/test-formula-patch.sh (same extraction discipline, -# same fixtures), kept as a separate file because it targets a different -# workflow file and a different step shape: a Python heredoc that reads -# real files from disk (fake bottle artifacts, not just a formula file), -# rather than a run of sed one-liners. Reuses test-formula-patch.sh's two -# existing fixtures — scripts/fixtures/rook-formula-no-bottle.rb and -# rook-formula-with-bottle.rb — since they already cover exactly the two -# shapes this insertion logic has to handle: no bottle block yet, and a -# stale bottle block left over from a previous version. -# -# This does NOT hand-maintain a copy of the sort_key/insertion logic. It -# extracts the real "Compute sha256s and patch formula" step body live -# from build-bottles.yml (stripping the YAML block indentation the same -# way GitHub Actions does) and runs the untouched `python3 - <<'PY' ... PY` -# heredoc as-is against fixture formulas plus fake bottle artifacts. If -# someone edits the real transform, this test exercises the edit. -# -# Usage: scripts/test-bottle-insert.sh -set -uo pipefail -# (deliberately no -e, anywhere in this script: run_transform's whole job -# is to sometimes fail — including under deliberate corruption of the real -# workflow, applied by hand outside this script, see task-10-report.md — -# and every check after it (bottle-block count, sha256 line count, etc.) -# must still run and report FAIL on its own merits rather than the script -# silently aborting on the first non-zero exit status it meets.) - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WORKFLOW="$REPO_ROOT/.github/workflows/build-bottles.yml" -FIXTURES="$REPO_ROOT/scripts/fixtures" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -FAIL=0 -pass() { echo "PASS: $1"; } -fail() { echo "FAIL: $1"; FAIL=1; } -# Same explicit if/else as test-formula-patch.sh (shellcheck SC2015: `cmd -# && pass || fail` would also run fail if pass itself ever failed). -check() { - local ok_msg="$1" fail_msg="$2" - shift 2 - if "$@"; then pass "$ok_msg"; else fail "$fail_msg"; fi -} - -if [ ! -f "$WORKFLOW" ]; then - echo "FATAL: $WORKFLOW not found" >&2 - exit 2 -fi - -# --- Extract the exact "Compute sha256s and patch formula" step body ------- -extract_transform() { - awk ' - /- name: Compute sha256s and patch formula/ { capture=1; next } - capture && /run: \|/ { inrun=1; next } - capture && inrun && /^ - name:/ { exit } - capture && inrun { print } - ' "$WORKFLOW" | sed 's/^ //' -} - -TRANSFORM="$(extract_transform)" - -if [ -z "$TRANSFORM" ]; then - echo "FATAL: could not extract the 'Compute sha256s and patch formula' step from $WORKFLOW — did the step name or run block move?" >&2 - exit 2 -fi -if ! echo "$TRANSFORM" | grep -q 'import hashlib'; then - echo "FATAL: extraction did not capture the python hashing logic" >&2 - exit 2 -fi -if ! echo "$TRANSFORM" | grep -q 'sort_key'; then - echo "FATAL: extraction did not capture the sort_key ordering logic" >&2 - exit 2 -fi -if echo "$TRANSFORM" | grep -qF -- "\${{"; then - echo "FATAL: extraction captured a GitHub Actions template expression (\${{ ... }}) — the step's env: block leaked into the run: body; extraction markers need updating" >&2 - exit 2 -fi - -# run_transform DIR VERSION: run the extracted heredoc with cwd=DIR and -# VERSION=VERSION in its environment (matches how the real step gets -# VERSION — via `env:`, not a bash assignment inside the script body). -# Does NOT abort on a non-zero exit; callers capture and assert on RC. -run_transform() { - local dir="$1" version="$2" - ( cd "$dir" && VERSION="$version" bash -c "$TRANSFORM" ) -} - -# Independent of the transform's own hashing: whichever tool the host has. -# (macOS ships shasum; the ubuntu-latest runner this now also runs on ships -# sha256sum, and shasum only via perl.) -if command -v sha256sum >/dev/null 2>&1; then - sha_of() { sha256sum "$1" | awk '{print $1}'; } -else - sha_of() { shasum -a 256 "$1" | awk '{print $1}'; } -fi -# absent PATTERN FILE: succeeds (exit 0) iff PATTERN is NOT present in FILE -# (fixed-string match) — used with check() to assert something was cleaned -# up, since check() treats a 0 exit as PASS. -absent() { ! grep -qF -- "$1" "$2"; } - -# label_order_in FORMULA: prints the bottle-block label names in the order -# they appear in the sha256 lines, one per line. -label_order_in() { - grep -E '^ sha256 cellar:' "$1" | sed -E 's/^ sha256 cellar: :any_skip_relocation, ([A-Za-z0-9_]+):.*/\1/' -} - -# ============================================================================= -# Case A: no existing bottle block (scripts/fixtures/rook-formula-no-bottle.rb) -# — 5 labels chosen so the correct macos_rank order is NOT the same as plain -# alphabetical order of the label strings. This matters: because arm64_* -# labels all start with "a" and x86_64_linux starts with "x", the *group* -# boundaries (arm64 macOS < bare/intel macOS < linux) happen to fall out of -# plain alphabetical sorting too, for any label set — that part alone can't -# tell sort_key apart from `sorted(shas.keys())` with no key at all. What -# CAN tell them apart is the order *within* a group, where macos_rank and -# alphabetical order of the codename disagree: -# - arm64_sequoia (rank 1), arm64_monterey (rank 4), arm64_bigsur (not in -# macos_rank -> falls to the `.get(name, 99)` fallback, so it must sort -# LAST within the arm64 group despite "bigsur" being alphabetically -# FIRST of the three) — this also exercises the unrecognized-codename -# fallback branch, not just the known ranks. -# - tahoe (rank 0, bare/intel-group) and x86_64_linux (always last). -# Correct order: arm64_sequoia, arm64_monterey, arm64_bigsur, tahoe, -# x86_64_linux. Plain alphabetical order of these exact 5 strings is -# arm64_bigsur, arm64_monterey, arm64_sequoia, tahoe, x86_64_linux — a -# different order (first and third swapped) — so a test that only checked -# "does the output order match X" with an alphabetically-coincident X could -# pass even with sort_key's key= dropped entirely. This label set can't. -# ============================================================================= -CASE_A="$WORKDIR/case-a" -mkdir -p "$CASE_A/Formula" "$CASE_A/bottles" -cp "$FIXTURES/rook-formula-no-bottle.rb" "$CASE_A/Formula/rook.rb" - -VERSION_A="0.1.0" -declare -a LABELS_A=(arm64_sequoia arm64_monterey arm64_bigsur tahoe x86_64_linux) -declare -a EXPECTED_ORDER_A=(arm64_sequoia arm64_monterey arm64_bigsur tahoe x86_64_linux) -for label in "${LABELS_A[@]}"; do - printf 'bottle-content-%s\n' "$label" > "$CASE_A/bottles/rook-${VERSION_A}.${label}.bottle.tar.gz" -done - -run_transform "$CASE_A" "$VERSION_A" -RC_A=$? - -check "(a) transform exited 0" "(a) transform exited nonzero ($RC_A)" [ "$RC_A" -eq 0 ] - -check "(a) exactly one bottle block inserted" \ - "(a) bottle block count is not exactly 1" \ - [ "$(grep -c '^ bottle do$' "$CASE_A/Formula/rook.rb")" -eq 1 ] - -check "(a) root_url set correctly" \ - "(a) root_url missing or wrong" \ - grep -qF " root_url \"https://github.com/LambdaTest/rook/releases/download/rook-${VERSION_A}\"" \ - "$CASE_A/Formula/rook.rb" - -SHA_LINE_COUNT_A="$(grep -c '^ sha256 cellar:' "$CASE_A/Formula/rook.rb")" -check "(a) one sha256 line per label (${#LABELS_A[@]})" \ - "(a) sha256 line count ($SHA_LINE_COUNT_A) != label count (${#LABELS_A[@]})" \ - [ "$SHA_LINE_COUNT_A" -eq "${#LABELS_A[@]}" ] - -ACTUAL_ORDER_A="$(label_order_in "$CASE_A/Formula/rook.rb" | tr '\n' ',' )" -EXPECTED_ORDER_A_STR="$(IFS=,; echo "${EXPECTED_ORDER_A[*]}")," -check "(a) sort order matches macos_rank, not plain alphabetical (sequoia, monterey, then fallback-ranked bigsur; then intel tahoe; then linux last): $ACTUAL_ORDER_A" \ - "(a) sort order wrong: got [$ACTUAL_ORDER_A], expected [$EXPECTED_ORDER_A_STR]" \ - [ "$ACTUAL_ORDER_A" = "$EXPECTED_ORDER_A_STR" ] - -SHA_OK_A=1 -for label in "${LABELS_A[@]}"; do - EXPECTED_SHA="$(sha_of "$CASE_A/bottles/rook-${VERSION_A}.${label}.bottle.tar.gz")" - if ! grep -E "^ sha256 cellar: :any_skip_relocation, ${label}:[[:space:]]+\"${EXPECTED_SHA}\"$" "$CASE_A/Formula/rook.rb" >/dev/null; then - SHA_OK_A=0 - echo " -- sha mismatch for $label: expected $EXPECTED_SHA" - fi -done -check "(a) every sha256 value matches an independent shasum of its bottle file" \ - "(a) at least one sha256 value did not match an independent shasum of its bottle file" \ - [ "$SHA_OK_A" -eq 1 ] - -VERSION_LINE_NUM_A="$(grep -n '^ version "' "$CASE_A/Formula/rook.rb" | head -1 | cut -d: -f1)" -# -1 sentinel (rather than empty) if no " bottle do" line exists at all — -# keeps the arithmetic comparison below well-formed (a clean numeric -# mismatch) instead of a bash "[: integer expected" warning when this -# fires under a corrupted transform that never inserted anything. -BOTTLE_LINE_NUM_A="$(grep -n '^ bottle do$' "$CASE_A/Formula/rook.rb" | head -1 | cut -d: -f1)" -BOTTLE_LINE_NUM_A="${BOTTLE_LINE_NUM_A:--1}" -check "(a) bottle block placed immediately after the version line (blank line between, per the insertion regex)" \ - "(a) bottle block not placed immediately after the version line (version at line $VERSION_LINE_NUM_A, bottle do at line $BOTTLE_LINE_NUM_A)" \ - [ "$BOTTLE_LINE_NUM_A" -eq "$((VERSION_LINE_NUM_A + 2))" ] - -check "(a) content after the bottle block survived the insertion" \ - "(a) content after the bottle block was lost or corrupted" \ - grep -qF 'depends_on "node"' "$CASE_A/Formula/rook.rb" - -# ============================================================================= -# Case B: fixture that ALREADY has a stale bottle block (a re-run, or a -# version bump: scripts/fixtures/rook-formula-with-bottle.rb, version -# bumped 0.1.0 -> 0.2.0 the same way update-formula.yml would have already -# done it before build-bottles.yml ever runs) — must end up with exactly -# ONE clean bottle block, not two, and the stale fake shas/root_url from -# the old version must be gone. -# ============================================================================= -CASE_B="$WORKDIR/case-b" -mkdir -p "$CASE_B/Formula" "$CASE_B/bottles" -cp "$FIXTURES/rook-formula-with-bottle.rb" "$CASE_B/Formula/rook.rb" - -VERSION_B="0.2.0" -sed -i.bak "s/version \"0.1.0\"/version \"${VERSION_B}\"/" "$CASE_B/Formula/rook.rb" -rm -f "$CASE_B/Formula/rook.rb.bak" - -declare -a LABELS_B=(arm64_sequoia x86_64_linux) -for label in "${LABELS_B[@]}"; do - printf 'bottle-content-v2-%s\n' "$label" > "$CASE_B/bottles/rook-${VERSION_B}.${label}.bottle.tar.gz" -done - -run_transform "$CASE_B" "$VERSION_B" -RC_B=$? - -check "(b) transform exited 0" "(b) transform exited nonzero ($RC_B)" [ "$RC_B" -eq 0 ] - -check "(b) exactly one bottle block present after re-run (not two)" \ - "(b) bottle block count is not exactly 1 — strip-then-reinsert left duplicates" \ - [ "$(grep -c '^ bottle do$' "$CASE_B/Formula/rook.rb")" -eq 1 ] - -check "(b) sha256 line count matches only the new labels (${#LABELS_B[@]}) — old block's lines did not survive alongside the new ones" \ - "(b) sha256 line count doesn't match the new label count — old block's lines may have leaked through alongside the new ones" \ - [ "$(grep -c '^ sha256 cellar:' "$CASE_B/Formula/rook.rb")" -eq "${#LABELS_B[@]}" ] - -check "(b) new root_url reflects the bumped version" \ - "(b) root_url does not reflect the bumped version" \ - grep -qF " root_url \"https://github.com/LambdaTest/rook/releases/download/rook-${VERSION_B}\"" \ - "$CASE_B/Formula/rook.rb" - -check "(b) stale root_url from the old version is gone" \ - "(b) stale root_url from the old version is still present" \ - absent "download/rook-0.1.0" "$CASE_B/Formula/rook.rb" - -check "(b) stale fake sha256 (arm64_sequoia, old '1111...') is gone" \ - "(b) stale fake sha256 (arm64_sequoia, old '1111...') survived" \ - absent "1111111111111111111111111111111111111111111111111111111111111111" "$CASE_B/Formula/rook.rb" - -check "(b) stale fake sha256 (x86_64_linux, old '2222...') is gone" \ - "(b) stale fake sha256 (x86_64_linux, old '2222...') survived" \ - absent "2222222222222222222222222222222222222222222222222222222222222222" "$CASE_B/Formula/rook.rb" - -SHA_OK_B=1 -for label in "${LABELS_B[@]}"; do - EXPECTED_SHA="$(sha_of "$CASE_B/bottles/rook-${VERSION_B}.${label}.bottle.tar.gz")" - if ! grep -E "^ sha256 cellar: :any_skip_relocation, ${label}:[[:space:]]+\"${EXPECTED_SHA}\"$" "$CASE_B/Formula/rook.rb" >/dev/null; then - SHA_OK_B=0 - echo " -- sha mismatch for $label: expected $EXPECTED_SHA" - fi -done -check "(b) every new sha256 value matches an independent shasum of its bottle file" \ - "(b) at least one new sha256 value did not match an independent shasum of its bottle file" \ - [ "$SHA_OK_B" -eq 1 ] - -check "(b) content after the bottle block survived the re-insertion" \ - "(b) content after the bottle block was lost or corrupted" \ - grep -qF 'depends_on "node"' "$CASE_B/Formula/rook.rb" - -# ============================================================================= -# Case C: a formula carrying a COMMENT that mentions the insertion anchor. -# The real Formula/rook.rb does exactly this — its comment explains why the -# url/sha256/version lines must not be reordered. Python's `[^"]` matches -# newlines, so an insertion regex written as ` version "[^"]+"\n` can start -# on the comment's mention of the anchor and run to a closing quote several -# lines later, splicing the bottle block into the middle of the file. The -# block must land after the REAL version line regardless. -# -# Scope, measured rather than assumed (all four regex variants were run -# against both this case and case D): -# -# regex case C case D -# ( version "[^"]+"\n) FAIL FAIL <- the pre-fix form -# ( version "[^"\n]+"\n) pass FAIL -# (?m)^( version "[^"]+"\n) pass pass -# (?m)^( version "[^"\n]+"\n) pass pass <- current -# -# So this case is a true regression test against the pre-fix form, but once -# `(?m)^` is in place it no longer isolates the character-class guard: -# anchoring alone satisfies it. For the class guard specifically it is -# CHARACTERISATION, not regression. The guard is kept anyway — it costs -# nothing and covers a line that does start with the anchor but carries no -# closing quote — and the case that would isolate it needs a formula whose -# Ruby is already unparseable, which the `ruby -c` step now blocks upstream. -# ============================================================================= -CASE_C="$WORKDIR/case-c" -mkdir -p "$CASE_C/Formula" "$CASE_C/bottles" -# The decoy is the minimal shape that triggers a newline-crossing match: a -# comment ending in the anchor's own opening quote, and then a later line -# whose only quote is its last character. `[^"]+` walks from the first -# straight through the newline to the second, and the whole span becomes -# the "version line" the block gets appended to. -awk ' - /^ version "/ && !done { - print " # The bottle insertion anchors on ^ version \"" - print " # ...and update-formula.yml uses the same anchor. Do not reorder.\"" - done = 1 - } - { print } -' "$FIXTURES/rook-formula-no-bottle.rb" >"$CASE_C/Formula/rook.rb" - -VERSION_C="0.3.0" -sed -i.bak "s/version \"0.1.0\"/version \"${VERSION_C}\"/" "$CASE_C/Formula/rook.rb" -rm -f "$CASE_C/Formula/rook.rb.bak" -printf 'bottle-content-c\n' > "$CASE_C/bottles/rook-${VERSION_C}.x86_64_linux.bottle.tar.gz" - -run_transform "$CASE_C" "$VERSION_C" -RC_C=$? - -check "(c) transform exited 0 with an anchor-quoting comment present" \ - "(c) transform exited nonzero ($RC_C) with an anchor-quoting comment present" \ - [ "$RC_C" -eq 0 ] - -VERSION_LINE_NUM_C="$(grep -n '^ version "' "$CASE_C/Formula/rook.rb" | head -1 | cut -d: -f1)" -BOTTLE_LINE_NUM_C="$(grep -n '^ bottle do$' "$CASE_C/Formula/rook.rb" | head -1 | cut -d: -f1)" -BOTTLE_LINE_NUM_C="${BOTTLE_LINE_NUM_C:--1}" -check "(c) bottle block still lands after the real version line, not the comment that mentions it" \ - "(c) bottle block landed at line $BOTTLE_LINE_NUM_C, expected $((VERSION_LINE_NUM_C + 2)) — the insertion regex matched across lines starting from the comment" \ - [ "$BOTTLE_LINE_NUM_C" -eq "$((VERSION_LINE_NUM_C + 2))" ] - -check "(c) the comment that mentions the anchor survived intact" \ - "(c) the insertion consumed the comment that mentions the anchor" \ - grep -qF "The bottle insertion anchors on" "$CASE_C/Formula/rook.rb" - -check "(c) exactly one bottle block" \ - "(c) bottle block count is not exactly 1" \ - [ "$(grep -c '^ bottle do$' "$CASE_C/Formula/rook.rb")" -eq 1 ] - -# ============================================================================= -# Case D: a decoy that is complete WITHIN ONE LINE — a comment carrying a -# whole `version "..."` that happens to end at the line's closing quote. -# -# Distinct from case (c), and neither guard catches both: (c) needs the -# character class to stop at newlines, (d) needs the match pinned to the -# start of a line. A pattern with only the first guard still matches this -# decoy mid-line and appends the bottle block to the comment instead of to -# the real version line — which, since the comment sits above it, drops the -# block into the middle of the formula header. -# ============================================================================= -CASE_D="$WORKDIR/case-d" -mkdir -p "$CASE_D/Formula" "$CASE_D/bottles" -awk ' - /^ version "/ && !done { - print " # Decoy: the release notes still say version \"0.0.9\"" - done = 1 - } - { print } -' "$FIXTURES/rook-formula-no-bottle.rb" >"$CASE_D/Formula/rook.rb" - -VERSION_D="0.4.0" -sed -i.bak "s/^ version \"0.1.0\"/ version \"${VERSION_D}\"/" "$CASE_D/Formula/rook.rb" -rm -f "$CASE_D/Formula/rook.rb.bak" -printf 'bottle-content-d\n' > "$CASE_D/bottles/rook-${VERSION_D}.x86_64_linux.bottle.tar.gz" - -run_transform "$CASE_D" "$VERSION_D" -RC_D=$? - -check "(d) transform exited 0 with a same-line version decoy present" \ - "(d) transform exited nonzero ($RC_D) with a same-line version decoy present" \ - [ "$RC_D" -eq 0 ] - -VERSION_LINE_NUM_D="$(grep -n '^ version "' "$CASE_D/Formula/rook.rb" | head -1 | cut -d: -f1)" -BOTTLE_LINE_NUM_D="$(grep -n '^ bottle do$' "$CASE_D/Formula/rook.rb" | head -1 | cut -d: -f1)" -BOTTLE_LINE_NUM_D="${BOTTLE_LINE_NUM_D:--1}" -check "(d) bottle block lands after the real version line, not after a comment containing one" \ - "(d) bottle block landed at line $BOTTLE_LINE_NUM_D, expected $((VERSION_LINE_NUM_D + 2)) — the insertion regex matched a version string that was not at the start of a line" \ - [ "$BOTTLE_LINE_NUM_D" -eq "$((VERSION_LINE_NUM_D + 2))" ] - -check "(d) the decoy comment survived intact" \ - "(d) the insertion consumed the decoy comment" \ - grep -qF 'Decoy: the release notes still say' "$CASE_D/Formula/rook.rb" - -check "(d) the block's root_url carries the real version, not the decoy's" \ - "(d) root_url does not carry the real version" \ - grep -qF "download/rook-${VERSION_D}" "$CASE_D/Formula/rook.rb" - -check "(d) exactly one bottle block" \ - "(d) bottle block count is not exactly 1" \ - [ "$(grep -c '^ bottle do$' "$CASE_D/Formula/rook.rb")" -eq 1 ] - -echo -if [ "$FAIL" -ne 0 ]; then - echo "=== bottle-insert transform test: FAILED ===" - exit 1 -fi -echo "=== bottle-insert transform test: PASSED ===" diff --git a/scripts/test-formula-patch.sh b/scripts/test-formula-patch.sh deleted file mode 100755 index 79912d2..0000000 --- a/scripts/test-formula-patch.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash -# Regression test for the "Update formula" step in -# .github/workflows/update-formula.yml — the sed/python logic that patches -# url/sha256/version in Formula/rook.rb and strips any stale -# `bottle do...end` block on every version bump. -# -# This does NOT hand-maintain a copy of that logic. It extracts the real -# step's shell body live from the workflow file (stripping the YAML block -# indentation the same way GitHub Actions does, then dropping only the -# `FORMULA=` assignment so the fixture path can be injected) and runs the -# untouched remainder — the 3 sed substitutions plus the python heredoc — -# against fixture formulas. If someone edits the real transform, this test -# exercises the edit; there is nothing here to fall out of sync. -# -# VERSION/TARBALL_URL/SHA256 are supplied to the extracted body through the -# environment, which is exactly how the real step receives them: they are -# `env:` entries, not `${{ }}` spliced into the shell body. The extraction -# asserts that below — a re-introduced splice is a shell-injection -# regression, not a cosmetic one, and must fail this harness loudly. -# -# macOS ships BSD sed, whose `-i` needs a backup-suffix argument the real -# workflow's `-i` invocations don't pass (GNU sed, as used by the -# ubuntu-latest runner, doesn't need one). Rather than changing the -# extracted `sed -i` calls to fit BSD sed, this script swaps in `gsed` -# (`brew install gnu-sed`) on macOS so the dialect under test matches CI. -# -# Usage: scripts/test-formula-patch.sh -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WORKFLOW="$REPO_ROOT/.github/workflows/update-formula.yml" -FIXTURES="$REPO_ROOT/scripts/fixtures" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -FAIL=0 -pass() { echo "PASS: $1"; } -fail() { echo "FAIL: $1"; FAIL=1; } -# check : explicit if/else rather -# than `cmd && pass || fail`, which shellcheck (rightly, SC2015) flags — -# `fail` would also run if `pass` itself ever failed, since A && B || C is -# not if-then-else. -check() { - local ok_msg="$1" fail_msg="$2" - shift 2 - if "$@"; then pass "$ok_msg"; else fail "$fail_msg"; fi -} - -if [ ! -f "$WORKFLOW" ]; then - echo "FATAL: $WORKFLOW not found" >&2 - exit 2 -fi - -# --- Extract the exact "Update formula" step body from the real workflow --- -# Dedents by the run: block's own first-line indent and stops at the first -# line indented less than that, so trailing YAML (the next step, or a -# comment above it) can't leak into the extracted script. -extract_transform() { - awk ' - /- name: Update formula/ { instep=1; next } - instep && /run: \|/ { inrun=1; next } - inrun { - if ($0 ~ /^[[:space:]]*$/) { print ""; next } - match($0, /^ */); ind = RLENGTH - if (base == 0) base = ind - if (ind < base) exit - print substr($0, base + 1) - } - ' "$WORKFLOW" | grep -vE '^FORMULA="' -} - -TRANSFORM="$(extract_transform)" - -if [ -z "$TRANSFORM" ]; then - echo "FATAL: could not extract the 'Update formula' step from $WORKFLOW — did the step name or run block move?" >&2 - exit 2 -fi -# shellcheck disable=SC2016 # the literal two-brace Actions sigil is the point -if echo "$TRANSFORM" | grep -qF -- '${{'; then - echo "FATAL: the 'Update formula' run: body contains a \${{ ... }} expression. Actions expands those before bash parses the script, so the value's quotes/\$()/backticks become live shell syntax — route it through the step's env: block and reference \"\$VAR\" instead." >&2 - exit 2 -fi -if ! echo "$TRANSFORM" | grep -q 'sed -i.*url'; then - echo "FATAL: extraction did not capture the url sed line — check the step name/marker still matches" >&2 - exit 2 -fi -if ! echo "$TRANSFORM" | grep -q 'bottle do'; then - echo "FATAL: extraction did not capture the python bottle-strip regex" >&2 - exit 2 -fi - -# Pick the sed binary that matches CI's GNU sed. -SED_BIN="sed" -if ! sed --version >/dev/null 2>&1; then - if command -v gsed >/dev/null 2>&1; then - SED_BIN="gsed" - else - echo "FATAL: this platform's sed is not GNU sed and gsed is not installed (brew install gnu-sed)" >&2 - exit 2 - fi -fi -RUN_SCRIPT="$(echo "$TRANSFORM" | sed "s/^sed -i/${SED_BIN} -i/")" - -run_transform() { - # $1 = FORMULA path, $2 = VERSION, $3 = TARBALL_URL, $4 = SHA256 - FORMULA="$1" VERSION="$2" TARBALL_URL="$3" SHA256="$4" bash -c "$RUN_SCRIPT" -} - -VERSION="9.9.9" -TARBALL_URL="https://registry.npmjs.org/@testmuai/rook/-/rook-9.9.9.tgz" -SHA256="deadbeef00112233445566778899aabbccddeeff00112233445566778899aa" - -# --- Case A: fixture WITH a stale bottle block ------------------------------ -WITH_BOTTLE="$WORKDIR/with-bottle.rb" -cp "$FIXTURES/rook-formula-with-bottle.rb" "$WITH_BOTTLE" -run_transform "$WITH_BOTTLE" "$VERSION" "$TARBALL_URL" "$SHA256" - -check "(a) url replaced (with-bottle fixture)" \ - "(a) url NOT replaced (with-bottle fixture)" \ - grep -qF "url \"$TARBALL_URL\"" "$WITH_BOTTLE" -check "(a) sha256 replaced (with-bottle fixture)" \ - "(a) sha256 NOT replaced (with-bottle fixture)" \ - grep -qF "sha256 \"$SHA256\"" "$WITH_BOTTLE" -check "(a) version replaced (with-bottle fixture)" \ - "(a) version NOT replaced (with-bottle fixture)" \ - grep -qF "version \"$VERSION\"" "$WITH_BOTTLE" -# Anchored to match exactly what the python regex targets (a line that is -# precisely " bottle do") — a loose substring check would also match the -# words "bottle do" inside this fixture's own descriptive comments above, -# producing a false FAIL even when the real Ruby block was stripped fine. -if grep -qE '^ bottle do$' "$WITH_BOTTLE"; then - fail "(b) stale bottle block NOT stripped" -else - pass "(b) stale bottle block stripped" -fi -check "(b) content after the bottle block survived the strip" \ - "(b) the strip regex ate content past the bottle block's 'end'" \ - grep -qF 'depends_on "node"' "$WITH_BOTTLE" - -# --- Case B: fixture with NO bottle block — must come out otherwise identical -NO_BOTTLE="$WORKDIR/no-bottle.rb" -cp "$FIXTURES/rook-formula-no-bottle.rb" "$WORKDIR/no-bottle.orig.rb" -cp "$FIXTURES/rook-formula-no-bottle.rb" "$NO_BOTTLE" -run_transform "$NO_BOTTLE" "$VERSION" "$TARBALL_URL" "$SHA256" - -check "(c) url replaced (no-bottle fixture)" \ - "(c) url NOT replaced (no-bottle fixture)" \ - grep -qF "url \"$TARBALL_URL\"" "$NO_BOTTLE" -check "(c) sha256 replaced (no-bottle fixture)" \ - "(c) sha256 NOT replaced (no-bottle fixture)" \ - grep -qF "sha256 \"$SHA256\"" "$NO_BOTTLE" -check "(c) version replaced (no-bottle fixture)" \ - "(c) version NOT replaced (no-bottle fixture)" \ - grep -qF "version \"$VERSION\"" "$NO_BOTTLE" - -# Diff against the pre-patch original, excluding nothing: only the 3 patched -# lines may differ. 3 changed lines => 6 diff lines (3 "<" + 3 ">"). Any -# other count means the bottle-strip regex touched something it shouldn't -# have even though there was no bottle block present. -DIFF_LINES=$(diff "$WORKDIR/no-bottle.orig.rb" "$NO_BOTTLE" | grep -c '^[<>]' || true) -if [ "$DIFF_LINES" -eq 6 ]; then - pass "(c) no-bottle fixture unchanged apart from the 3 patched lines" -else - fail "(c) no-bottle fixture changed more than expected ($DIFF_LINES diff lines, expected 6) — the strip regex may be eating something with no bottle block present" -fi - -echo -if [ "$FAIL" -ne 0 ]; then - echo "=== formula-patch transform test: FAILED ===" - exit 1 -fi -echo "=== formula-patch transform test: PASSED ===" diff --git a/scripts/test-install-fixture.sh b/scripts/test-install-fixture.sh index 246ec89..e3375ec 100755 --- a/scripts/test-install-fixture.sh +++ b/scripts/test-install-fixture.sh @@ -36,7 +36,7 @@ # Usage: scripts/test-install-fixture.sh # shellcheck disable=SC1090 # install.sh is sourced via a computed path below set -uo pipefail -# (Deliberately no -e — same reasoning as scripts/test-bottle-insert.sh: +# (Deliberately no -e — same reasoning as homebrew-rook's test-bottle-insert.sh: # several checks here run install.sh expecting it to FAIL, and every # check after that must still run and report on its own merits.) diff --git a/scripts/test-platform-detect.sh b/scripts/test-platform-detect.sh index 8d253f9..9937532 100755 --- a/scripts/test-platform-detect.sh +++ b/scripts/test-platform-detect.sh @@ -18,7 +18,7 @@ # ... ) rather than sourcing once at top level — install.sh does `set -euo # pipefail`, and sourcing it directly into this script's top-level shell # would leak those options into the rest of this test harness (which, -# like its siblings test-formula-patch.sh/test-bottle-insert.sh, needs to +# like the formula harnesses now in LambdaTest/homebrew-rook, needs to # keep running after a deliberately-failing case). # # Usage: scripts/test-platform-detect.sh diff --git a/scripts/test-runtime-version-poll.sh b/scripts/test-runtime-version-poll.sh deleted file mode 100755 index bcafd84..0000000 --- a/scripts/test-runtime-version-poll.sh +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env bash -# Regression test for update-formula.yml's "Wait for npm package -# availability" step's runtime-version derivation. -# -# The bug this guards against: @testmuai/rook publishes at $VERSION (the -# CLI's own semver, e.g. "0.1.0"). The @testmuai/rook-node-* runtime -# packages do NOT — they version independently, pinned to a Node.js -# release via scripts/ci/node-runtime.json in the private repo (e.g. -# "24.19.0"). A prior version of this step polled all three packages under -# the same $VERSION; on every real release (where the two never match), -# the two rook-node-* checks would 404 forever and the job would fail -# after a silent 2-minute timeout, long after npm/curl/changelog already -# shipped — Homebrew would never actually get updated. -# -# The fix: wait for @testmuai/rook@$VERSION first, then read the real -# runtime version out of ITS OWN published package.json -# (optionalDependencies["@testmuai/rook-node-"]), then poll the -# runtime packages under THAT version instead. -# -# Same "extract the real thing, never hand-copy it" discipline as its -# siblings: this test pulls the step body live out of the committed -# workflow file and runs it, with a stub `curl` on PATH standing in for -# the npm registry. -# -# Usage: scripts/test-runtime-version-poll.sh -set -uo pipefail -# (No -e: several cases run the step expecting it to FAIL, and every later -# check must still run on its own merits.) - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WF_FILE="$REPO_ROOT/.github/workflows/update-formula.yml" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -FAIL=0 -pass() { echo "PASS: $1"; } -fail() { echo "FAIL: $1"; FAIL=1; } - -if [ ! -f "$WF_FILE" ]; then - echo "FATAL: $WF_FILE not found" >&2 - exit 2 -fi - -# step_body FILE STEPNAME: print the run: block belonging to the step -# called STEPNAME, dedented by its own first line's indent, stopped at the -# first line indented less than that. -step_body() { - awk -v step="- name: $2" ' - index($0, step) { instep = 1; next } - instep && !inrun && /run:[[:space:]]*[|>]/ { inrun = 1; next } - inrun { - if ($0 ~ /^[[:space:]]*$/) { print ""; next } - match($0, /^ */); ind = RLENGTH - if (base == 0) base = ind - if (ind < base) exit - print substr($0, base + 1) - } - ' "$1" -} - -BODY="$(step_body "$WF_FILE" "Wait for npm package availability")" -if [ -z "$BODY" ]; then - echo "FATAL: could not extract 'Wait for npm package availability' step body from $WF_FILE — has the step name changed?" >&2 - exit 2 -fi -if ! printf '%s' "$BODY" | grep -q 'optionalDependencies'; then - echo "FATAL: extracted step body does not reference optionalDependencies — extraction markers are stale, or the fix regressed" >&2 - exit 2 -fi - -# --- Stub curl ----------------------------------------------------------- -# Two call shapes to distinguish: -# 1. Availability check: curl -s --max-time 10 -o /dev/null -w "%{http_code}" URL -# -> print a status code to stdout, nothing else (matches real curl -w). -# 2. Metadata fetch: curl -fsSL URL -# -> print a fixture JSON body to stdout; exit nonzero on a configured miss. -# -# STATUS_MAP ($WORKDIR/status_map): lines of "pkg|version|code". A pkg/version -# pair not listed defaults to 404 (matches a real unpublished package). -# META_BODY ($WORKDIR/meta_body.json): the package.json to hand back for the -# metadata fetch. -STUB_BIN="$WORKDIR/stub-bin" -mkdir -p "$STUB_BIN" -STATUS_MAP="$WORKDIR/status_map" -META_BODY="$WORKDIR/meta_body.json" -: >"$STATUS_MAP" -: >"$META_BODY" -cat >"$STUB_BIN/curl" <<'STUB' -#!/usr/bin/env bash -set -euo pipefail -: "${ROOK_TEST_STATUS_MAP:?}" -: "${ROOK_TEST_META_BODY:?}" - -has_w=0 -url="" -for arg in "$@"; do - case "$arg" in - "%{http_code}") has_w=1 ;; - https://*) url="$arg" ;; - esac -done - -if [ "$has_w" = "1" ]; then - # Availability check: last path segment is the version, the rest - # (after the host) is the package name — correct even though scoped - # package names contain their own "/", since only the LAST slash - # separates the version. - path="${url#https://registry.npmjs.org/}" - ver="${path##*/}" - pkg="${path%/*}" - code=$(awk -F'|' -v p="$pkg" -v v="$ver" '$1==p && $2==v {print $3; found=1} END{if(!found) print "404"}' "$ROOK_TEST_STATUS_MAP") - printf '%s' "$code" - exit 0 -fi - -# Metadata fetch (-fsSL, no -w): fail like a real 404 if the map says so -# for this exact package/version, else hand back the fixture body. -path="${url#https://registry.npmjs.org/}" -ver="${path##*/}" -pkg="${path%/*}" -code=$(awk -F'|' -v p="$pkg" -v v="$ver" '$1==p && $2==v {print $3; found=1} END{if(!found) print "200"}' "$ROOK_TEST_STATUS_MAP") -if [ "$code" != "200" ]; then - echo "stub-curl: simulated ${code} fetching ${url}" >&2 - exit 22 -fi -cat "$ROOK_TEST_META_BODY" -STUB -chmod +x "$STUB_BIN/curl" - -run_step() { # run_step VERSION [ENV_OVERRIDE...] - # "$@" (the optional RETRY_COUNT=/RETRY_SLEEP= overrides) is expanded at - # runtime, so bash's parse-time prefix-assignment recognition never - # applies to it — routing it through `env` instead is what actually - # sets those variables, rather than bash trying to execute - # "RETRY_COUNT=2" as a program name. - local version="$1" - shift - ( PATH="$STUB_BIN:$PATH" \ - env ROOK_TEST_STATUS_MAP="$STATUS_MAP" \ - ROOK_TEST_META_BODY="$META_BODY" \ - VERSION="$version" \ - "$@" \ - bash --noprofile --norc -e -c "$BODY" ) 2>&1 -} - -# ============================================================================= -# (a) Happy path: CLI version and runtime version genuinely differ -# ============================================================================= -cat >"$STATUS_MAP" <<'MAP' -@testmuai/rook|0.1.0|200 -@testmuai/rook-node-darwin-arm64|24.19.0|200 -@testmuai/rook-node-linux-x64|24.19.0|200 -MAP -cat >"$META_BODY" <<'JSON' -{"name":"@testmuai/rook","version":"0.1.0","optionalDependencies":{"@testmuai/rook-node-darwin-arm64":"24.19.0","@testmuai/rook-node-darwin-x64":"24.19.0","@testmuai/rook-node-linux-x64":"24.19.0","@testmuai/rook-node-linux-arm64":"24.19.0","@testmuai/rook-node-win-x64":"24.19.0"}} -JSON -OUT="$(run_step "0.1.0")" -RC=$? -if [ "$RC" = "0" ] && echo "$OUT" | grep -q "Resolved runtime version: 24.19.0"; then - pass "(a) resolves the real runtime version (24.19.0) when it differs from the CLI version (0.1.0)" -else - fail "(a) did not resolve the runtime version correctly — output: -$OUT" -fi - -# ============================================================================= -# (b) Regression: polling the runtime packages under the CLI's own version -# (the original bug) must NOT be what this step does — prove the fixture -# above would have failed under the old, wrong query. -# ============================================================================= -if awk -F'|' '$1=="@testmuai/rook-node-darwin-arm64" && $2=="0.1.0"' "$STATUS_MAP" | grep -q .; then - fail "(b) fixture setup itself is wrong — rook-node-darwin-arm64 must NOT be registered under the CLI version" -else - pass "(b) fixture is honest: rook-node-darwin-arm64 is only registered under the real runtime version (24.19.0), not the CLI version (0.1.0) — case (a) passing on this fixture is a genuine test of the fix, not an accident of the map matching everything" -fi - -# ============================================================================= -# (c) optionalDependencies missing the runtime-package key entirely -# ============================================================================= -cat >"$STATUS_MAP" <<'MAP' -@testmuai/rook|0.1.0|200 -MAP -cat >"$META_BODY" <<'JSON' -{"name":"@testmuai/rook","version":"0.1.0","optionalDependencies":{}} -JSON -OUT="$(run_step "0.1.0")" -RC=$? -if [ "$RC" != "0" ] && echo "$OUT" | grep -q "could not resolve a valid runtime version"; then - pass "(c) missing optionalDependencies entry is rejected with a clear error, not silently skipped" -else - fail "(c) missing optionalDependencies entry was not rejected as expected — exit=$RC, output: -$OUT" -fi - -# ============================================================================= -# (d) optionalDependencies present but malformed (not a plain X.Y.Z) -# ============================================================================= -cat >"$META_BODY" <<'JSON' -{"name":"@testmuai/rook","version":"0.1.0","optionalDependencies":{"@testmuai/rook-node-darwin-arm64":"latest"}} -JSON -OUT="$(run_step "0.1.0")" -RC=$? -if [ "$RC" != "0" ] && echo "$OUT" | grep -q "could not resolve a valid runtime version"; then - pass "(d) a non-semver-ish runtime version ('latest') is rejected, not passed through to the next poll" -else - fail "(d) malformed runtime version was not rejected as expected — exit=$RC, output: -$OUT" -fi - -# ============================================================================= -# (e) @testmuai/rook itself never becomes available — must fail closed, -# fast (RETRY_COUNT/RETRY_SLEEP overrides), without ever reaching the -# metadata fetch. -# ============================================================================= -: >"$STATUS_MAP" -OUT="$(run_step "0.1.0" RETRY_COUNT=2 RETRY_SLEEP=0)" -RC=$? -if [ "$RC" != "0" ] && echo "$OUT" | grep -q "@testmuai/rook@0.1.0 not visible"; then - pass "(e) @testmuai/rook never becoming available fails closed with the right message" -else - fail "(e) unavailable @testmuai/rook did not fail as expected — exit=$RC, output: -$OUT" -fi - -# ============================================================================= -# (f) @testmuai/rook available, runtime resolves, but a runtime package -# never becomes available — must fail closed, fast. -# ============================================================================= -cat >"$STATUS_MAP" <<'MAP' -@testmuai/rook|0.1.0|200 -MAP -cat >"$META_BODY" <<'JSON' -{"name":"@testmuai/rook","version":"0.1.0","optionalDependencies":{"@testmuai/rook-node-darwin-arm64":"24.19.0"}} -JSON -OUT="$(run_step "0.1.0" RETRY_COUNT=2 RETRY_SLEEP=0)" -RC=$? -if [ "$RC" != "0" ] && echo "$OUT" | grep -q "@testmuai/rook-node-darwin-arm64@24.19.0 not visible"; then - pass "(f) a runtime package never publishing fails closed with the right message" -else - fail "(f) unavailable runtime package did not fail as expected — exit=$RC, output: -$OUT" -fi - -echo "" -if [ "$FAIL" = "0" ]; then - echo "=== runtime-version-poll transform test: PASSED ===" - exit 0 -else - echo "=== runtime-version-poll transform test: FAILED ===" - exit 1 -fi diff --git a/scripts/test-workflow-injection.sh b/scripts/test-workflow-injection.sh deleted file mode 100755 index 9ee709d..0000000 --- a/scripts/test-workflow-injection.sh +++ /dev/null @@ -1,502 +0,0 @@ -#!/usr/bin/env bash -# Regression test for shell injection through the version inputs of the -# four release workflows — .github/workflows/update-formula.yml, -# build-bottles.yml, brew-smoke.yml and brew-smoke-intel.yml — and for what -# those steps do with a version value once it is safely inert data. -# -# Why this exists. GitHub Actions expands `${{ ... }}` into the run: script -# BEFORE bash ever parses it. A value spliced straight into a run: body -# therefore arrives as shell *syntax*, not as a string: quotes end the -# surrounding quoting, `$(...)` and backticks execute, `;` starts a new -# command. Every one of these workflows' version inputs is attacker-shaped: -# update-formula.yml takes one over a repo_dispatch trust boundary and -# holds contents:write + actions:write on the tap's main branch; -# build-bottles.yml takes one from workflow_dispatch and splices it into -# its guard job BEFORE the guard's own drift comparison can run; -# brew-smoke.yml puts one inside a `case` pattern, which a payload can -# close early, and (as of the brew-smoke hardening) also compares one -# against Formula/rook.rb's own version in its guard job before the matrix -# runs; brew-smoke-intel.yml repeats that same guard-shaped comparison -# inline. The fix, uniformly, is to route the value through the step's -# `env:` block and reference "$VAR" in the body. -# -# What this harness checks, in the same "extract the real thing, never -# hand-copy it" discipline as its sibling scripts: -# -# 1. A structural invariant over ALL of the run: bodies in ALL four -# files: not one of them may contain a `${{ ... }}` expression. This -# is the check that catches a re-introduced splice at a site nobody -# thought to write a case for. -# 2. Per-site behaviour: the real step body, extracted live, is executed -# with a hostile value in the env var it now reads, and must neither -# execute anything nor accept the value. -# 3. A positive control for each of those: the same extracted body with -# the env var's *reference* textually replaced by the payload — which -# is precisely what Actions used to do — must execute the payload. A -# test that cannot fail proves nothing, and this one demonstrates the -# vulnerability it is guarding against on every run. -# 4. The ordinary data path still works: valid versions resolve, drifted -# and malformed ones are rejected with the intended message, and a -# prerelease root_url is not misread as stale. -# -# Usage: scripts/test-workflow-injection.sh -set -uo pipefail -# (No -e: several cases run a step expecting it to FAIL, and every later -# check must still run on its own merits — same reasoning as -# scripts/test-bottle-insert.sh.) - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WF="$REPO_ROOT/.github/workflows" -FIXTURES="$REPO_ROOT/scripts/fixtures" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -FAIL=0 -pass() { echo "PASS: $1"; } -fail() { echo "FAIL: $1"; FAIL=1; } -check() { - local ok_msg="$1" fail_msg="$2" - shift 2 - if "$@"; then pass "$ok_msg"; else fail "$fail_msg"; fi -} -contains() { printf '%s' "$2" | grep -qF -- "$1"; } - -for f in update-formula.yml build-bottles.yml brew-smoke.yml brew-smoke-intel.yml; do - if [ ! -f "$WF/$f" ]; then - echo "FATAL: $WF/$f not found" >&2 - exit 2 - fi -done - -# ============================================================================= -# Helpers -# ============================================================================= - -# run_body_lines FILE: print every line that lives inside a `run:` block -# scalar, prefixed with its line number. Handles both `run: |` under a -# named step and the bare `- run: |` form brew-smoke.yml uses. A block ends -# at the first non-blank line indented less than the block's first line. -run_body_lines() { - awk ' - function is_block_run(l) { return l ~ /^[[:space:]]*(- )?run:[[:space:]]*[|>][-+]?[[:space:]]*$/ } - { - if (inrun) { - if ($0 ~ /^[[:space:]]*$/) next - match($0, /^ */); ind = RLENGTH - if (base == 0) base = ind - if (ind >= base) { print FILENAME ":" NR ":" $0; next } - inrun = 0 - } - if (is_block_run($0)) { inrun = 1; base = 0; next } - # A single-line `run: something` is just as injectable as a block. - if ($0 ~ /^[[:space:]]*(- )?run:[[:space:]]*[^|>[:space:]]/) print FILENAME ":" NR ":" $0 - } - ' "$1" -} - -# step_body FILE STEPNAME [OCCURRENCE]: print the run: block belonging to -# the OCCURRENCE'th step called STEPNAME, dedented by its own first line's -# indent and stopped at the first line indented less than that (so a -# following step, or a YAML comment above it, cannot leak in). -step_body() { - awk -v step="- name: $2" -v want="${3:-1}" ' - index($0, step) { n++; if (n == want) instep = 1; next } - instep && !inrun && /run:[[:space:]]*[|>]/ { inrun = 1; next } - inrun { - if ($0 ~ /^[[:space:]]*$/) { print ""; next } - match($0, /^ */); ind = RLENGTH - if (base == 0) base = ind - if (ind < base) exit - print substr($0, base + 1) - } - ' "$1" -} - -# splice BODY VAR VALUE: rebuild the pre-fix, vulnerable form of a step by -# replacing the env var's *reference* with VALUE, exactly as the Actions -# runner replaced `${{ ... }}` with it. Used only for positive controls. -splice() { - local body="$1" var="$2" value="$3" - body="${body//\$\{$var\}/$value}" - body="${body//\$$var/$value}" - printf '%s' "$body" -} - -# GitHub's default shell for a `run:` step that does not set `shell:` — as -# none of these do — is `bash -e {0}`. pipefail is NOT on by default; it is -# only added when a step opts in with `shell: bash`. So `-e` alone is what -# these bodies really run under, and that is what is reproduced here. -# (--noprofile --norc are ours: they keep the run from picking up a -# developer's BASH_ENV, and change nothing about how the body behaves.) -# -# The distinction matters for the guard's STALE_ROOT pipeline, whose -# trailing `|| true` is what makes it safe either way; these cases were run -# under both settings and behave identically. -# ROOK_TEST_SHELL_OPTS overrides the options, so the "identical under both" -# claim above can be re-checked rather than taken on trust: -# ROOK_TEST_SHELL_OPTS="-eo pipefail" scripts/test-workflow-injection.sh -read -r -a SHELL_OPTS <<<"${ROOK_TEST_SHELL_OPTS:--e}" - -run_step() { # run_step CWD SCRIPT [VAR=VAL ...] - local cwd="$1" script="$2" - shift 2 - ( cd "$cwd" && env "$@" GITHUB_OUTPUT="$WORKDIR/github_output" \ - bash --noprofile --norc "${SHELL_OPTS[@]}" -c "$script" ) 2>&1 -} - -MARK="$WORKDIR/PWNED" -marker_absent() { [ ! -e "$MARK" ]; } -reset_marker() { rm -f "$MARK"; } - -# The payload corpus. `a`/`b` are the two shapes that reach a command in -# any double-quoted context; `c` escapes a `case` pattern list; `d`/`e` are -# the plain metacharacter and newline shapes; the last two are the empty -# and blank inputs from the standing hostile-input corpus, which are not -# injections but are the other way a version field goes wrong. -declare -a PAYLOAD_KEYS=(cmdsub backtick quotebreak caseescape semicolon andand newline empty blank) -# shellcheck disable=SC2016 # the un-expanded shell syntax IS the payload -payload() { - case "$1" in - cmdsub) printf '$(touch %s)' "$MARK" ;; - backtick) printf '`touch %s`' "$MARK" ;; - quotebreak) printf '"; touch %s; [ -n "x' "$MARK" ;; - caseescape) printf 'x"*) ;; *) touch %s ;; esac #' "$MARK" ;; - semicolon) printf '0.1.0; touch %s' "$MARK" ;; - andand) printf '0.1.0 && touch %s' "$MARK" ;; - newline) printf '0.1.0\ntouch %s' "$MARK" ;; - empty) printf '' ;; - blank) printf ' ' ;; - esac -} - -# ============================================================================= -# 1. Structural invariant: no ${{ }} anywhere inside any run: body -# ============================================================================= -SPLICES="" -for f in update-formula.yml build-bottles.yml brew-smoke.yml brew-smoke-intel.yml; do - # shellcheck disable=SC2016 # the literal Actions sigil is what we hunt for - hits="$(run_body_lines "$WF/$f" | grep -F -- '${{' || true)" - if [ -n "$hits" ]; then - SPLICES="${SPLICES}${hits}"$'\n' - fi -done -if [ -n "$SPLICES" ]; then - fail "(1) a run: body contains a \${{ ... }} expression — Actions expands those before bash parses the script, so the value becomes live shell syntax. Route it through the step's env: block: -$SPLICES" -else - pass "(1) no run: body in any of the 4 workflows contains a \${{ ... }} expression" -fi - -# Sanity-check the scanner itself against a synthetic offender: a scanner -# that matches nothing is a green light that checks nothing. -cat >"$WORKDIR/offender.yml" <<'YML' -jobs: - a: - steps: - - name: block form - run: | - echo "${{ inputs.version }}" - - run: echo "${{ inputs.version }}" -YML -# shellcheck disable=SC2016 # the literal Actions sigil is what we hunt for -OFFENDER_HITS="$(run_body_lines "$WORKDIR/offender.yml" | grep -cF -- '${{' || true)" -check "(1) the scanner finds both splice shapes in a synthetic offender (block and single-line)" \ - "(1) the scanner missed a splice in the synthetic offender (found $OFFENDER_HITS of 2) — it would not catch a real one either" \ - [ "$OFFENDER_HITS" -eq 2 ] - -# ============================================================================= -# 2/3. update-formula.yml — "Determine version" -# ============================================================================= -UF_BODY="$(step_body "$WF/update-formula.yml" "Determine version")" -if ! contains "INPUT_VERSION" "$UF_BODY"; then - echo "FATAL: could not extract 'Determine version' from update-formula.yml (or it no longer reads INPUT_VERSION)" >&2 - exit 2 -fi - -for key in "${PAYLOAD_KEYS[@]}"; do - p="$(payload "$key")" - reset_marker - OUT="$(run_step "$WORKDIR" "$UF_BODY" INPUT_VERSION="$p" DISPATCH_VERSION="")" - RC=$? - check "(2/uf/$key) hostile value executed nothing" \ - "(2/uf/$key) THE PAYLOAD RAN — update-formula.yml's Determine version step executed an injected command. output: -$OUT" \ - marker_absent - check "(2/uf/$key) hostile value rejected (exit $RC)" \ - "(2/uf/$key) hostile value was accepted (exit $RC) — output: -$OUT" \ - [ "$RC" -ne 0 ] - check "(2/uf/$key) rejection names the version as the problem" \ - "(2/uf/$key) rejection message doesn't name the version — got: $OUT" \ - contains "not a valid semver-ish string" "$OUT" -done - -# Positive control: the pre-fix shape must execute the payload. -for key in cmdsub quotebreak; do - p="$(payload "$key")" - reset_marker - VULN="$(splice "$UF_BODY" INPUT_VERSION "$p")" - run_step "$WORKDIR" "$VULN" DISPATCH_VERSION="" >/dev/null 2>&1 - check "(3/uf/$key) positive control: the pre-fix spliced form DOES execute the payload" \ - "(3/uf/$key) positive control failed — the payload did not run even when spliced, so case (2/uf/$key) proves nothing" \ - [ -e "$MARK" ] -done -reset_marker - -# Data path: a valid version from either source resolves. -OUT="$(run_step "$WORKDIR" "$UF_BODY" INPUT_VERSION="0.1.0" DISPATCH_VERSION="")" -check "(4/uf) a valid workflow_dispatch version is accepted" \ - "(4/uf) a valid workflow_dispatch version was rejected — $OUT" \ - contains "version=0.1.0" "$(cat "$WORKDIR/github_output")" -: >"$WORKDIR/github_output" -OUT="$(run_step "$WORKDIR" "$UF_BODY" INPUT_VERSION="" DISPATCH_VERSION="1.2.3-beta.1")" -check "(4/uf) a valid prerelease from repository_dispatch is accepted" \ - "(4/uf) a valid prerelease from repository_dispatch was rejected — $OUT" \ - contains "version=1.2.3-beta.1" "$(cat "$WORKDIR/github_output")" - -# ============================================================================= -# 2/3. build-bottles.yml — guard / "Resolve version" -# ============================================================================= -BB_BODY="$(step_body "$WF/build-bottles.yml" "Resolve version")" -if ! contains "INPUT_VERSION" "$BB_BODY"; then - echo "FATAL: could not extract 'Resolve version' from build-bottles.yml (or it no longer reads INPUT_VERSION)" >&2 - exit 2 -fi - -# A checkout stand-in: the guard reads Formula/rook.rb out of the workspace. -GUARD="$WORKDIR/guard" -mkdir -p "$GUARD/Formula" -cp "$FIXTURES/rook-formula-with-bottle.rb" "$GUARD/Formula/rook.rb" - -for key in "${PAYLOAD_KEYS[@]}"; do - [ "$key" = "empty" ] && continue # blank input is the documented "use the formula's own version" path - p="$(payload "$key")" - reset_marker - OUT="$(run_step "$GUARD" "$BB_BODY" INPUT_VERSION="$p")" - RC=$? - check "(2/bb/$key) hostile value executed nothing" \ - "(2/bb/$key) THE PAYLOAD RAN — build-bottles.yml's guard executed an injected command BEFORE its own drift check could matter. output: -$OUT" \ - marker_absent - check "(2/bb/$key) hostile value rejected (exit $RC)" \ - "(2/bb/$key) hostile value was accepted (exit $RC) — output: -$OUT" \ - [ "$RC" -ne 0 ] -done - -# cmdsub/backtick rather than quotebreak here: the quote-break shape has to -# re-satisfy whatever syntax surrounds the splice point, and this step's -# first use of the value is inside `[[ ... =~ ... ]]`, where an unbalanced -# quote is a parse error before anything runs. The command-substitution -# shapes need no such tailoring — they execute in any double-quoted -# context, which is what makes them the realistic payload. -for key in cmdsub backtick; do - p="$(payload "$key")" - reset_marker - VULN="$(splice "$BB_BODY" INPUT_VERSION "$p")" - run_step "$GUARD" "$VULN" >/dev/null 2>&1 - check "(3/bb/$key) positive control: the pre-fix spliced form DOES execute the payload" \ - "(3/bb/$key) positive control failed — the payload did not run even when spliced, so case (2/bb/$key) proves nothing" \ - [ -e "$MARK" ] -done -reset_marker - -# Data path. -: >"$WORKDIR/github_output" -OUT="$(run_step "$GUARD" "$BB_BODY" INPUT_VERSION="")" -check "(4/bb) blank input falls back to the formula's own version" \ - "(4/bb) blank input did not resolve to the formula's version — $OUT" \ - contains "version=0.1.0" "$(cat "$WORKDIR/github_output")" - -OUT="$(run_step "$GUARD" "$BB_BODY" INPUT_VERSION="0.2.0")" -RC=$? -check "(4/bb) an input that disagrees with the formula is rejected" \ - "(4/bb) version drift was not rejected (exit $RC) — $OUT" \ - [ "$RC" -ne 0 ] -check "(4/bb) the drift rejection says so" \ - "(4/bb) the drift rejection didn't name the formula version — $OUT" \ - contains "but Formula/rook.rb on main is" "$OUT" - -# The formula-derived path is validated too. Resolving the version from -# Formula/rook.rb rather than from inputs.version must not skip the shape -# check: a hand edit to main would otherwise drive the tag name, the -# artifact glob, the generated Ruby, a commit and a workflow dispatch -# unchecked. (Before this was fixed, the same body answered a formula -# reading `version "not-semver"` with exit 0 and version=not-semver.) -BADFORM="$WORKDIR/guard-malformed" -mkdir -p "$BADFORM/Formula" -sed -e 's/version "0\.1\.0"/version "not-semver"/' \ - "$FIXTURES/rook-formula-no-bottle.rb" >"$BADFORM/Formula/rook.rb" -: >"$WORKDIR/github_output" -OUT="$(run_step "$BADFORM" "$BB_BODY" INPUT_VERSION="")" -RC=$? -check "(4/bb) a malformed version in the formula is rejected, not just a malformed input" \ - "(4/bb) the formula-derived path skipped validation (exit $RC) — output: $OUT" \ - [ "$RC" -ne 0 ] -check "(4/bb) that rejection names the version as the problem" \ - "(4/bb) the formula-derived rejection didn't name the version — $OUT" \ - contains "not a valid semver-ish string" "$OUT" -check "(4/bb) the malformed version never reaches GITHUB_OUTPUT" \ - "(4/bb) the malformed version was published to GITHUB_OUTPUT: $(cat "$WORKDIR/github_output")" \ - [ ! -s "$WORKDIR/github_output" ] - -# F12: a prerelease re-run. The stale-root guard used to match only -# `rook-`, so a root_url of rook-0.2.0-beta.1 was read as -# "rook-0.2.0", compared against the correct current version, and reported -# as a stale bottle block on a perfectly legitimate re-run. -PRE="$WORKDIR/guard-prerelease" -mkdir -p "$PRE/Formula" -sed -e 's/version "0\.1\.0"/version "0.2.0-beta.1"/' \ - -e 's|download/rook-0\.1\.0|download/rook-0.2.0-beta.1|' \ - "$FIXTURES/rook-formula-with-bottle.rb" >"$PRE/Formula/rook.rb" -: >"$WORKDIR/github_output" -OUT="$(run_step "$PRE" "$BB_BODY" INPUT_VERSION="0.2.0-beta.1")" -RC=$? -check "(4/bb) a prerelease re-run is not misread as a stale bottle block" \ - "(4/bb) a prerelease root_url was reported stale (exit $RC) — $OUT" \ - [ "$RC" -eq 0 ] -check "(4/bb) the prerelease version is what gets published" \ - "(4/bb) the prerelease version did not reach GITHUB_OUTPUT — $(cat "$WORKDIR/github_output")" \ - contains "version=0.2.0-beta.1" "$(cat "$WORKDIR/github_output")" - -# ... and a genuinely stale block is still caught (positive control for the -# widened regex: it must not have widened into matching everything). -STALE="$WORKDIR/guard-stale" -mkdir -p "$STALE/Formula" -sed -e 's/version "0\.1\.0"/version "0.2.0"/' \ - "$FIXTURES/rook-formula-with-bottle.rb" >"$STALE/Formula/rook.rb" -OUT="$(run_step "$STALE" "$BB_BODY" INPUT_VERSION="0.2.0")" -RC=$? -check "(4/bb) a genuinely stale bottle block is still caught" \ - "(4/bb) a stale bottle block was NOT caught (exit $RC) — the widened regex matches too much. output: -$OUT" \ - [ "$RC" -ne 0 ] -check "(4/bb) the stale-block rejection names the stale tag" \ - "(4/bb) the stale-block rejection didn't name the stale tag — $OUT" \ - contains "stale bottle block referencing rook-0.1.0" "$OUT" - -# ============================================================================= -# 2/3. brew-smoke.yml — "Check installed version", both jobs -# ============================================================================= -BS_BODY="$(step_body "$WF/brew-smoke.yml" "Check installed version" 1)" -BS_BODY2="$(step_body "$WF/brew-smoke.yml" "Check installed version" 2)" -if ! contains "EXPECTED_VERSION" "$BS_BODY"; then - echo "FATAL: could not extract 'Check installed version' from brew-smoke.yml (or it no longer reads EXPECTED_VERSION)" >&2 - exit 2 -fi -check "(2/bs) both jobs' version checks are byte-identical" \ - "(2/bs) the macos-arm and linux-x64 version checks have drifted apart — only the first is covered below" \ - [ "$BS_BODY" = "$BS_BODY2" ] - -# `rook` stands in for the installed CLI: the step under test is the -# comparison, not the install. -SMOKE_BIN="$WORKDIR/smoke-bin" -mkdir -p "$SMOKE_BIN" -printf '#!/usr/bin/env bash\necho "rook 0.1.0"\n' >"$SMOKE_BIN/rook" -chmod +x "$SMOKE_BIN/rook" - -for key in "${PAYLOAD_KEYS[@]}"; do - p="$(payload "$key")" - reset_marker - OUT="$(run_step "$WORKDIR" "$BS_BODY" PATH="$SMOKE_BIN:$PATH" EXPECTED_VERSION="$p")" - RC=$? - check "(2/bs/$key) hostile value executed nothing" \ - "(2/bs/$key) THE PAYLOAD RAN — brew-smoke.yml's case pattern executed an injected command. output: -$OUT" \ - marker_absent - # empty/blank legitimately substring-match anything, so they are the one - # pair that may pass the comparison; every other payload must not. - if [ "$key" != "empty" ] && [ "$key" != "blank" ]; then - check "(2/bs/$key) hostile value did not satisfy the version check (exit $RC)" \ - "(2/bs/$key) hostile value satisfied the version check (exit $RC) — $OUT" \ - [ "$RC" -ne 0 ] - fi -done - -for key in cmdsub caseescape; do - p="$(payload "$key")" - reset_marker - VULN="$(splice "$BS_BODY" EXPECTED_VERSION "$p")" - run_step "$WORKDIR" "$VULN" PATH="$SMOKE_BIN:$PATH" >/dev/null 2>&1 - check "(3/bs/$key) positive control: the pre-fix spliced form DOES execute the payload" \ - "(3/bs/$key) positive control failed — the payload did not run even when spliced, so case (2/bs/$key) proves nothing" \ - [ -e "$MARK" ] -done -reset_marker - -OUT="$(run_step "$WORKDIR" "$BS_BODY" PATH="$SMOKE_BIN:$PATH" EXPECTED_VERSION="0.1.0")" -RC=$? -check "(4/bs) the matching version passes the check" \ - "(4/bs) the matching version failed the check (exit $RC) — $OUT" \ - [ "$RC" -eq 0 ] - -# ============================================================================= -# 2/3. brew-smoke.yml — guard job / "Verify expected version matches -# Formula/rook.rb on main". brew-smoke-intel.yml carries an inline copy of -# the same comparison shape (env-routed EXPECTED_VERSION vs. a value read -# out of Formula/rook.rb) — covered structurally by check (1) above rather -# than duplicated here, since the injection surface (one [ "$X" != "$Y" ] -# comparison, no other use of the value) is identical. -# ============================================================================= -GUARD_BODY="$(step_body "$WF/brew-smoke.yml" "Verify expected version matches Formula/rook.rb on main")" -if ! contains "EXPECTED_VERSION" "$GUARD_BODY"; then - echo "FATAL: could not extract the guard job's version-check step from brew-smoke.yml (or it no longer reads EXPECTED_VERSION)" >&2 - exit 2 -fi - -GUARD_BS="$WORKDIR/guard-bs" -mkdir -p "$GUARD_BS/Formula" -cp "$FIXTURES/rook-formula-no-bottle.rb" "$GUARD_BS/Formula/rook.rb" - -# Unlike brew-smoke.yml's substring `case` check, this step does an exact -# string comparison against the fixture's "0.1.0" — every payload, -# including empty/blank, is a legitimate non-match here, so none are -# skipped. -for key in "${PAYLOAD_KEYS[@]}"; do - p="$(payload "$key")" - reset_marker - OUT="$(run_step "$GUARD_BS" "$GUARD_BODY" EXPECTED_VERSION="$p")" - RC=$? - check "(2/gbs/$key) hostile value executed nothing" \ - "(2/gbs/$key) THE PAYLOAD RAN — brew-smoke.yml's guard job executed an injected command. output: -$OUT" \ - marker_absent - check "(2/gbs/$key) hostile value rejected as a version mismatch (exit $RC)" \ - "(2/gbs/$key) hostile value was accepted (exit $RC) — output: -$OUT" \ - [ "$RC" -ne 0 ] -done - -for key in cmdsub backtick; do - p="$(payload "$key")" - reset_marker - VULN="$(splice "$GUARD_BODY" EXPECTED_VERSION "$p")" - run_step "$GUARD_BS" "$VULN" >/dev/null 2>&1 - check "(3/gbs/$key) positive control: the pre-fix spliced form DOES execute the payload" \ - "(3/gbs/$key) positive control failed — the payload did not run even when spliced, so case (2/gbs/$key) proves nothing" \ - [ -e "$MARK" ] -done -reset_marker - -: >"$WORKDIR/github_output" -OUT="$(run_step "$GUARD_BS" "$GUARD_BODY" EXPECTED_VERSION="0.1.0")" -check "(4/gbs) the matching version passes and is published" \ - "(4/gbs) the matching version did not pass or publish — $OUT / $(cat "$WORKDIR/github_output")" \ - contains "version=0.1.0" "$(cat "$WORKDIR/github_output")" - -: >"$WORKDIR/github_output" -OUT="$(run_step "$GUARD_BS" "$GUARD_BODY" EXPECTED_VERSION="0.2.0")" -RC=$? -check "(4/gbs) a drifted version is rejected" \ - "(4/gbs) a drifted version was NOT rejected (exit $RC) — $OUT" \ - [ "$RC" -ne 0 ] -check "(4/gbs) the drift rejection names the formula version" \ - "(4/gbs) the drift rejection didn't name the formula version — $OUT" \ - contains "but Formula/rook.rb on main is" "$OUT" - -echo -if [ "$FAIL" -ne 0 ]; then - echo "=== workflow-injection test: FAILED ===" - exit 1 -fi -echo "=== workflow-injection test: PASSED ==="