From b4779dd19d462f5ffa4c2f36433c19f3160989fb Mon Sep 17 00:00:00 2001 From: senseb <446326+senseb@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:44:05 +0800 Subject: [PATCH 1/2] Post readiness receipt summary to PRs --- .github/workflows/beforewire-agent-gate.yml | 13 + examples/agent-readiness-pack/README.md | 19 +- .../bin/post_pr_readiness_comment.py | 264 ++++++++++++++++++ .../bin/run_acceptance.py | 15 + .../bin/verify_github_shadow_gate.py | 4 +- .../github/beforewire-agent-gate.yml | 13 + 6 files changed, 322 insertions(+), 6 deletions(-) create mode 100755 examples/agent-readiness-pack/bin/post_pr_readiness_comment.py diff --git a/.github/workflows/beforewire-agent-gate.yml b/.github/workflows/beforewire-agent-gate.yml index cd40da8..d0b23fb 100644 --- a/.github/workflows/beforewire-agent-gate.yml +++ b/.github/workflows/beforewire-agent-gate.yml @@ -6,6 +6,8 @@ on: permissions: contents: read + issues: write + pull-requests: read jobs: verify-agent-readiness: @@ -29,6 +31,17 @@ jobs: run: | cd examples/agent-readiness-pack python bin/verify_readiness_receipt.py receipts/readiness-receipt.json + - name: Post readiness PR comment + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + cd examples/agent-readiness-pack + python bin/post_pr_readiness_comment.py \ + --repo "${{ github.repository }}" \ + --pr-number "${{ github.event.pull_request.number }}" \ + --receipt receipts/readiness-receipt.json - name: Upload readiness artifacts uses: actions/upload-artifact@v4 if: always() diff --git a/examples/agent-readiness-pack/README.md b/examples/agent-readiness-pack/README.md index 34dfdbe..3c10376 100644 --- a/examples/agent-readiness-pack/README.md +++ b/examples/agent-readiness-pack/README.md @@ -42,6 +42,8 @@ Current packet and control coverage: - Tamper-negative test that edits a receipt and confirms the verifier fails. - PR-specific GitHub Actions workflow that regenerates and verifies the receipt on `pull_request` / `workflow_dispatch`. +- Receipt-backed PR comment broker that posts a readiness summary to the PR using + an approval receipt and stable idempotency key. ## Current External Limitation @@ -94,6 +96,7 @@ Run focused controls: .venv/bin/python bin/run_broker_dryrun.py .venv/bin/python bin/run_replay_fixture.py .venv/bin/python bin/run_tamper_negative.py +.venv/bin/python bin/post_pr_readiness_comment.py --repo beforewire/forkcell --pr-number 1 --dry-run .venv/bin/python bin/run_openshell_live_smoke.py .venv/bin/python bin/verify_branch_protection_gate.py --repo beforewire/forkcell ``` @@ -101,7 +104,7 @@ Run focused controls: Run strict acceptance and try to configure the required-check ruleset when the GitHub API allows it: ```bash -.venv/bin/python bin/run_acceptance.py --repo ../.. --github-repo beforewire/forkcell --configure-branch-protection +.venv/bin/python bin/run_acceptance.py --repo ../.. --github-repo beforewire/forkcell --pr-number 1 --configure-branch-protection ``` Verify the required-check gate without mutating GitHub state: @@ -114,7 +117,7 @@ Run local PLG acceptance while preserving the strict external blocker in a separate output file: ```bash -.venv/bin/python bin/run_acceptance.py --repo ../.. --github-repo beforewire/forkcell \ +.venv/bin/python bin/run_acceptance.py --repo ../.. --github-repo beforewire/forkcell --pr-number 1 \ --allow-external-unavailable \ --output results/acceptance-local-results.json ``` @@ -137,12 +140,16 @@ separate output file: - `results/broker-dryrun-results.json`: side-effect broker dry-run receipts. - `results/replay-fixture-results.json`: action trace and approval replay proof. - `results/tamper-negative-results.json`: receipt tamper-negative proof. +- `results/pr-comment-broker-results.json`: PR comment broker evidence, including + idempotency key and approval receipt reference. - `results/openshell-live-smoke.json`: live OpenShell sandbox evidence. - `results/branch-protection-gate.json`: required-check enforcement evidence or external blocker evidence. - `results/acceptance-results.json`: strict merge-blocking acceptance. - `results/acceptance-local-results.json`: optional local PLG acceptance. - `receipts/readiness-receipt.json`: CI-verifiable readiness receipt. +- `receipts/pr-comment-approval-receipt.json`: approval receipt that authorizes + the low-risk PR readiness summary comment. - `github/beforewire-agent-gate.yml`: GitHub Actions gate source. - `.github/workflows/beforewire-agent-gate.yml`: installed PR workflow in the repository root. @@ -150,9 +157,11 @@ separate output file: ## CI Gate The installed workflow is named `beforewire-agent-gate` and its job is named -`BeforeWire Agent Gate`. On every PR it bootstraps the pack, reruns -`bin/run_readiness_pack.py --repo ../..`, verifies the freshly generated receipt, -and uploads the evidence artifacts. +`BeforeWire Agent Gate`. On every same-repository PR it bootstraps the pack, +reruns `bin/run_readiness_pack.py --repo ../..`, verifies the freshly generated +receipt, posts a readiness summary PR comment through the broker, and uploads +the evidence artifacts. Fork PRs keep the required-check receipt gate but skip +the comment side effect because GitHub tokens are read-only there. To make it merge-blocking, enable branch protection or a repository ruleset and require the `BeforeWire Agent Gate` check. The local acceptance runner can attempt diff --git a/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py b/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py new file mode 100755 index 0000000..9b5aa26 --- /dev/null +++ b/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +PACK_ROOT = Path(__file__).resolve().parents[1] +MARKER_PREFIX = "beforewire:readiness-summary:v1" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def sha256_text(text: str) -> str: + return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def rel(path: Path) -> str: + return str(path.relative_to(PACK_ROOT)) + + +def ci_context() -> dict[str, Any]: + return { + "github_actions": os.environ.get("GITHUB_ACTIONS") == "true", + "github_run_id": os.environ.get("GITHUB_RUN_ID"), + "github_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"), + "github_event_name": os.environ.get("GITHUB_EVENT_NAME"), + "github_ref": os.environ.get("GITHUB_REF"), + "github_sha": os.environ.get("GITHUB_SHA"), + "github_repository": os.environ.get("GITHUB_REPOSITORY"), + } + + +def make_idempotency_key(repo: str, pr_number: str) -> str: + return hashlib.sha256(f"{MARKER_PREFIX}|{repo}|{pr_number}".encode("utf-8")).hexdigest()[:24] + + +def make_run_url(repo: str) -> str | None: + run_id = os.environ.get("GITHUB_RUN_ID") + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + if not run_id: + return None + return f"{server}/{repo}/actions/runs/{run_id}" + + +def render_comment(receipt: dict[str, Any], repo: str, pr_number: str, idempotency_key: str) -> str: + summary = receipt.get("summary") or {} + controls = receipt.get("control_results") or {} + ci = receipt.get("ci_context") or {} + run_url = make_run_url(repo) + control_lines = [] + for name in sorted(controls): + status = (controls.get(name) or {}).get("status") + control_lines.append(f"- `{name}`: `{status}`") + control_block = "\n".join(control_lines) if control_lines else "- No control results recorded" + artifact_line = f"- Run: {run_url}" if run_url else "- Run: local or unavailable" + return f""" +### BeforeWire Agent Readiness + +Status: `{summary.get("status")}` + +- Packets: `{summary.get("passed")}/{summary.get("packets_total")}` passed, `{summary.get("failed")}` failed +- Receipt hash: `{receipt.get("receipt_hash")}` +- Repository: `{repo}` +- PR: `#{pr_number}` +- CI event: `{ci.get("github_event_name")}` +- CI SHA: `{ci.get("github_sha")}` +{artifact_line} + +Controls: +{control_block} + +Broker proof: +- Approval receipt: `receipts/pr-comment-approval-receipt.json` +- Idempotency key: `{idempotency_key}` +- Broker target: `github.issue_comment` +""" + + +def build_approval_receipt( + receipt: dict[str, Any], + repo: str, + pr_number: str, + body: str, + idempotency_key: str, +) -> dict[str, Any]: + approved_action = { + "type": "github.issue_comment.upsert", + "repo": repo, + "pr_number": pr_number, + "target": "github.issue_comment", + "receipt_hash": receipt.get("receipt_hash"), + "body_hash": sha256_text(body), + "idempotency_key": idempotency_key, + } + action_digest = sha256_text(json.dumps(approved_action, sort_keys=True, separators=(",", ":"))) + payload = { + "schema": "beforewire.broker-approval-receipt.v1", + "generated_at": utc_now(), + "approval_type": "policy_approval", + "approver": "github-actions:beforewire-agent-gate", + "policy": { + "name": "low-risk-pr-readiness-summary", + "constraints": [ + "target must be github.issue_comment", + "comment body must be derived from the verified readiness receipt", + "comment must include the stable BeforeWire idempotency marker", + "no side effect other than creating or updating one PR comment is allowed", + ], + }, + "approved_action": approved_action, + "approved_action_digest": action_digest, + "ci_context": ci_context(), + } + payload["approval_receipt_hash"] = sha256_text(json.dumps(payload, sort_keys=True, separators=(",", ":"))) + return payload + + +def validate_approval(approval: dict[str, Any], receipt: dict[str, Any], repo: str, pr_number: str, body: str, idempotency_key: str) -> list[str]: + errors: list[str] = [] + action = approval.get("approved_action") or {} + expected = { + "type": "github.issue_comment.upsert", + "repo": repo, + "pr_number": pr_number, + "target": "github.issue_comment", + "receipt_hash": receipt.get("receipt_hash"), + "body_hash": sha256_text(body), + "idempotency_key": idempotency_key, + } + for key, value in expected.items(): + if action.get(key) != value: + errors.append(f"approved_action.{key} mismatch") + if approval.get("schema") != "beforewire.broker-approval-receipt.v1": + errors.append("approval schema mismatch") + if approval.get("approval_receipt_hash") != sha256_text(json.dumps({k: v for k, v in approval.items() if k != "approval_receipt_hash"}, sort_keys=True, separators=(",", ":"))): + errors.append("approval receipt hash mismatch") + return errors + + +def github_request(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any: + data = json.dumps(payload).encode("utf-8") if payload is not None else None + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "beforewire-agent-readiness-broker", + } + if payload is not None: + headers["Content-Type"] = "application/json" + req = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(req, timeout=30) as resp: + text = resp.read().decode("utf-8") + return json.loads(text) if text else {} + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API {method} {url} failed: HTTP {exc.code}: {detail}") from exc + + +def upsert_comment(repo: str, pr_number: str, token: str, body: str, idempotency_key: str) -> dict[str, Any]: + api = "https://api.github.com" + marker = f"" + comments = github_request("GET", f"{api}/repos/{repo}/issues/{pr_number}/comments?per_page=100", token) + existing = None + for comment in comments: + if isinstance(comment, dict) and marker in str(comment.get("body") or ""): + existing = comment + break + if existing: + updated = github_request("PATCH", f"{api}/repos/{repo}/issues/comments/{existing['id']}", token, {"body": body}) + return {"operation": "updated", "comment_id": updated.get("id"), "comment_url": updated.get("html_url")} + created = github_request("POST", f"{api}/repos/{repo}/issues/{pr_number}/comments", token, {"body": body}) + return {"operation": "created", "comment_id": created.get("id"), "comment_url": created.get("html_url")} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Post a PR readiness summary through a receipt-backed side-effect broker") + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "beforewire/forkcell")) + parser.add_argument("--pr-number", default=os.environ.get("GITHUB_PR_NUMBER") or "") + parser.add_argument("--receipt", default="receipts/readiness-receipt.json") + parser.add_argument("--approval-output", default="receipts/pr-comment-approval-receipt.json") + parser.add_argument("--output", default="results/pr-comment-broker-results.json") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + receipt_path = PACK_ROOT / args.receipt + receipt = read_json(receipt_path) + pr_number = str(args.pr_number) + if not pr_number: + pr_number = str((receipt.get("ci_context") or {}).get("github_pr_number") or "") + if not pr_number: + raise SystemExit("missing PR number") + if (receipt.get("summary") or {}).get("status") != "pass": + raise SystemExit("readiness receipt must pass before broker commit") + + idempotency_key = make_idempotency_key(args.repo, pr_number) + body = render_comment(receipt, args.repo, pr_number, idempotency_key) + approval = build_approval_receipt(receipt, args.repo, pr_number, body, idempotency_key) + approval_path = PACK_ROOT / args.approval_output + write_json(approval_path, approval) + approval_errors = validate_approval(approval, receipt, args.repo, pr_number, body, idempotency_key) + + broker_result: dict[str, Any] = { + "schema": "beforewire.pr-comment-broker-results.v1", + "generated_at": utc_now(), + "repo": args.repo, + "pr_number": pr_number, + "target": "github.issue_comment", + "idempotency_key": idempotency_key, + "receipt_hash": receipt.get("receipt_hash"), + "body_hash": sha256_text(body), + "approval_receipt_ref": { + "path": rel(approval_path), + "hash": approval.get("approval_receipt_hash"), + }, + "approval_valid": not approval_errors, + "approval_errors": approval_errors, + "external_call_performed": False, + "operation": "dry_run" if args.dry_run else "pending", + "ci_context": ci_context(), + } + + if approval_errors: + broker_result["status"] = "fail" + elif args.dry_run: + broker_result["status"] = "pass" + broker_result["comment_preview"] = body + else: + token = os.environ.get("GITHUB_TOKEN") + if not token: + broker_result.update({"status": "fail", "operation": "skipped", "reason": "GITHUB_TOKEN missing"}) + else: + commit = upsert_comment(args.repo, pr_number, token, body, idempotency_key) + broker_result.update(commit) + broker_result["external_call_performed"] = True + broker_result["status"] = "pass" + + output_path = PACK_ROOT / args.output + write_json(output_path, broker_result) + print(json.dumps({k: broker_result.get(k) for k in ["status", "operation", "external_call_performed", "idempotency_key", "comment_url"]}, indent=2)) + return 0 if broker_result.get("status") == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agent-readiness-pack/bin/run_acceptance.py b/examples/agent-readiness-pack/bin/run_acceptance.py index f9cf70d..2435e60 100755 --- a/examples/agent-readiness-pack/bin/run_acceptance.py +++ b/examples/agent-readiness-pack/bin/run_acceptance.py @@ -51,6 +51,7 @@ def main() -> int: parser = argparse.ArgumentParser(description="Run full BeforeWire readiness acceptance") parser.add_argument("--repo", default="../..") parser.add_argument("--github-repo", default="beforewire/forkcell") + parser.add_argument("--pr-number", default="1") parser.add_argument("--skip-openshell-live", action="store_true") parser.add_argument( "--allow-external-unavailable", @@ -84,6 +85,18 @@ def main() -> int: ), ("readiness_pack_final", [py, "bin/run_readiness_pack.py", "--repo", args.repo]), ("receipt_verify", [py, "bin/verify_readiness_receipt.py", "receipts/readiness-receipt.json"]), + ( + "pr_comment_broker_dryrun", + [ + py, + "bin/post_pr_readiness_comment.py", + "--repo", + args.github_repo, + "--pr-number", + args.pr_number, + "--dry-run", + ], + ), ] ) results = [run(name, cmd) for name, cmd in steps] @@ -96,6 +109,7 @@ def main() -> int: "live_action_packets": read_status("results/live-action-packet-results.json"), "replay_fixture": read_status("results/replay-fixture-results.json"), "tamper_negative": read_status("results/tamper-negative-results.json"), + "pr_comment_broker": read_status("results/pr-comment-broker-results.json"), "openshell_live_smoke": read_status("results/openshell-live-smoke.json"), "branch_protection_gate": read_status("results/branch-protection-gate.json"), } @@ -115,6 +129,7 @@ def main() -> int: "live_action_packets", "replay_fixture", "tamper_negative", + "pr_comment_broker", ] ) and (args.skip_openshell_live or status_map.get("openshell_live_smoke") == "pass"), diff --git a/examples/agent-readiness-pack/bin/verify_github_shadow_gate.py b/examples/agent-readiness-pack/bin/verify_github_shadow_gate.py index 5b117a8..e1a07c0 100755 --- a/examples/agent-readiness-pack/bin/verify_github_shadow_gate.py +++ b/examples/agent-readiness-pack/bin/verify_github_shadow_gate.py @@ -61,10 +61,12 @@ def verify(path: Path) -> dict[str, Any]: checks["has_pull_request_or_manual_trigger"] = isinstance(on_block, dict) and ( "pull_request" in on_block or "workflow_dispatch" in on_block ) - checks["contents_read_only"] = permissions == {"contents": "read"} + checks["least_privilege_permissions"] = permissions == {"contents": "read", "issues": "write", "pull-requests": "read"} checks["bootstraps_readiness_pack"] = "bootstrap_readiness_pack.py" in all_runs checks["generates_pr_specific_receipt"] = "bin/run_readiness_pack.py --repo ../.." in all_runs checks["verifies_readiness_receipt"] = "verify_readiness_receipt.py" in all_runs + checks["posts_receipt_pr_comment"] = "post_pr_readiness_comment.py" in all_runs and "GITHUB_TOKEN" in text + checks["comment_limited_to_same_repo_prs"] = "github.event.pull_request.head.repo.full_name == github.repository" in text checks["uploads_readiness_artifact"] = "actions/upload-artifact@v4" in text checks["does_not_configure_branch_protection"] = "branch_protection" not in text and "required_status_checks" not in text checks["job_is_blocking_when_required"] = True diff --git a/examples/agent-readiness-pack/github/beforewire-agent-gate.yml b/examples/agent-readiness-pack/github/beforewire-agent-gate.yml index cd40da8..d0b23fb 100644 --- a/examples/agent-readiness-pack/github/beforewire-agent-gate.yml +++ b/examples/agent-readiness-pack/github/beforewire-agent-gate.yml @@ -6,6 +6,8 @@ on: permissions: contents: read + issues: write + pull-requests: read jobs: verify-agent-readiness: @@ -29,6 +31,17 @@ jobs: run: | cd examples/agent-readiness-pack python bin/verify_readiness_receipt.py receipts/readiness-receipt.json + - name: Post readiness PR comment + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + cd examples/agent-readiness-pack + python bin/post_pr_readiness_comment.py \ + --repo "${{ github.repository }}" \ + --pr-number "${{ github.event.pull_request.number }}" \ + --receipt receipts/readiness-receipt.json - name: Upload readiness artifacts uses: actions/upload-artifact@v4 if: always() From 1c236ca3802985366d2b114f72c0cc464f1fe5ff Mon Sep 17 00:00:00 2001 From: senseb <446326+senseb@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:49:39 +0800 Subject: [PATCH 2/2] Handle restricted workflow comment tokens --- .github/workflows/beforewire-agent-gate.yml | 3 ++- .../bin/post_pr_readiness_comment.py | 26 ++++++++++++++++--- .../github/beforewire-agent-gate.yml | 3 ++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/beforewire-agent-gate.yml b/.github/workflows/beforewire-agent-gate.yml index d0b23fb..a6d7979 100644 --- a/.github/workflows/beforewire-agent-gate.yml +++ b/.github/workflows/beforewire-agent-gate.yml @@ -41,7 +41,8 @@ jobs: python bin/post_pr_readiness_comment.py \ --repo "${{ github.repository }}" \ --pr-number "${{ github.event.pull_request.number }}" \ - --receipt receipts/readiness-receipt.json + --receipt receipts/readiness-receipt.json \ + --allow-permission-skip - name: Upload readiness artifacts uses: actions/upload-artifact@v4 if: always() diff --git a/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py b/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py index 9b5aa26..bb53d58 100755 --- a/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py +++ b/examples/agent-readiness-pack/bin/post_pr_readiness_comment.py @@ -200,6 +200,11 @@ def main() -> int: parser.add_argument("--approval-output", default="receipts/pr-comment-approval-receipt.json") parser.add_argument("--output", default="results/pr-comment-broker-results.json") parser.add_argument("--dry-run", action="store_true") + parser.add_argument( + "--allow-permission-skip", + action="store_true", + help="Return pass with a skipped operation when the GitHub token cannot write issue comments.", + ) args = parser.parse_args() receipt_path = PACK_ROOT / args.receipt @@ -249,10 +254,23 @@ def main() -> int: if not token: broker_result.update({"status": "fail", "operation": "skipped", "reason": "GITHUB_TOKEN missing"}) else: - commit = upsert_comment(args.repo, pr_number, token, body, idempotency_key) - broker_result.update(commit) - broker_result["external_call_performed"] = True - broker_result["status"] = "pass" + try: + commit = upsert_comment(args.repo, pr_number, token, body, idempotency_key) + broker_result.update(commit) + broker_result["external_call_performed"] = True + broker_result["status"] = "pass" + except RuntimeError as exc: + if args.allow_permission_skip and "Resource not accessible by integration" in str(exc): + broker_result.update( + { + "status": "pass", + "operation": "skipped_permission_denied", + "reason": "workflow token cannot write issue comments", + "external_call_performed": False, + } + ) + else: + broker_result.update({"status": "fail", "operation": "failed", "reason": str(exc)}) output_path = PACK_ROOT / args.output write_json(output_path, broker_result) diff --git a/examples/agent-readiness-pack/github/beforewire-agent-gate.yml b/examples/agent-readiness-pack/github/beforewire-agent-gate.yml index d0b23fb..a6d7979 100644 --- a/examples/agent-readiness-pack/github/beforewire-agent-gate.yml +++ b/examples/agent-readiness-pack/github/beforewire-agent-gate.yml @@ -41,7 +41,8 @@ jobs: python bin/post_pr_readiness_comment.py \ --repo "${{ github.repository }}" \ --pr-number "${{ github.event.pull_request.number }}" \ - --receipt receipts/readiness-receipt.json + --receipt receipts/readiness-receipt.json \ + --allow-permission-skip - name: Upload readiness artifacts uses: actions/upload-artifact@v4 if: always()