Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7b63749
feat: implement issue #1388 — lint-provisioning Python core (install_…
github-actions[bot] Aug 25, 2026
87be033
feat: implement issue #1388 — provision-lint-tools.sh helper + end-to…
github-actions[bot] Aug 25, 2026
a48c3cd
feat: implement issue #1388 — lint_mode composite-action input + thre…
github-actions[bot] Aug 25, 2026
c6e5b80
feat: implement issue #1388 — tracked digest-bound install-state mark…
github-actions[bot] Aug 25, 2026
168411c
feat: implement issue #1388 — install.sh ships manifest + publishes m…
github-actions[bot] Aug 25, 2026
457e0d9
feat: implement issue #1388 — CI candidate-manifest job (no write cre…
github-actions[bot] Aug 25, 2026
5767bb3
fix: reconcile devflow-runner provision count pins for #1388 hardenin…
github-actions[bot] Aug 25, 2026
ee0e5bb
feat: implement issue #1388 — §2.3 sweep reconciliation, coverage-map…
github-actions[bot] Aug 25, 2026
3cd6590
chore: mark lint_provision.py executable (#1388)
github-actions[bot] Aug 25, 2026
5ed52b4
Merge remote-tracking branch 'origin/main' into issue-1388-1276-follo…
github-actions[bot] Aug 25, 2026
d2d43bb
chore: add changeset for issue #1388 lint provisioning (#1963)
github-actions[bot] Aug 25, 2026
c066ff5
refactor: address /simplify findings for issue #1388 (whole-dir revie…
github-actions[bot] Aug 25, 2026
f91cdaa
fix: address review findings for issue #1388 (iteration 1) — whole-to…
github-actions[bot] Aug 25, 2026
72c223c
fix: wire actions/cache for AC5 — cache restores now re-verified unde…
github-actions[bot] Aug 25, 2026
949de2d
docs: update documentation for issue #1388
github-actions[bot] Aug 25, 2026
3471e1c
Merge remote-tracking branch 'origin/main' into issue-1388-1276-follo…
github-actions[bot] Aug 25, 2026
9d522ea
fix: installer-wiring #959 fixture carries the two new copy-list file…
github-actions[bot] Aug 25, 2026
4bbff4c
feat: pre-provisioned-runner reuse + unsupported-platform degrade in …
Aug 25, 2026
33e4037
fix: backfill measured sha256 digests in lint-manifest.json
Aug 25, 2026
e0d3478
docs: CLAUDE.md convention — design for consumer custom runners incl.…
Aug 25, 2026
41da42e
test(ci): exercise real lint-provisioning downloads and regenerate in…
Aug 25, 2026
a0c914c
fix(test): derive #1388 shellcheck digest assertion from the manifest…
Aug 25, 2026
20f9117
Merge origin/main into issue-1388 lint-provisioning branch (resolve a…
Aug 25, 2026
9a48a4c
Merge remote-tracking branch 'origin/main' into pr-1963-review-reception
Aug 26, 2026
399aea3
fix: address PR #1963 review findings (reception pass)
Aug 26, 2026
a81c47d
test: reconcile installer-wiring coupled literals for the new #1388 4…
Aug 26, 2026
dbf1330
fix: address PR #1963 round-2 review findings
Aug 26, 2026
64c4be4
fix: address PR #1963 round-3 review findings (unknown-tool fail-clos…
Aug 26, 2026
743cdef
Merge branch 'main' into issue-1388-1276-follow-up-lint-provisioning-…
The01Geek Aug 26, 2026
a50e50b
fix: address review findings on #1963 (iteration 1)
Aug 26, 2026
36c4f83
Merge remote-tracking branch 'origin/main' into worktree-pr-1963
Aug 26, 2026
1331341
fix: repair the three CI failures the first fix round introduced
Aug 26, 2026
bb40477
fix: raise installer-wiring's exact assertion floor to 304
Aug 26, 2026
f8a3e84
Merge remote-tracking branch 'origin/main' into worktree-pr-1963
Aug 26, 2026
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
18 changes: 18 additions & 0 deletions .changeset/issue-1388-lint-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
bump: patch
type: Added
---

- **Provision the bounded lint toolchain before the model runs.** The installer now ships the
lint manifest and publishes a digest-bound compatibility marker (`.prflow/install-state.json`)
only after validating the staged tuple of manifest, readers, setup action, and implement
workflow. `setup-project-env` gains a closed `lint_mode` input (`provision` installs the
manifest's ShellCheck/Ruff set run-local, digest- and version-verified, before the Claude
action; `none` does no lint work and validates no manifest; an unknown value is refused), wired
`none`/`provision`/`none` across `devflow.yml`/`devflow-implement.yml`/`devflow-runner.yml`. The
review runner hardens its setup invocation by materializing trusted base-ref bytes over the
composite-action directory before it runs, so the read-only review job executes the base-ref
action body rather than a PR-head edit, and CI validates and exercises the candidate manifest
with no repository write credentials. An unsupported platform degrades with a warning instead of
failing, and a version-verified pre-provisioned runner-image tool is reused instead of
downloaded. (#1963)
72 changes: 72 additions & 0 deletions .github/actions/setup-project-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ inputs:
config_json:
description: '.prflow/config.json contents, as emitted by the read-project-config action (steps.<id>.outputs.json). The automated reviewer (devflow-runner.yml, provision_env path) instead passes the TRUSTED base-ref config it reads directly via git show + `jq -c` — a different provenance but the same shape (a JSON object with a `.setup` block), so this action consumes it identically.'
required: true
lint_mode:
description: >-
Lint-tool provisioning mode. Closed set: "provision" installs the
manifest's bounded ShellCheck/Ruff toolchain (run-local, no sudo, digest
and version verified) before the Claude action; "none" does no lint-tool
work and validates no manifest. Any other value is refused.
required: false
default: 'none'

outputs:
health_summary:
Expand Down Expand Up @@ -223,6 +231,70 @@ runs:
} >> "$GITHUB_OUTPUT"
fi

# The hashFiles(manifest, marker) key covers the whole AC5 tuple transitively —
# do not add tuple components to it by hand. A restored $DEST_BIN is still
# re-verified by the provisioning step: a cache hit is never verification.
- name: Cache the provisioned lint toolchain
if: inputs.lint_mode == 'provision'
uses: actions/cache@v5
with:
path: ${{ runner.temp }}/prflow-lint-bin
key: lintprov-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.prflow/lint-manifest.json', '.prflow/install-state.json') }}

# Must stay ordered BEFORE the Claude action (provisioning after model start
# defeats #1388), and the os/arch → artifact/URL mapping stays in the trusted
# Python helpers — never manifest-supplied strings (#1276 trust model).
- name: Provision lint toolchain (before model)
shell: bash
env:
LINT_MODE: ${{ inputs.lint_mode }}
run: |
set -euo pipefail
case "$LINT_MODE" in
none)
echo "lint_mode=none: no lint-tool provisioning and no manifest validation."
exit 0
;;
provision) ;;
*)
echo "::error::setup-project-env: unknown lint_mode '$LINT_MODE' (closed set: provision | none)."
exit 1
;;
esac
# Resolve the trusted bundled helpers: this repo's own scripts/ (self /
# committed), else the vendored slice a thin consumer materializes.
# Probe BOTH helpers the provisioning step invokes, and refuse when neither
# candidate carries them: probing only lint_provision.py let a failed vendor
# materialization fall through and surface as a marker-readiness refusal,
# sending the operator to re-run an installer that was never the problem.
SCRIPTS_DIR=""
for _cand in "scripts" ".prflow/vendor/prflow/scripts"; do
if [ -f "$_cand/lint_provision.py" ] && [ -f "$_cand/install_state.py" ]; then
SCRIPTS_DIR="$_cand"
break
fi
done
if [ -z "$SCRIPTS_DIR" ]; then
echo "::error::setup-project-env: neither scripts/ nor .prflow/vendor/prflow/scripts/ carries both lint_provision.py and install_state.py — the plugin vendor step did not materialize the bundled helpers."
exit 1
fi
case "${RUNNER_OS:-}" in
Linux) TARGET_OS=linux ;;
macOS) TARGET_OS=macos ;;
Windows) TARGET_OS=windows ;;
*) echo "::error::setup-project-env: unsupported RUNNER_OS '${RUNNER_OS:-}'"; exit 1 ;;
esac
case "${RUNNER_ARCH:-}" in
X64) TARGET_ARCH=x86_64 ;;
ARM64) TARGET_ARCH=arm64 ;;
*) echo "::error::setup-project-env: unsupported RUNNER_ARCH '${RUNNER_ARCH:-}'"; exit 1 ;;
esac
export LINT_MANIFEST=".prflow/lint-manifest.json"
export INSTALL_STATE=".prflow/install-state.json"
export DEST_BIN="${RUNNER_TEMP:-/tmp}/prflow-lint-bin"
export TARGET_OS TARGET_ARCH SCRIPTS_DIR
bash "$GITHUB_ACTION_PATH/provision-lint-tools.sh"

