Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
schema_version: 1
id: "iss-2609260709386741"
slug: "site-yml-probes-for-the-site-verb-by-grepping-the-root-help"
severity: "major"
category: "bug"
source: "drift-detection"
found_during: "autonomous run A resumed 2026-09-25"
origin: researcher-authored
production_mode: hand-written
found_at: ".github/workflows/site.yml"
resolution: "site.yml's four probing steps read abcd --help --agent and fall back to abcd --help when the flag is refused, so a grouped-help binary, a pre-grouping binary with the site verb, and a binary from before the site slice are each judged correctly; TestSiteWorkflowProbeFindsTheSiteVerb runs every probing step against all three shapes plus a grouped binary with the verb withdrawn."
impact: fix
resolved_by:
commit: "858754f5"
---

site.yml probes for the site verb by grepping the ROOT help listing (./abcd --help | grep -qE '^[[:space:]]+site[[:space:]]', four places: the production render step, its check step, the preview build step and the preview check step). Since the grouped root help landed (#711, merge a3cdf26e) abcd --help lists only the human-facing verbs and site appears only under abcd --help --agent, so every site run on a binary built after a3cdf26e refuses with 'this binary has no site verb': the preview job has failed on every push to main since (run 36225049347 on 22997314 and the eight before it), and the production render the release chain calls after publishing v0.11.0 refuses the same way, leaving abcdev.app unrendered for the release. The probe must read the agent listing where the binary has one and the flat listing where it does not (v0.6.2 to v0.10.0 carry site but refuse --agent as an unknown flag), and still refuse, in one line, a binary built before the site slice.

## Grounds

- pursued: the next preview run on main and the v0.11.0 production render reach abcd site build instead of refusing; a site run on a v0.11.0-or-later binary that still prints 'this binary has no site verb', or a dispatch of v0.10.0 that refuses, would show it wrong
47 changes: 33 additions & 14 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,22 @@ jobs:
# A bare `abcd site` does exit 2 today, but it would EXECUTE the verb once
# the verb exists, so it is not a probe either. The listing is the public
# surface, costs nothing, has no side effects, and names `site` exactly when
# the verb is registered. Verified in both directions against a binary built
# before the site slice.
./abcd --help 2>/dev/null | grep -qE '^[[:space:]]+site[[:space:]]' || {
# the verb is registered.
#
# WHICH listing depends on the tag. From v0.11.0 the root help is grouped:
# `abcd --help` names only the person's verbs, and `site` is listed under
# `abcd --help --agent` alone, so the plain listing misses it on every
# binary that has it. A tag before v0.11.0 refuses `--agent` as an unknown
# flag (exit 2, nothing on stdout) and lists every verb in the plain
# listing. So read the agent listing, and fall back to the plain one when
# the flag is refused: a binary from before the site slice passes neither,
# and still refuses here in one line. Captured in a variable, not piped, so
# neither the fallback nor grep's early exit meets pipefail. Verified
# against binaries built from v0.6.1 (no verb), v0.10.0 (plain listing)
# and v0.11.0 (agent listing); internal/surface/cli's
# TestSiteWorkflowProbeFindsTheSiteVerb runs every probing step.
listing="$(./abcd --help --agent 2>/dev/null || ./abcd --help 2>/dev/null || true)"
grep -qE '^[[:space:]]+site[[:space:]]' <<<"$listing" || {
echo "::error::this binary has no site verb (released before the site slice); refusing — see PR #421"
exit 1
}
Expand Down Expand Up @@ -468,9 +481,11 @@ jobs:
# runs AFTER the docs build so it sees the whole tree that will be deployed.
run: |
set -euo pipefail
# The root-listing probe again; see the render step above for why neither
# `abcd site --help` nor `abcd site build --help` can serve as one.
./abcd --help 2>/dev/null | grep -qE '^[[:space:]]+site[[:space:]]' || {
# The listing probe again (the agent listing, else the plain one); see the
# render step above for why that pair, and why neither `abcd site --help`
# nor `abcd site build --help` can serve as one.
listing="$(./abcd --help --agent 2>/dev/null || ./abcd --help 2>/dev/null || true)"
grep -qE '^[[:space:]]+site[[:space:]]' <<<"$listing" || {
echo "::error::this binary has no site verb (released before the site slice); refusing — see PR #421"
exit 1
}
Expand Down Expand Up @@ -757,12 +772,14 @@ jobs:
# one. The workflow therefore passes no version anywhere on this path.
run: |
set -euo pipefail
# Probe before use, as in `render` — same root-listing grep, and see that
# step for why the obvious `--help` forms all pass on a verb-less binary.
# main carries the site verb now, so this is a standing guard rather than
# a window: it fires if the verb is ever renamed or withdrawn, and turns
# what would be a cobra usage dump mid-render into one line naming why.
./abcd --help 2>/dev/null | grep -qE '^[[:space:]]+site[[:space:]]' || {
# Probe before use, as in `render` — same listing grep (the agent listing,
# else the plain one), and see that step for why that pair, and why the
# obvious `--help` forms all pass on a verb-less binary. main carries the
# site verb now, so this is a standing guard rather than a window: it
# fires if the verb is ever renamed or withdrawn, and turns what would be
# a cobra usage dump mid-render into one line naming why.
listing="$(./abcd --help --agent 2>/dev/null || ./abcd --help 2>/dev/null || true)"
grep -qE '^[[:space:]]+site[[:space:]]' <<<"$listing" || {
echo "::error::this binary has no site verb (built from a commit before the site slice); refusing — see PR #421"
exit 1
}
Expand All @@ -787,8 +804,10 @@ jobs:
# a preview that skipped them would stop being a preview of anything.
run: |
set -euo pipefail
# The root-listing probe again; see the render job for the full note.
./abcd --help 2>/dev/null | grep -qE '^[[:space:]]+site[[:space:]]' || {
# The listing probe again (the agent listing, else the plain one); see the
# render job for the full note.
listing="$(./abcd --help --agent 2>/dev/null || ./abcd --help 2>/dev/null || true)"
grep -qE '^[[:space:]]+site[[:space:]]' <<<"$listing" || {
echo "::error::this binary has no site verb (built from a commit before the site slice); refusing — see PR #421"
exit 1
}
Expand Down
160 changes: 160 additions & 0 deletions internal/surface/cli/siteprobe_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package cli

import (
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"

"github.com/intentdriven/abcd/internal/gittest"
)

// siteprobe_workflow_test.go holds iss-2609260709386741: site.yml probes for the
// `site` verb before it renders, and the probe read `abcd --help`. Once the root
// help was grouped (itd-146), that listing names only the person's verbs and `site`
// sits under `--help --agent`, so every site run refused a binary that has the
// verb. The test EXECUTES each probing step's own `run:` script out of the
// committed workflow against a stand-in `./abcd` that answers the help calls with
// the real command tree's output, so it judges what the step does, not its text.

const siteWorkflowPath = ".github/workflows/site.yml"

// siteProbeRefusal is the line every probing step prints when it refuses a
// binary; it is also how the test finds those steps.
const siteProbeRefusal = "::error::this binary has no site verb"

// siteProbeSteps returns the dedented `run: |` body of every site.yml step that
// probes for the site verb.
func siteProbeSteps(t *testing.T) []string {
t.Helper()
b, err := os.ReadFile(filepath.Join(testRepoRoot(), filepath.FromSlash(siteWorkflowPath)))
if err != nil {
t.Fatalf("read %s: %v", siteWorkflowPath, err)
}
lines := strings.Split(string(b), "\n")
indent := func(s string) int { return len(s) - len(strings.TrimLeft(s, " ")) }
var steps []string
for i, line := range lines {
if strings.TrimSpace(line) != "run: |" {
continue
}
key := indent(line)
body, bodyIndent := []string{}, -1
for _, l := range lines[i+1:] {
if strings.TrimSpace(l) == "" {
body = append(body, "")
continue
}
if indent(l) <= key {
break
}
if bodyIndent < 0 {
bodyIndent = indent(l)
}
body = append(body, l[bodyIndent:])
}
script := strings.Join(body, "\n") + "\n"
if strings.Contains(script, siteProbeRefusal) {
steps = append(steps, script)
}
}
return steps
}

// siteEntryRe is a listing line naming the site verb, the shape the probe greps.
var siteEntryRe = regexp.MustCompile(`(?m)^[ \t]+site[ \t].*\n`)

// siteProbeStub is the stand-in binary. agent.txt present means the binary knows
// `--agent`; absent, it refuses the flag the way a binary from before the grouped
// help does (verified against v0.10.0: exit 2, one line on stderr, nothing on
// stdout). Any call that is not a help call is a verb the step ran, logged.
const siteProbeStub = `#!/usr/bin/env bash
dir="$(cd "$(dirname "$0")" && pwd)"
case " $* " in
*" lint --help "*) cat "$dir/stub/lint.txt" ;;
*" --agent "*)
if [ -f "$dir/stub/agent.txt" ]; then cat "$dir/stub/agent.txt"
else echo "abcd: unknown flag: --agent" >&2; exit 2; fi ;;
" --help ") cat "$dir/stub/flat.txt" ;;
*) echo "$*" >> "$dir/stub/ran.log" ;;
esac
`

// TestSiteWorkflowProbeFindsTheSiteVerb runs every probing step against four
// binaries: this tree's (the verb only in the agent listing), one from before the
// grouped help (the verb in the flat listing, `--agent` unknown), one from before
// the site slice (no verb, `--agent` unknown), and a grouped one with the verb
// withdrawn. The first two must reach the verb; the last two must refuse in one
// line without running anything.
func TestSiteWorkflowProbeFindsTheSiteVerb(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash unavailable")
}
steps := siteProbeSteps(t)
// Four probing steps: render and its check (production), build and its check
// (preview). A different count means a step gained or lost a probe, and this
// test would silently judge the wrong set.
if len(steps) != 4 {
t.Fatalf("%s: found %d steps printing %q, want 4", siteWorkflowPath, len(steps), siteProbeRefusal)
}

flat, _ := executedHelp(t, "--help")
agent, _ := executedHelp(t, "--help", "--agent")
lint, _ := executedHelp(t, "lint", "--help")
if siteEntryRe.MatchString(flat) || !siteEntryRe.MatchString(agent) {
t.Fatalf("fixture premise broken: the site verb must be in the agent listing only\n--- --help:\n%s\n--- --help --agent:\n%s", flat, agent)
}
// Before the grouped help the flat listing named every verb; one line under a
// flat heading stands in for it.
legacyFlat := flat + "\nAvailable Commands:\n site Render the website\n"

for _, bin := range []struct {
name string
flat, agent string // agent "" = the binary refuses --agent
wantVerb bool
}{
{"this tree (grouped help)", flat, agent, true},
{"grouped help predates, site present (v0.6.2 to v0.10.0)", legacyFlat, "", true},
{"site slice predates (v0.6.1 and earlier)", flat, "", false},
{"grouped help, site withdrawn", flat, siteEntryRe.ReplaceAllString(agent, ""), false},
} {
for i, step := range steps {
t.Run(bin.name+"/step"+strconv.Itoa(i), func(t *testing.T) {
repo := gittest.NewRepo(t)
repo.Commit("fixture")
root := repo.Root()
repo.Write("stub/flat.txt", bin.flat)
repo.Write("stub/lint.txt", lint)
if bin.agent != "" {
repo.Write("stub/agent.txt", bin.agent)
}
if err := os.WriteFile(filepath.Join(root, "abcd"), []byte(siteProbeStub), 0o755); err != nil {
t.Fatal(err)
}
repo.Write("step.sh", step)

cmd := exec.Command("bash", "--noprofile", "--norc", "-eo", "pipefail", "step.sh")
cmd.Dir = root
cmd.Env = append(repo.Env(), "TAG=v0.11.0")
out, err := cmd.CombinedOutput()
ran, _ := os.ReadFile(filepath.Join(root, "stub", "ran.log"))

var exit *exec.ExitError
switch {
case bin.wantVerb && err != nil:
t.Errorf("step %d refused a binary that has the site verb: %v\n%s\n--- step:\n%s", i, err, out, step)
case bin.wantVerb && !strings.Contains(string(ran), "site"):
t.Errorf("step %d exited 0 without running a site verb (ran %q)\n--- step:\n%s", i, ran, step)
case !bin.wantVerb && !errors.As(err, &exit):
t.Errorf("step %d did not refuse a binary with no site verb: err=%v\n%s", i, err, out)
case !bin.wantVerb && (!strings.Contains(string(out), siteProbeRefusal) || len(ran) > 0):
t.Errorf("step %d must refuse in its one line before running anything; ran %q, output:\n%s", i, ran, out)
}
})
}
}
}
Loading