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
33 changes: 24 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:
contents: read

# Per-PR supersession: a new push to the same PR replaces that PR's in-flight
# CI instead of letting superseded heads keep 13 jobs of hosted-runner work.
# CI instead of letting superseded heads keep the full hosted-runner fan-out.
# The group uses the PR number for pull_request events, so every run of one PR
# shares a group, and falls back to the unique run id for push events, so each
# main push gets its own group and is never cancelled. Cancellation is likewise
Expand All @@ -24,11 +24,14 @@ concurrency:

jobs:
lint:
name: Lint
name: Lint ${{ matrix.partition }}
runs-on: ubuntu-latest
# Hang tripwire only: lint executions measured at 14-16 minutes in the
# September 12 starvation report, so this leaves deliberate margin.
# Keep the hang tripwire separate from the measured performance target.
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
partition: [1, 2]
steps:
- uses: actions/checkout@v6
- name: Install pinned ShellCheck
Expand All @@ -45,7 +48,19 @@ jobs:
# and GitHub workflow lint). Do not re-spell the checks here; keep CI
# and the pre-push gate on this script so a self-broken ci.yml still
# fails locally before merge.
- run: bin/fm-lint.sh
- name: Lint canonical partition
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-lint"
bin/fm-lint.sh --partition "${{ matrix.partition }}of${{ strategy.job-total }}" \
--telemetry "$RUNNER_TEMP/fm-lint/partition-${{ matrix.partition }}.tsv"
- name: Upload lint telemetry
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-lint-telemetry-${{ matrix.partition }}
path: ${{ runner.temp }}/fm-lint/partition-${{ matrix.partition }}.tsv
if-no-files-found: warn

# Deterministic proof that portable parallel shards + portable serial + Herdr
# equal the complete tests/*.test.sh inventory with no missing or duplicates,
Expand Down Expand Up @@ -159,15 +174,15 @@ jobs:
tests-portable-serial:
name: Behavior portable serial ${{ matrix.shard }}
runs-on: ubuntu-latest
# Current runners can take ~20 min for a balanced shard. This 30-minute cap
# preserves the timeout as a hang tripwire while allowing runner-speed and
# job-setup margin; it is not the expected healthy end of the lane.
# Refreshed weights put the longest modeled shard near 12 minutes across
# nine runners. Preserve the existing hang tripwire until complete Linux
# measurements establish the new healthy envelope; a model is not a timer.
timeout-minutes: 30
strategy:
# Every shard reports so one failure never hides another shard's result.
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9]
steps:
- uses: actions/checkout@v6
with:
Expand Down
21 changes: 20 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ GitHub Actions and Dependabot are exempt so their automation keeps working, but

See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/start-here/quick-start/) for the full first-run walkthrough.

## Maintaining required checks

GitHub required checks are configured in the repository's existing main ruleset, not activated by committing workflow YAML.
When applying this CI layout, preserve its existing pull-request, merge-method, linear-history, deletion, non-fast-forward, and administrator-bypass settings.
Add required status checks with `strict_required_status_checks_policy: false`; a main update alone must not force a branch update and retest.
Bind the checks to the GitHub Actions app already producing them, rather than accepting the same context from any integration.
No new app installation or manual runner setup is needed for that setting.

Require the actual job contexts: `Lint 1`, `Lint 2`, `Test coverage guard`, `Repo invariants`, `Stock macOS Bash snapshot compatibility`, `Behavior portable parallel 1`, `Behavior portable parallel 2`, `Behavior portable serial 1` through `Behavior portable serial 9`, `Behavior tests (Herdr)`, `Behavior timing aggregate`, and `PR must be raised via no-mistakes`.
The last name is the compliance job context, not its workflow title; its existing automation exceptions remain unchanged.
The timing aggregate is not a substitute for individual jobs because it can succeed while collecting evidence from a failed run.

Apply the approved rule change only after the corresponding workflow is green and landed, confirming exact names and the Actions integration id from real checks first.
Snapshot the current ruleset, amend that same rule with the authenticated GitHub API or settings UI, and read back both the ruleset and effective branch rules.
Verify missing or red checks prevent ordinary merging without creating a test merge; administrator override intentionally remains available.
Coordinate any workflow rollback with its required-check names so a retired check cannot leave ordinary merges waiting forever.