- name: Provision project dependencies
shell: bash
env:
Expand Down
263 changes: 263 additions & 0 deletions .github/actions/setup-project-env/provision-lint-tools.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Daniel Radman
# SPDX-License-Identifier: MIT
# ============================================================================
# provision-lint-tools.sh — install the manifest's bounded lint toolchain BEFORE
# the model runs (issue #1388).
# ============================================================================
# Manifest validation, platform resolution, and the compatibility-marker
# readiness gate live in the Python helpers (scripts/lint_provision.py,
# scripts/install_state.py); this script orchestrates: gate on readiness,
# resolve each tool's artifact, download → verify the pinned ARCHIVE digest →
# extract → install run-local (NO sudo) → verify the executable reports the
# pinned version. A binary already at the destination, or on PATH, is reused
# only after re-passing that version check. Every INTEGRITY failure fails
# CLOSED naming the tool, before the model runs — missing installer primitive,
# checksum mismatch, archive that will not extract, wrong version, network
# failure, unwritable target, unknown tool. An unsupported platform tuple
# degrades instead: reuse a version-matching PATH tool, else warn and continue.
#
# Required environment:
# LINT_MANIFEST path to .prflow/lint-manifest.json
# INSTALL_STATE path to .prflow/install-state.json (the compatibility marker)
# DEST_BIN directory to install the tool executables into (added to PATH)
# TARGET_OS linux | macos | windows
# TARGET_ARCH x86_64 | arm64
# SCRIPTS_DIR directory holding lint_provision.py + install_state.py
# Optional (overridable so lib/test can drive the fail-closed arms offline):
# INSTALLER_VERSION overrides the marker's installer_version (cache-key component);
# derived from the marker after the readiness gate when unset
# TOOLS space-separated tool list (default: the manifest's own tool set)
# LINTPROV_PYTHON python3 interpreter (default python3)
# LINTPROV_CURL downloader; called as "$LINTPROV_CURL" -fsSL -o OUT URL (default curl)
# LINTPROV_TAR tar extractor (default tar)
# LINTPROV_UNZIP zip extractor (default unzip)
# LINTPROV_SKIP_PATH_REUSE set to 1 to skip the pre-provisioned-runner PATH
# reuse check on an established plan, forcing the download
# path (the unsupported-plan PATH check is unaffected)
# ============================================================================
set -euo pipefail

