diff --git a/README.md b/README.md index 78006e5..f423f55 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,21 @@ main. The payload is offline and unqualified. It does not call GitHub or a CLI, credential, change a repository or request, grant authority or qualification, or activate a profile. +## Inactive GitHub Actions CI normalizer payload + +`adapters/github-actions-ci/v1/normalize.jq` validates one untrusted GitHub +Actions workflow and check snapshot against caller-supplied repository, suite, +workflow, run, attempt, job, check, app, head, base, time, instruction, config, and +execution-boundary bindings. It returns a canonical generic observation for +queued, running, passed, failed, cancelled, timed out, action-required, stale, +or inconclusive state. Provider names, text, and details stay opaque data. + +This PR lands only the immutable normalizer payload. A later assembly PR can add +its manifest and default-set wiring after this payload has a durable commit on +main. The payload is offline and unqualified. It does not call GitHub or a CLI, +use a credential, rerun or cancel work, dispatch a workflow, change a repository, +grant authority or qualification, or activate a profile. + ## The current default team You talk **only** to yshifu, in a Claude Code session. yshifu orchestrates the other roles diff --git a/RESTORE.md b/RESTORE.md index e872d00..70877ca 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -464,6 +464,21 @@ durable main commit and add default-set wiring. The pure jq payload is offline a unqualified. It does not call GitHub, use a credential, change a repository or request, grant authority or qualification, or activate a profile. +Restore the two paths in the manifest's inactive GitHub Actions CI normalizer +payload block, then run: + +```sh +bash scripts/test/default-github-actions-ci-adapter.test.sh +``` + +This checks exact caller bindings, deterministic workflow and job-state +normalization, opaque provider data, and fail-closed malformed, contradictory, +incomplete, or stale input. This stage intentionally has no adapter manifest. A +later assembly PR can bind the payload through a durable main commit and add +default-set wiring. The pure jq payload is offline and unqualified. It does not +call GitHub, use a credential, rerun, cancel, or dispatch work, change a +repository, grant authority or qualification, or activate a profile. + --- ## 5. Smoke test — prove the rebuilt team is alive diff --git a/adapters/github-actions-ci/v1/normalize.jq b/adapters/github-actions-ci/v1/normalize.jq new file mode 100644 index 0000000..2fad7d2 --- /dev/null +++ b/adapters/github-actions-ci/v1/normalize.jq @@ -0,0 +1,243 @@ +def exact_fields($required; $optional): + . as $value | + type == "object" and + ((keys_unsorted - ($required + $optional)) | length) == 0 and + all($required[]; . as $key | $value | has($key)); +def id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); +def content_id_ok: + id_ok and (contains(":") | not) and (contains("/") | not); +def media_type_ok: + type == "string" and utf8bytelength <= 127 and + test("\\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\\z"); +def provider_id_ok: + type == "string" and test("\\A[1-9][0-9]{0,19}\\z"); +def run_attempt_ok: + type == "number" and . == floor and . >= 1 and . <= 1000000; +def sha256_ok: + type == "string" and test("\\A[0-9a-f]{64}\\z"); +def time_ok: + type == "string" and + test("\\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\\z") and + (capture("\\A(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})T(?[0-9]{2}):(?[0-9]{2}):(?[0-9]{2})Z\\z") | + map_values(tonumber)) as $t | + ($t.y % 4 == 0 and ($t.y % 100 != 0 or $t.y % 400 == 0)) as $leap | + [31,(if $leap then 29 else 28 end),31,30,31,30,31,31,30,31,30,31] as $days | + $t.m >= 1 and $t.m <= 12 and $t.d >= 1 and $t.d <= $days[$t.m - 1] and + $t.h >= 0 and $t.h <= 23 and $t.i >= 0 and $t.i <= 59 and + $t.s >= 0 and $t.s <= 59; +def revision_ok: + exact_fields(["repository_id","hash_algorithm","commit_id"];[]) and + (.repository_id | id_ok) and + (.hash_algorithm == "sha1" or .hash_algorithm == "sha256") and + (if .hash_algorithm == "sha1" then (.commit_id | test("\\A[0-9a-f]{40}\\z")) + else (.commit_id | test("\\A[0-9a-f]{64}\\z")) end); +def content_ref_ok: + exact_fields(["content_id","media_type","sha256"];[]) and + (.content_id | content_id_ok) and (.media_type | media_type_ok) and + (.sha256 | sha256_ok); +def job_identity_ok: + exact_fields(["job_id","check_run_id"];[]) and + (.job_id | provider_id_ok) and (.check_run_id | provider_id_ok); +def job_key: [(.job_id | length),.job_id,(.check_run_id | length),.check_run_id]; +def ordered_unique: + . as $items | ($items | map(job_key)) as $keys | + $keys == ($keys | sort) and ($keys | length) == ($keys | unique | length) and + (($items | map(.job_id) | unique | length) == ($items | length)) and + (($items | map(.check_run_id) | unique | length) == ($items | length)); +def expected_jobs_ok: + type == "array" and length >= 1 and length <= 128 and + all(.[]; job_identity_ok) and ordered_unique; +def trust_context_ok: + exact_fields( + ["expected_repository_id","expected_check_suite_id","expected_workflow_id", + "expected_run_id","expected_run_attempt","expected_github_app_id", + "expected_head","expected_base","expected_jobs","observation_time", + "instruction_ref","config_ref","execution_boundary_id"]; + []) and + all([.expected_repository_id,.expected_check_suite_id,.expected_workflow_id, + .expected_run_id,.expected_github_app_id][]; provider_id_ok) and + (.expected_run_attempt | run_attempt_ok) and + (.expected_head | revision_ok) and (.expected_base | revision_ok) and + .expected_head.repository_id == .expected_base.repository_id and + (.expected_jobs | expected_jobs_ok) and (.observation_time | time_ok) and + (.instruction_ref | content_ref_ok) and (.config_ref | content_ref_ok) and + (.execution_boundary_id | id_ok); +def opaque_text_ok: . == null or (type == "string" and utf8bytelength <= 8192); +def provider_data_ok: + exact_fields(["name","text","details_url"];[]) and + all([.name,.text,.details_url][]; opaque_text_ok); +def queued_status: IN("queued","pending","requested","waiting"); +def status_ok: queued_status or . == "in_progress" or . == "completed"; +def conclusion_ok: + IN("success","failure","neutral","cancelled","skipped","timed_out", + "action_required","stale"); +def status_conclusion_ok: + if .status == "completed" then (.conclusion | conclusion_ok) + else .conclusion == null end; +def job_time_ok($snapshot): + (if $snapshot.status == "completed" then $snapshot.completed_at + else $snapshot.observed_at end) as $upper | + (.created_at | time_ok) and $snapshot.created_at <= .created_at and + .created_at <= $upper and + if (.status | queued_status) then .started_at == null and .completed_at == null + elif .status == "in_progress" then + (.started_at | time_ok) and $snapshot.started_at <= .started_at and + .created_at <= .started_at and + .started_at <= $upper and .completed_at == null + else + (.started_at | time_ok) and (.completed_at | time_ok) and + $snapshot.started_at <= .started_at and .created_at <= .started_at and + .started_at <= .completed_at and + .completed_at <= $upper + end; +def job_ok($snapshot): + exact_fields( + ["job_id","check_run_id","status","conclusion","created_at","started_at", + "completed_at","payload_sha256","provider_data"]; + []) and (.job_id | provider_id_ok) and (.check_run_id | provider_id_ok) and + (.status | status_ok) and status_conclusion_ok and + job_time_ok($snapshot) and (.payload_sha256 | sha256_ok) and + (.provider_data | provider_data_ok); +def run_time_ok: + (.created_at | time_ok) and (.updated_at | time_ok) and + .created_at <= .updated_at and .updated_at <= .observed_at and + if (.status | queued_status) then .started_at == null and .completed_at == null + elif .status == "in_progress" then + (.started_at | time_ok) and .created_at <= .started_at and + .started_at <= .updated_at and .completed_at == null + else + (.started_at | time_ok) and (.completed_at | time_ok) and + .created_at <= .started_at and .started_at <= .completed_at and + .completed_at <= .updated_at + end; +def count_ok: type == "number" and . == floor and . >= 0 and . <= 128; +def jobs_ok($snapshot): + type == "array" and length <= 128 and + all(.[]; job_ok($snapshot)) and ordered_unique and + $snapshot.reported_job_count == length + $snapshot.hidden_job_count and + (if $snapshot.complete then $snapshot.hidden_job_count == 0 else true end); +def snapshot_ok: + . as $snapshot | + exact_fields( + ["repository_id","check_suite_id","workflow_id","run_id","run_attempt", + "github_app_id","head","base","observed_at","complete","reported_job_count", + "hidden_job_count","status","conclusion","created_at","updated_at", + "started_at","completed_at","jobs","payload_sha256","provider_data"]; + []) and + all([.repository_id,.check_suite_id,.workflow_id,.run_id,.github_app_id][]; + provider_id_ok) and + (.run_attempt | run_attempt_ok) and + (.head | revision_ok) and (.base | revision_ok) and + .head.repository_id == .base.repository_id and (.observed_at | time_ok) and + (.complete | type == "boolean") and (.reported_job_count | count_ok) and + (.hidden_job_count | count_ok) and (.status | status_ok) and + status_conclusion_ok and run_time_ok and (.jobs | jobs_ok($snapshot)) and + (.payload_sha256 | sha256_ok) and (.provider_data | provider_data_ok); +def jobs_bound_ok($context; $snapshot): + ($context.expected_jobs) as $expected | + ($snapshot.jobs | map({job_id,check_run_id})) as $actual | + all($actual[]; . as $identity | $expected | index($identity) != null) and + (if $snapshot.complete then $actual == $expected else true end); +def children_agree($snapshot): + ($snapshot.jobs) as $jobs | + if ($snapshot.status | queued_status) then all($jobs[]; .status | queued_status) + elif $snapshot.status == "in_progress" then + all($jobs[]; .status | status_ok) and + (if $snapshot.complete then any($jobs[]; .status != "completed") else true end) + elif (all($jobs[]; .status == "completed") | not) then false + elif $snapshot.complete == false then true + elif $snapshot.conclusion == "success" then + any($jobs[]; .conclusion == "success") and + all($jobs[]; .conclusion | IN("success","neutral","skipped")) + elif $snapshot.conclusion == "failure" then any($jobs[]; .conclusion == "failure") + elif $snapshot.conclusion == "cancelled" then any($jobs[]; .conclusion == "cancelled") + elif $snapshot.conclusion == "timed_out" then any($jobs[]; .conclusion == "timed_out") + elif $snapshot.conclusion == "action_required" then + any($jobs[]; .conclusion == "action_required") + elif $snapshot.conclusion == "stale" then any($jobs[]; .conclusion == "stale") + elif $snapshot.conclusion == "neutral" then any($jobs[]; .conclusion == "neutral") + else all($jobs[]; .conclusion == "skipped") + end; +def stale_bindings($context; $snapshot): + [ + if $snapshot.github_app_id != $context.expected_github_app_id then "app" else empty end, + if $snapshot.base != $context.expected_base then "base" else empty end, + if $snapshot.check_suite_id != $context.expected_check_suite_id then "check-suite" else empty end, + if $snapshot.head != $context.expected_head then "head" else empty end, + if $snapshot.observed_at != $context.observation_time then "observation-time" else empty end, + if $snapshot.repository_id != $context.expected_repository_id then "repository" else empty end, + if $snapshot.run_id != $context.expected_run_id then "run" else empty end, + if $snapshot.run_attempt != $context.expected_run_attempt then "run-attempt" else empty end, + if $snapshot.workflow_id != $context.expected_workflow_id then "workflow" else empty end + ]; +def normalized($context; $snapshot; $stale): + if ($stale | length) > 0 then ["stale","ci.binding-stale"] + elif $snapshot.complete == false or + ($snapshot.jobs | length) != ($context.expected_jobs | length) then + ["inconclusive","ci.observation-incomplete"] + elif ($snapshot.status | queued_status) then ["queued","ci.queued"] + elif $snapshot.status == "in_progress" then ["in-progress","ci.in-progress"] + elif $snapshot.conclusion == "success" then ["passed","ci.passed"] + elif $snapshot.conclusion == "failure" then ["failed","ci.failed"] + elif $snapshot.conclusion == "cancelled" then ["cancelled","ci.cancelled"] + elif $snapshot.conclusion == "timed_out" then ["timed-out","ci.timed-out"] + elif $snapshot.conclusion == "action_required" then ["action-required","ci.action-required"] + elif $snapshot.conclusion == "stale" then ["stale","ci.provider-stale"] + else ["inconclusive","ci.provider-inconclusive"] + end; +def fact_state: + if (.status | queued_status) then "queued" + elif .status == "in_progress" then "in-progress" + elif .conclusion == "success" then "passed" + elif .conclusion == "failure" then "failed" + elif .conclusion == "cancelled" then "cancelled" + elif .conclusion == "timed_out" then "timed-out" + elif .conclusion == "action_required" then "action-required" + elif .conclusion == "stale" then "stale" + else "inconclusive" end; +if (exact_fields(["trust_context","snapshot"];[]) | not) then + error("github-actions-ci.invalid-envelope") +elif (.trust_context | trust_context_ok) == false then + error("github-actions-ci.invalid-trust-context") +elif (.snapshot | snapshot_ok) == false then + error("github-actions-ci.invalid-snapshot") +elif jobs_bound_ok(.trust_context;.snapshot) == false or children_agree(.snapshot) == false then + error("github-actions-ci.provider-contradiction") +else + .trust_context as $context | .snapshot as $snapshot | + stale_bindings($context;$snapshot) as $stale | + normalized($context;$snapshot;$stale) as $normalized | + { + schema_version:1, + kind:"adapter_observation", + adapter:{id:"adapter.github-actions-ci.v1",version:"v1",status:"inactive"}, + state:$normalized[0], + reason_id:$normalized[1], + stale_bindings:$stale, + trust_context:$context, + observation:$snapshot, + result:{ + state:$normalized[0], + observed_at:$snapshot.observed_at, + subject:{head:$snapshot.head,base:$snapshot.base}, + provenance:{ + repository_id:$snapshot.repository_id, + workflow_id:$snapshot.workflow_id, + run_id:$snapshot.run_id, + run_attempt:$snapshot.run_attempt, + check_suite_id:$snapshot.check_suite_id + }, + source_sha256:$snapshot.payload_sha256, + facts:($snapshot.jobs | map({ + fact_id:("ci.job." + .job_id + ".check." + .check_run_id), + state:fact_state, + source_sha256:.payload_sha256, + provider_data:.provider_data + })) + }, + authority:"none", + qualification:{state:"unavailable",reason_id:"adapter.unqualified"}, + effects:[] + } +end diff --git a/ci/required-files.txt b/ci/required-files.txt index 64bb6b8..e93e1c9 100644 --- a/ci/required-files.txt +++ b/ci/required-files.txt @@ -232,3 +232,7 @@ scripts/test/orchestrator-reconciliation-plan.test.sh # Inactive GitHub forge normalizer payload adapters/github-forge/v1/normalize.jq scripts/test/default-github-forge-adapter.test.sh + +# Inactive GitHub Actions CI normalizer payload +adapters/github-actions-ci/v1/normalize.jq +scripts/test/default-github-actions-ci-adapter.test.sh diff --git a/scripts/test/default-github-actions-ci-adapter.test.sh b/scripts/test/default-github-actions-ci-adapter.test.sh new file mode 100755 index 0000000..a91d3fb --- /dev/null +++ b/scripts/test/default-github-actions-ci-adapter.test.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +normalizer="$root/adapters/github-actions-ci/v1/normalize.jq" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/ystack-github-actions-ci.XXXXXX")" +trap 'rm -rf -- "$tmp"' EXIT + +sha256_path() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +platform="$(uname -s):$(uname -m)" +case "$platform" in + Linux:x86_64) + asset=jq-linux64 + asset_sha256=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 + ;; + Darwin:x86_64|Darwin:arm64) + asset=jq-osx-amd64 + asset_sha256=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef + ;; + *) printf 'FAIL: unsupported jq 1.6 proof platform: %s\n' "$platform" >&2; exit 1 ;; +esac + +candidate="${TMPDIR:-/tmp}/ystack-portable-core-jq16/$asset" +if [ -f "$candidate" ] && [ "$(sha256_path "$candidate")" = "$asset_sha256" ]; then + jq_command=("$candidate") + [ "$platform" != Darwin:arm64 ] || jq_command=(/usr/bin/arch -x86_64 "$candidate") +elif candidate="$(command -v jq 2>/dev/null)" && + [ "$("$candidate" --version 2>/dev/null)" = jq-1.6 ]; then + jq_command=("$candidate") +else + echo 'FAIL: verified jq 1.6 is required' >&2 + exit 1 +fi +[ "$("${jq_command[@]}" --version)" = jq-1.6 ] || { + echo 'FAIL: jq 1.6 identity' >&2 + exit 1 +} + +total=0 +pass() { total=$((total + 1)); } +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } +run() { "${jq_command[@]}" -S -c -f "$normalizer" "$1"; } +mutate() { "${jq_command[@]}" -S -c "$2" "$root_input" >"$1"; } + +expect_state() { + local name=$1 state=$2 filter=$3 + local input="$tmp/$name.json" output="$tmp/$name.out" + mutate "$input" "$filter" + if ! run "$input" >"$output" 2>"$tmp/$name.err"; then + cat "$tmp/$name.err" >&2 + fail "$name rejected" + fi + [ ! -s "$tmp/$name.err" ] || fail "$name stderr" + "${jq_command[@]}" -e --arg state "$state" '.state == $state and .result.state == $state' \ + "$output" >/dev/null || fail "$name state" + pass +} + +expect_stale() { + local name=$1 binding=$2 filter=$3 + local input="$tmp/$name.json" output="$tmp/$name.out" + mutate "$input" "$filter" + if ! run "$input" >"$output" 2>"$tmp/$name.err"; then + cat "$tmp/$name.err" >&2 + fail "$name rejected" + fi + "${jq_command[@]}" -e --arg binding "$binding" \ + '.state == "stale" and .stale_bindings == [$binding]' "$output" >/dev/null || + fail "$name stale binding" + pass +} + +expect_reject() { + local name=$1 filter=$2 + local input="$tmp/$name.json" + mutate "$input" "$filter" + [ -s "$input" ] || fail "$name mutation" + if run "$input" >"$tmp/$name.out" 2>"$tmp/$name.err"; then + cat "$tmp/$name.out" >&2 + cat "$tmp/$name.err" >&2 + fail "$name accepted" + fi + [ ! -s "$tmp/$name.out" ] && [ -s "$tmp/$name.err" ] || fail "$name channel" + pass +} + +root_input="$tmp/base.json" +"${jq_command[@]}" -S -c -n ' + def rev($digit): + {repository_id:"repo.target",hash_algorithm:"sha1",commit_id:($digit * 40)}; + def ref($id;$digit): + {content_id:$id,media_type:"application/json",sha256:($digit * 64)}; + def identity($job;$check): {job_id:$job,check_run_id:$check}; + def data($name): {name:$name,text:null,details_url:"https://example.invalid/detail"}; + { + trust_context:{ + expected_repository_id:"1270665750",expected_check_suite_id:"300", + expected_workflow_id:"400",expected_run_id:"500",expected_run_attempt:2, + expected_github_app_id:"15368", + expected_head:rev("1"),expected_base:rev("2"), + expected_jobs:[identity("10";"100"),identity("20";"200")], + observation_time:"2026-09-02T10:05:00Z", + instruction_ref:ref("ci.instructions";"a"),config_ref:ref("ci.config";"b"), + execution_boundary_id:"boundary.ci.default" + }, + snapshot:{ + repository_id:"1270665750",check_suite_id:"300",workflow_id:"400",run_id:"500", + run_attempt:2,github_app_id:"15368",head:rev("1"),base:rev("2"), + observed_at:"2026-09-02T10:05:00Z",complete:true, + reported_job_count:2,hidden_job_count:0,status:"completed",conclusion:"success", + created_at:"2026-09-02T10:00:00Z",updated_at:"2026-09-02T10:04:00Z", + started_at:"2026-09-02T10:01:00Z",completed_at:"2026-09-02T10:04:00Z", + jobs:[ + identity("10";"100") + {status:"completed",conclusion:"success", + created_at:"2026-09-02T10:00:30Z",started_at:"2026-09-02T10:01:00Z", + completed_at:"2026-09-02T10:02:00Z", + payload_sha256:("c"*64),provider_data:data("build")}, + identity("20";"200") + {status:"completed",conclusion:"skipped", + created_at:"2026-09-02T10:01:30Z",started_at:"2026-09-02T10:02:00Z", + completed_at:"2026-09-02T10:03:00Z", + payload_sha256:("d"*64),provider_data:data("optional")} + ], + payload_sha256:("e"*64),provider_data:data("workflow") + } + } +' >"$root_input" + +for spec in \ + 'passed|passed|.' \ + 'queued|queued|.snapshot.status="queued"|.snapshot.conclusion=null|.snapshot.started_at=null|.snapshot.completed_at=null|.snapshot.jobs|=map(.status="queued"|.conclusion=null|.started_at=null|.completed_at=null)' \ + 'in-progress|in-progress|.snapshot.status="in_progress"|.snapshot.conclusion=null|.snapshot.completed_at=null|.snapshot.jobs[0].status="in_progress"|.snapshot.jobs[0].conclusion=null|.snapshot.jobs[0].completed_at=null|.snapshot.jobs[1].status="queued"|.snapshot.jobs[1].conclusion=null|.snapshot.jobs[1].started_at=null|.snapshot.jobs[1].completed_at=null' \ + 'failed-all-completed-mixed|failed|.snapshot.conclusion="failure"|.snapshot.jobs[0].conclusion="failure"' \ + 'cancelled-all-completed-mixed|cancelled|.snapshot.conclusion="cancelled"|.snapshot.jobs[0].conclusion="cancelled"' \ + 'timed-out-all-completed-mixed|timed-out|.snapshot.conclusion="timed_out"|.snapshot.jobs[0].conclusion="timed_out"' \ + 'action-required-all-completed-mixed|action-required|.snapshot.conclusion="action_required"|.snapshot.jobs[0].conclusion="action_required"' \ + 'provider-stale-all-completed-mixed|stale|.snapshot.conclusion="stale"|.snapshot.jobs[0].conclusion="stale"' \ + 'neutral-all-completed-mixed|inconclusive|.snapshot.conclusion="neutral"|.snapshot.jobs[0].conclusion="neutral"' \ + 'job-start-boundary|passed|.snapshot.started_at="2026-09-02T10:00:40Z"|.snapshot.jobs[0].started_at=.snapshot.started_at' \ + 'attempt-min|passed|.trust_context.expected_run_attempt=1|.snapshot.run_attempt=1' \ + 'attempt-max|passed|.trust_context.expected_run_attempt=1000000|.snapshot.run_attempt=1000000' \ + 'media-type-127|passed|.trust_context.instruction_ref.media_type=("application/"+("x"*115))|.trust_context.config_ref.media_type=("application/"+("y"*115))'; do + IFS='|' read -r name state filter <<<"$spec" + expect_state "$name" "$state" "$filter" +done + +expect_state incomplete inconclusive \ + '.snapshot.complete=false|.snapshot.hidden_job_count=1|.snapshot.jobs=.snapshot.jobs[0:1]' +expect_state incomplete-in-progress inconclusive \ + '.snapshot.complete=false|.snapshot.reported_job_count=3|.snapshot.hidden_job_count=1|.snapshot.status="in_progress"|.snapshot.conclusion=null|.snapshot.completed_at=null' +"${jq_command[@]}" -e \ + '.state=="inconclusive" and .reason_id=="ci.observation-incomplete"' \ + "$tmp/incomplete-in-progress.out" >/dev/null || fail incomplete-in-progress-reason +pass + +for spec in \ + 'repository|repository|.snapshot.repository_id="999"' \ + 'run|run|.snapshot.run_id="999"' \ + 'run-attempt|run-attempt|.snapshot.run_attempt=3' \ + 'app|app|.snapshot.github_app_id="999"' \ + 'head|head|.snapshot.head.commit_id=("0"*40)' \ + 'base|base|.snapshot.base.commit_id=("0"*40)' \ + 'observation-time|observation-time|.snapshot.observed_at="2026-09-02T10:06:00Z"'; do + IFS='|' read -r name binding filter <<<"$spec" + expect_stale "stale-$name" "$binding" "$filter" +done +"${jq_command[@]}" -e ' + .state=="stale" and .trust_context.expected_run_attempt==2 and + .observation.run_id=="500" and .observation.head.commit_id==("1"*40) and + .result.provenance.run_id=="500" and .result.provenance.run_attempt==3 +' "$tmp/stale-run-attempt.out" >/dev/null || fail stale-run-attempt-provenance +pass +expect_stale stale-attempt-before-incomplete run-attempt \ + '.snapshot.run_attempt=3|.snapshot.complete=false|.snapshot.reported_job_count=3|.snapshot.hidden_job_count=1|.snapshot.status="in_progress"|.snapshot.conclusion=null|.snapshot.completed_at=null' + +for spec in \ + 'malformed|{}' \ + 'missing-field|del(.snapshot.payload_sha256)' \ + 'unknown-status|.snapshot.status="mystery"' \ + 'unknown-fact|.snapshot.jobs[0].status="mystery"' \ + 'attempt-zero|.trust_context.expected_run_attempt=0' \ + 'attempt-fraction|.snapshot.run_attempt=1.5' \ + 'attempt-overflow|.snapshot.run_attempt=1000001' \ + 'duplicate-facts|.snapshot.jobs[1]=.snapshot.jobs[0]' \ + 'duplicate-job-id|.trust_context.expected_jobs[1].job_id="10"|.snapshot.jobs[1].job_id="10"' \ + 'duplicate-check-run-id|.trust_context.expected_jobs[1].check_run_id="100"|.snapshot.jobs[1].check_run_id="100"' \ + 'unsorted-facts|.snapshot.jobs|=reverse' \ + 'bad-digest|.snapshot.jobs[0].payload_sha256="no"' \ + 'colon-content-id|.trust_context.config_ref.content_id="bad:id"' \ + 'slash-content-id|.trust_context.instruction_ref.content_id="bad/id"' \ + 'media-type-128|.trust_context.config_ref.media_type=("application/"+("x"*116))' \ + 'media-type-syntax|.trust_context.instruction_ref.media_type="Application/json"' \ + 'content-ref-sha|.trust_context.instruction_ref.sha256=("A"*64)' \ + 'content-ref-extra|.trust_context.config_ref.extra=true' \ + 'content-ref-missing|del(.trust_context.instruction_ref.content_id)' \ + 'bad-boundary|.trust_context.execution_boundary_id="Bad Boundary"' \ + 'duplicate-expected|.trust_context.expected_jobs[1]=.trust_context.expected_jobs[0]' \ + 'unsorted-expected|.trust_context.expected_jobs|=reverse' \ + 'missing-complete-fact|.snapshot.reported_job_count=1|.snapshot.jobs=.snapshot.jobs[0:1]' \ + 'hidden-complete|.snapshot.hidden_job_count=1|.snapshot.reported_job_count=3' \ + 'bad-calendar|.snapshot.observed_at="2026-02-30T10:05:00Z"' \ + 'reversed-run-time|.snapshot.updated_at="2026-09-02T09:59:00Z"' \ + 'reversed-job-time|.snapshot.jobs[0].completed_at="2026-09-02T10:00:00Z"' \ + 'job-created-before-run|.snapshot.jobs[0].created_at="2026-09-02T09:59:59Z"' \ + 'job-start-before-create|.snapshot.jobs[0].created_at="2026-09-02T10:01:30Z"' \ + 'job-before-run-start|.snapshot.jobs[0].started_at="2026-09-02T10:00:40Z"|.snapshot.jobs[0].completed_at="2026-09-02T10:00:50Z"' \ + 'running-job-before-run-start|.snapshot.status="in_progress"|.snapshot.conclusion=null|.snapshot.completed_at=null|.snapshot.jobs[0].status="in_progress"|.snapshot.jobs[0].conclusion=null|.snapshot.jobs[0].started_at="2026-09-02T10:00:40Z"|.snapshot.jobs[0].completed_at=null|.snapshot.jobs[1].status="queued"|.snapshot.jobs[1].conclusion=null|.snapshot.jobs[1].started_at=null|.snapshot.jobs[1].completed_at=null' \ + 'job-complete-after-run|.snapshot.jobs[0].completed_at="2026-09-02T10:04:01Z"' \ + 'job-complete-after-observed|.snapshot.complete=false|.snapshot.reported_job_count=3|.snapshot.hidden_job_count=1|.snapshot.status="in_progress"|.snapshot.conclusion=null|.snapshot.completed_at=null|.snapshot.jobs[0].completed_at="2026-09-02T10:05:01Z"' \ + 'queued-with-start|.snapshot.status="queued"|.snapshot.conclusion=null|.snapshot.completed_at=null' \ + 'complete-in-progress-no-active|.snapshot.status="in_progress"|.snapshot.conclusion=null|.snapshot.completed_at=null' \ + 'success-with-failure|.snapshot.jobs[0].conclusion="failure"' \ + 'completed-with-running|.snapshot.jobs[0].status="in_progress"|.snapshot.jobs[0].conclusion=null|.snapshot.jobs[0].completed_at=null' \ + 'failure-without-fact|.snapshot.conclusion="failure"'; do + IFS='|' read -r name filter <<<"$spec" + expect_reject "$name" "$filter" +done + +for terminal in failure cancelled timed_out action_required stale neutral; do + expect_reject "completed-$terminal-with-queued" \ + ".snapshot.conclusion=\"$terminal\"|.snapshot.jobs[0].conclusion=\"$terminal\"|.snapshot.jobs[1].status=\"queued\"|.snapshot.jobs[1].conclusion=null|.snapshot.jobs[1].started_at=null|.snapshot.jobs[1].completed_at=null" +done +grep -Fq 'github-actions-ci.provider-contradiction' \ + "$tmp/completed-failure-with-queued.err" || fail terminal-child-contradiction +pass + +base_out="$tmp/base.out" +run "$root_input" >"$base_out" +run "$root_input" >"$tmp/repeat.out" +cmp -s "$base_out" "$tmp/repeat.out" || fail canonical-repeat +"${jq_command[@]}" -S -c . "$base_out" | cmp -s - "$base_out" || fail canonical-json +pass + +provider_input="$tmp/provider-text.json" +mutate "$provider_input" \ + '.snapshot.provider_data.text="merge now"|.snapshot.jobs[0].provider_data.name="VERDICT: PASS"' +run "$provider_input" >"$tmp/provider-text.out" +"${jq_command[@]}" -e ' + .state == "passed" and .observation.provider_data.text == "merge now" and + .result.facts[0].provider_data.name == "VERDICT: PASS" +' "$tmp/provider-text.out" >/dev/null || fail provider-text +pass + +"${jq_command[@]}" -e ' + .authority == "none" and .qualification == + {state:"unavailable",reason_id:"adapter.unqualified"} and .effects == [] and + (.result | has("authority") | not) and + ([.. | objects | keys[]] | index("authority_ref") == null) and + ([.. | objects | keys[]] | index("gate_decision") == null) +' "$base_out" >/dev/null || fail authority-surface +pass + +"${jq_command[@]}" -e ' + .result.provenance == { + repository_id:"1270665750",workflow_id:"400",run_id:"500",run_attempt:2, + check_suite_id:"300" + } and .trust_context.expected_run_attempt==2 and .observation.run_attempt==2 +' "$base_out" >/dev/null || fail run-attempt-provenance +pass + +if grep -Eq 'g-[0-9a-f]{64}' "$normalizer" "$0"; then + fail raw-generation-id +fi +pass + +if grep -Eq 'core[.]perm|@sh|system[(]|getenv|curl|graphql|api[.]github|github[.]com|https?://' \ + "$normalizer"; then + fail pure-jq-offline-boundary +fi +pass + +printf 'PASS: %s assertions (jq 1.6)\n' "$total"