## Repo conventions

- This repo is a template for running a firstmate orchestrator agent.
Expand All @@ -48,7 +65,9 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star
- Helper scripts in `bin/` are plain bash.
Each starts with a usage header comment; keep it accurate when you change behavior.
Test scripts and helpers in `tests/` are plain bash too.
`bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, pinned actionlint workflow lint, and the backend-purity check rejecting direct Beads CLI calls in core `bin/` scripts), and both CI and the no-mistakes pre-push gate invoke it with no arguments.
`bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, pinned shellcheck version, pinned actionlint workflow lint, and the backend-purity check rejecting direct Beads CLI calls in core `bin/` scripts).
CI uses its full canonical partitions; the no-mistakes pre-push gate uses its context-selected default.
`docs/fm-test-portable-shards.md` owns partition verification and performance evidence.
Its header and `--help` output own the exact local lint modes, file-set selection, and analysis flags.
A malformed `.github/workflows/*.yml`, including a self-broken `ci.yml`, fails that local lint path before merge because a broken workflow cannot report its own breakage.
It pins one exact shellcheck version and one exact actionlint version and refuses to run under any other.
Expand Down
104 changes: 77 additions & 27 deletions bin/fm-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# fm-lint.sh - the single owner of firstmate's lint definition.
#
# Runs its file set with ShellCheck's default severity, extended analysis,
# ambient configuration disabled, and one exact ShellCheck version. CI and
# no-mistakes both invoke this script with no arguments, so this owner selects
# the context-appropriate rule set without duplicating lint configuration.
# ambient configuration disabled, and one exact ShellCheck version. CI selects
# canonical partitions; no-mistakes invokes the context-selected default, so
# both use this owner without duplicating lint configuration.
# The explicit --fast mode is local-only and disables ShellCheck's extended
# dataflow analysis while preserving ordinary shell lint checks and source
# following. CI, main, and merge-base-less runs keep --norc --external-sources
Expand Down Expand Up @@ -41,10 +41,15 @@
# invocations in the core bin/ and bin/backends/ scripts so every configured
# backlog backend follows the same tasks-axi lifecycle path.
#
# Canonical lint defaults to two bounded workers over two stable logical shards.
# Each shard writes separate diagnostics, and the parent replays those outputs in
# deterministic shard and root order after every worker finishes. FM_LINT_JOBS=1
# runs the same shards serially with byte-identical diagnostics and exit selection.
# Lint defaults to two bounded workers over two stable logical shards.
# Diagnostics replay in stable shard/root order. FM_LINT_JOBS=1 changes
# concurrency, not diagnostics or exit selection.
# --partition 1of2/2of2 splits the entire canonical inventory across
# two CI runners, each with those same bounded workers. Partitions are complete,
# disjoint, and byte-weight balanced; --list-files exposes their actual roots.
# Partition mode is always full source-aware analysis, never changed-only or
# --fast, and does not accept explicit paths. Each partition also runs workflow
# lint and backend-purity checks, keeping either invocation independently useful.
#
# Optional quiet telemetry writes one bounded TSV snapshot of content and source
# graph identity, wall/CPU/RSS, shard load, and competing ShellCheck processes.
Expand All @@ -54,6 +59,7 @@
# fm-lint.sh --fast [path]... local lint with extended analysis disabled
# fm-lint.sh <path>... lint explicit roots with the same config
# fm-lint.sh --jobs <1|2> [path]... override bounded worker count
# fm-lint.sh --partition <1of2|2of2> lint one full-rigor canonical CI partition
# fm-lint.sh --telemetry <path> ... write a quiet metrics snapshot
# fm-lint.sh --required-version print the ShellCheck pin
# fm-lint.sh --list-files print the file set that would be linted
Expand Down Expand Up @@ -396,6 +402,8 @@ JOBS=${FM_LINT_JOBS:-2}
TELEMETRY=${FM_LINT_TELEMETRY:-}
FAST=0
ANALYSIS_MODE=full
PARTITION=
PARTITION_REQUESTED=0
LIST_FILES=0
while [ "$#" -gt 0 ]; do
case "$1" in
Expand All @@ -417,6 +425,17 @@ while [ "$#" -gt 0 ]; do
TELEMETRY=${1#*=}
shift
;;
--partition)
[ "$#" -ge 2 ] || { printf 'fm-lint.sh: --partition requires 1of2 or 2of2.\n' >&2; exit 2; }
PARTITION=$2
PARTITION_REQUESTED=1
shift 2
;;
--partition=*)
PARTITION=${1#*=}
PARTITION_REQUESTED=1
shift
;;
--fast)
FAST=1
ANALYSIS_MODE=fast
Expand All @@ -443,6 +462,22 @@ case "$JOBS" in
*) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;;
esac