PY="${LINTPROV_PYTHON:-python3}"
CURL="${LINTPROV_CURL:-curl}"
TAR="${LINTPROV_TAR:-tar}"
UNZIP="${LINTPROV_UNZIP:-unzip}"
# TOOLS is derived from the validated manifest below, after the readiness gate. An
# explicit value still wins (the suite drives one tool at a time).

# Set to the in-flight work directory while one exists; _die removes it. `exit` does
# NOT run a RETURN trap, so every fail-closed arm leaked its mktemp -d without this,
# and the suite drives this helper repeatedly in one process.
_WORKDIR=""

_die() {
# $1 = tool (or "-"), $2 = reason. One diagnostic per fail-closed arm so a
# reader can tell which tool and which condition detonated.
[ -n "$_WORKDIR" ] && rm -rf "$_WORKDIR"
printf 'provision-lint-tools: %s: %s\n' "$1" "$2" >&2
exit 1
}

_have() { command -v "$1" >/dev/null 2>&1; }

# Match the pinned version as a WHOLE token in the tool's --version output, never a
# substring: pinned "1.2" must NOT match reported "1.24.1". Portable ERE token
# boundary (no GNU \b, which BSD grep silently ignores). $1 = reported text, $2 = version.
_version_token_match() {
local esc="${2//./\\.}"
printf '%s' "$1" | grep -Eq "(^|[^0-9.])${esc}([^0-9.]|\$)"
}

# sha256 of a file via the trusted python interpreter (no sha256sum dependency —
# it is not preflight-guaranteed and diverges across BSD/GNU).
_digest() {
"$PY" - "$1" <<'PY'
import hashlib, sys
with open(sys.argv[1], "rb") as fh:
print("sha256:" + hashlib.sha256(fh.read()).hexdigest())
PY
}

for v in LINT_MANIFEST INSTALL_STATE DEST_BIN TARGET_OS TARGET_ARCH SCRIPTS_DIR; do
eval "val=\${$v:-}"
[ -n "$val" ] || _die - "missing required environment variable $v"
done

_have "$PY" || _die - "installer primitive not found: python3 ($PY)"

# Refuse the WHOLE pass before touching any tool: a component digest that
# disagrees means the readers and the manifest may not understand each other.
if ! ready="$("$PY" "$SCRIPTS_DIR/install_state.py" verify --state "$INSTALL_STATE" --manifest "$LINT_MANIFEST" 2>&1)"; then
_die - "install-state readiness refused: ${ready#NOT-READY } — remedy: re-run the PRFlow installer (install.sh), which republishes the marker over the components actually installed in this tree"
fi

# Derive the tool set from the manifest the gate just validated, so the shipped set
# has ONE source: a hardcoded list that omitted a manifest tool left that tool
# silently never provisioned while the readiness gate still reported READY.
if [ -z "${TOOLS:-}" ]; then
TOOLS="$("$PY" -c 'import json,sys; print(" ".join(json.load(open(sys.argv[1]))["tools"]))' "$LINT_MANIFEST")" \
|| _die - "could not derive the tool set from the manifest"
[ -n "$TOOLS" ] || _die - "manifest declares no tools to provision"
fi

# The marker validated above, so its installer_version is present and typed. An
# explicit INSTALLER_VERSION env overrides it (tests); otherwise derive it here.
INSTALLER_VERSION="${INSTALLER_VERSION:-}"
if [ -z "$INSTALLER_VERSION" ]; then
INSTALLER_VERSION="$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["installer_version"])' "$INSTALL_STATE")" \
|| _die - "could not read installer_version from the validated marker"
fi

mkdir -p "$DEST_BIN" 2>/dev/null || _die - "unwritable target: cannot create $DEST_BIN"

PROVISIONED=""
UNPROVISIONED=""