case "$PARTITION" in
'')
if [ "$PARTITION_REQUESTED" -eq 1 ]; then
printf 'fm-lint.sh: --partition requires 1of2 or 2of2.\n' >&2
exit 2
fi
;;
1of2|2of2)
if [ "$FAST" -eq 1 ] || [ "$#" -gt 0 ]; then
printf 'fm-lint.sh: --partition requires full canonical lint; omit --fast and explicit paths.\n' >&2
exit 2
fi
;;
*) printf 'fm-lint.sh: --partition must be 1of2 or 2of2, got %s.\n' "$PARTITION" >&2; exit 2 ;;
esac

if [ "$FAST" -eq 1 ] && { [ "${GITHUB_ACTIONS:-}" = true ] || [ "${CI:-}" = true ]; }; then
printf 'fm-lint.sh: --fast is local-only; CI uses full ShellCheck analysis.\n' >&2
exit 2
Expand Down Expand Up @@ -492,7 +527,7 @@ if [ "$#" -gt 0 ]; then
ROOTS=("$@")
else
full_lint=1
if [ "${GITHUB_ACTIONS:-}" != true ] && [ "${CI:-}" != true ] \
if [ -z "$PARTITION" ] && [ "${GITHUB_ACTIONS:-}" != true ] && [ "${CI:-}" != true ] \
&& command -v git >/dev/null 2>&1 \
&& git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
&& [ "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" != main ]; then
Expand All @@ -519,6 +554,38 @@ if [ "$CHANGED_MODE" -eq 1 ] && [ "$FAST" -eq 0 ]; then
EXCLUDE_CODES=$LOCAL_NOX_EXCLUDE
ANALYSIS_MODE=local
fi
# Stable largest-first packing is shared by cross-runner partition selection
# and the two local workers. Weights are a scheduling proxy, never a skip rule.
TAB=$(printf '\t')
fm_lint_root_weights() {
local index=1 path weight
for path in "${ROOTS[@]}"; do
case "$path" in
*"$TAB"*|*$'\n'*)
printf 'fm-lint.sh: paths containing tabs or newlines are not supported: %s\n' "$path" >&2
return 2
;;
esac
weight=1
if [ -f "$path" ]; then
weight=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]')
fi
case "$weight" in ''|*[!0-9]*) weight=1 ;; esac
printf '%s\t%s\t%s\n' "$weight" "$index" "$path"
index=$((index + 1))
done
}