_provision_one() {
local tool="$1"
local plan rc plan_err plan_err_file
# Keep stderr OUT of $plan: the tab-parse below splits $plan into fields, so
# interpreter noise merged via 2>&1 would corrupt the field split.
plan_err_file="$(mktemp)"
set +e
plan="$("$PY" "$SCRIPTS_DIR/lint_provision.py" plan \
--manifest "$LINT_MANIFEST" --tool "$tool" --os "$TARGET_OS" --arch "$TARGET_ARCH" 2>"$plan_err_file")"
rc=$?
set -e
plan_err="$(<"$plan_err_file")" || plan_err=""
rm -f "$plan_err_file"
if [ "$rc" -eq 3 ]; then
local unsupported_version sys_unsupported
unsupported_version="$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["tools"][sys.argv[2]]["version"])' \
"$LINT_MANIFEST" "$tool" 2>/dev/null || true)"
sys_unsupported="$(command -v "$tool" 2>/dev/null || true)"
if [ -n "$sys_unsupported" ] && [ -n "$unsupported_version" ] \
&& _version_token_match "$("$sys_unsupported" --version 2>&1 || true)" "$unsupported_version"; then
printf 'provision-lint-tools: %s: reused pre-provisioned %s (%s) from the runner image\n' \
"$tool" "$sys_unsupported" "$unsupported_version"
PROVISIONED="$PROVISIONED $tool"
return 0
fi
printf 'provision-lint-tools: %s: unsupported-lint-platform (%s/%s); continuing without provisioning this tool\n' \
"$tool" "$TARGET_OS" "$TARGET_ARCH" >&2
printf '::warning::provision-lint-tools: %s: unsupported-lint-platform (%s/%s) and no pre-provisioned %s at pinned version %s on PATH; continuing without provisioning this tool\n' \
"$tool" "$TARGET_OS" "$TARGET_ARCH" "$tool" "${unsupported_version:-unknown}" >&2
UNPROVISIONED="$UNPROVISIONED $tool"
return 0
elif [ "$rc" -eq 4 ]; then
# Unknown tool ≠ unsupported platform: nothing can provision it, so skipping
# it like a platform gap would silently drop a lint the manifest never covers.
_die "$tool" "unknown-lint-tool: not in the resolver's known tool set"
elif [ "$rc" -ne 0 ]; then
_die "$tool" "manifest resolution failed: ${plan}${plan_err:+ ${plan_err}}"
fi

local digest archive_type member strategy version url
IFS=$'\t' read -r digest archive_type member strategy version url <<<"$plan"
[ -n "$url" ] || _die "$tool" "manifest resolution returned no download URL"

# cache_key appears in log lines ONLY — the cross-run cache gate is action.yml's
# hashFiles key; do not wire this value into cache restore/save logic.
local cache_key
cache_key="$("$PY" "$SCRIPTS_DIR/lint_provision.py" cache-key \
--manifest "$LINT_MANIFEST" --tool "$tool" --os "$TARGET_OS" --arch "$TARGET_ARCH" \
--installer-version "$INSTALLER_VERSION")" \
|| _die "$tool" "cache-key computation failed"

local dest="$DEST_BIN/$member"

# Never reuse a cached executable without re-running the version check: the cache
# slot is keyed on the tuple, but a restored binary is otherwise unverified bytes.
if [ -x "$dest" ]; then
local cached_ver
cached_ver="$("$dest" --version 2>&1 || true)"
if _version_token_match "$cached_ver" "$version"; then
printf 'provision-lint-tools: %s: reused verified install (%s, key %s)\n' "$tool" "$version" "$cache_key"
PROVISIONED="$PROVISIONED $tool"
return 0
fi
fi

# PATH, not $dest — this must never substitute for the cache-restore check above.
# LINTPROV_SKIP_PATH_REUSE=1 keeps the download path exercised in tests.
if [ "${LINTPROV_SKIP_PATH_REUSE:-}" != "1" ]; then
local sys
sys="$(command -v "$tool" 2>/dev/null || true)"
if [ -n "$sys" ] && [ "$sys" != "$dest" ] \
&& _version_token_match "$("$sys" --version 2>&1 || true)" "$version"; then
printf 'provision-lint-tools: %s: reused pre-provisioned %s (%s) from the runner image\n' \
"$tool" "$sys" "$version"
PROVISIONED="$PROVISIONED $tool"
return 0
fi
fi

# Installer primitives for this artifact's strategy.
_have "$CURL" || _die "$tool" "installer primitive not found: downloader ($CURL)"
case "$strategy" in
extract-tar) _have "$TAR" || _die "$tool" "installer primitive not found: tar ($TAR)" ;;
extract-zip) _have "$UNZIP" || _die "$tool" "installer primitive not found: unzip ($UNZIP)" ;;
*) _die "$tool" "unknown extraction strategy $strategy" ;;
esac

local work archive extract_dir
work="$(mktemp -d)"
_WORKDIR="$work"
# shellcheck disable=SC2064
trap "rm -rf '$work'; _WORKDIR=''" RETURN
archive="$work/artifact"
extract_dir="$work/x"
mkdir -p "$extract_dir"

# Download — a network failure fails closed naming the tool.
"$CURL" -fsSL -o "$archive" "$url" || _die "$tool" "network failure downloading $url"
[ -s "$archive" ] || _die "$tool" "network failure: empty download from $url"

# Verify the pinned digest BEFORE extracting — a checksum mismatch is a
# supply-chain refusal, not a warning.
local got
got="$(_digest "$archive")" || _die "$tool" "digest computation failed"
[ "$got" = "$digest" ] || _die "$tool" "checksum mismatch: expected $digest got $got"

# Extract per the closed strategy — an archive that will not extract is refused.
case "$strategy" in
extract-tar) "$TAR" -xf "$archive" -C "$extract_dir" 2>/dev/null || _die "$tool" "archive mismatch: $archive_type archive did not extract" ;;
extract-zip) "$UNZIP" -q -o "$archive" -d "$extract_dir" 2>/dev/null || _die "$tool" "archive mismatch: $archive_type archive did not extract" ;;
esac

# Locate the member anywhere in the extracted tree (upstream archives nest it
# under a versioned directory) and install it run-local, no sudo.
local found
found="$(find "$extract_dir" -type f -name "$member" -print 2>/dev/null | head -n 1 || true)"
[ -n "$found" ] || _die "$tool" "archive mismatch: member $member not found in archive"
install -m 0755 "$found" "$dest" 2>/dev/null || cp "$found" "$dest" 2>/dev/null \
|| _die "$tool" "unwritable target: cannot install into $DEST_BIN"
chmod 0755 "$dest" 2>/dev/null || _die "$tool" "unwritable target: cannot chmod $dest"

# Verify the installed executable reports the manifest's exact version (one exec).
local reported
reported="$("$dest" --version 2>&1)" || _die "$tool" "installed executable is not runnable"
_version_token_match "$reported" "$version" \
|| _die "$tool" "wrong version: $dest does not report $version"

printf 'provision-lint-tools: %s: installed %s (%s), version-verified (key %s)\n' \
"$tool" "$member" "$version" "$cache_key"
PROVISIONED="$PROVISIONED $tool"
}

for tool in $TOOLS; do
_provision_one "$tool"
done

# Put the provisioned tools on PATH for later steps (before the model runs).
if [ -n "${GITHUB_PATH:-}" ]; then
printf '%s\n' "$DEST_BIN" >> "$GITHUB_PATH"
fi
# Report only what actually landed: a tool that took the unsupported-platform
# degrade must not be listed as provisioned beside its own ::warning::.
if [ -n "$UNPROVISIONED" ]; then
printf 'provision-lint-tools: readiness verified; provisioned:%s; unprovisioned (degraded):%s\n' \
"${PROVISIONED:- (none)}" "$UNPROVISIONED"
else
printf 'provision-lint-tools: readiness verified; provisioned:%s\n' "${PROVISIONED:- (none)}"
fi
6 changes: 5 additions & 1 deletion .github/actions/vendor-plugin/vendor-slice.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ devflow_copy_slice() {
# Only the committed templates/registry — not the whole .prflow/ tree (which
# would drag in learnings/ and a possibly-dirty config.json).
mkdir -p "$stage/.prflow"
# The lint manifest and its digest-bound compatibility marker ship (issue #1388):
# the setup action's provisioning phase reads .prflow/lint-manifest.json and gates
# on .prflow/install-state.json, so a consumer that lacks them cannot provision.
cp "$src/.prflow/config.example.json" "$src/.prflow/config.schema.json" \
"$src/.prflow/tool-presets.json" \
"$src/.prflow/tool-presets.json" "$src/.prflow/lint-manifest.json" \
"$src/.prflow/install-state.json" \
"$stage/.prflow/"
# The vendored copy is a plugin, not a marketplace — keep only plugin.json.
rm -f "$stage/.claude-plugin/marketplace.json"
Expand Down
Loading
Loading