if [ -n "$PARTITION" ]; then
PARTITION_ROOTS=()
partition_weights=$(fm_lint_root_weights) || exit $?
while IFS="$TAB" read -r index path; do
PARTITION_ROOTS+=("$path")
done < <(printf '%s\n' "$partition_weights" | LC_ALL=C sort -t "$TAB" -k1,1nr -k2,2n | awk -F '\t' -v want="${PARTITION%%of*}" '
{ shard=(load[2] < load[1]) ? 2 : 1; load[shard]+=$1; if (shard == want) print $2 "\t" $3 }
' | LC_ALL=C sort -t "$TAB" -k1,1n)
ROOTS=("${PARTITION_ROOTS[@]}")
fi
ROOT_COUNT=${#ROOTS[@]}

if [ "$LIST_FILES" -eq 1 ]; then
Expand Down Expand Up @@ -597,7 +664,6 @@ trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

TAB=$(printf '\t')
WEIGHTS="$TMP_ROOT/weights"
OUTPUT_DIR="$TMP_ROOT/output"
mkdir -p "$OUTPUT_DIR"
Expand All @@ -608,24 +674,7 @@ while [ "$worker" -lt "$SHARD_COUNT" ]; do
worker=$((worker + 1))
done

index=1
: > "$WEIGHTS"
for path in "${ROOTS[@]}"; do
case "$path" in
*"$TAB"*|*$'\n'*)
printf 'fm-lint.sh: paths containing tabs or newlines are not supported: %s\n' "$path" >&2
exit 2
;;
esac
if [ -f "$path" ]; then
weight=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]')
else
weight=1
fi
case "$weight" in ''|*[!0-9]*) weight=1 ;; esac
printf '%s\t%s\t%s\n' "$weight" "$index" "$path" >> "$WEIGHTS"
index=$((index + 1))
done
fm_lint_root_weights > "$WEIGHTS" || exit $?

# Largest-first deterministic greedy assignment keeps the two bounded workers
# balanced without affecting replay order. Direct bytes are a stable portable
Expand Down Expand Up @@ -841,6 +890,7 @@ EOF
printf 'content_cksum\t%s\n' "$content_cksum"
printf 'shellcheck_version\t%s\n' "$resolved"
printf 'analysis_mode\t%s\n' "$ANALYSIS_MODE"
printf 'partition\t%s\n' "${PARTITION:-all}"
printf 'jobs\t%s\n' "$JOBS"
printf 'root_count\t%s\n' "$ROOT_COUNT"
printf 'direct_lines\t%s\n' "$direct_lines"
Expand Down
12 changes: 7 additions & 5 deletions bin/fm-mail-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,11 @@ fi
# dropped, and an empty failure gets a truth-stating fallback.
poll_summary() {
local rc=$1 out=$2 line
line=$(printf '%s\n' "$out" | sed -n '/^fm-mail: woke for /d; s/^fm-mail: //p' | head -n 1)
# First-line selectors must still drain the stream: head/quiet grep can
# close a large poll's pipe early and add a Broken pipe diagnostic.
line=$(printf '%s\n' "$out" | sed -n '/^fm-mail: woke for /d; s/^fm-mail: //p' | sed -n '1p')
if [ -z "$line" ]; then
line=$(printf '%s\n' "$out" | sed -n '/^fm-mail: woke for /d; /^$/d; p' | head -n 1)
line=$(printf '%s\n' "$out" | sed -n '/^fm-mail: woke for /d; /^$/d; p' | sed -n '1p')
fi
if [ -z "$line" ]; then
line="poll failed (rc=$rc)"
Expand Down Expand Up @@ -174,8 +176,8 @@ record_write() {
poll_has_publication_evidence() {
local rc=${1:-0} out=$2 woken_before=$3
[ "$rc" -eq 124 ] && return 0
if [ -n "$out" ] && printf '%s\n' "$out" | grep -qE \
'^fm-mail: woke for |the wake stays queued|could not clear retry for recovered'
if [ -n "$out" ] && printf '%s\n' "$out" | grep -E \
'^fm-mail: woke for |the wake stays queued|could not clear retry for recovered' >/dev/null
then
return 0
fi
Expand Down Expand Up @@ -210,7 +212,7 @@ action_check() {
line="poll did not finish within the ${BUDGET_SECS}s budget"
elif [ "${rc:-0}" -ne 0 ]; then
line=$(poll_summary "$rc" "$out")
elif printf '%s\n' "$out" | grep -q '^fm-mail: woke for '; then
elif printf '%s\n' "$out" | grep '^fm-mail: woke for ' >/dev/null; then
# A successful poll can still surface new mail: the poll itself already
# appended the durable mail wake rows, but the watcher only calls wake()
# when THIS check's output is non-empty. Emit one line naming a surfaced
Expand Down
Loading
Loading