diff --git a/.github/scripts/add-to-project/add-issue-or-pr.sh b/.github/scripts/add-to-project/add-issue-or-pr.sh index f77513cbc..78c4bb05f 100644 --- a/.github/scripts/add-to-project/add-issue-or-pr.sh +++ b/.github/scripts/add-to-project/add-issue-or-pr.sh @@ -42,7 +42,7 @@ _atp_lib_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" . "${_atp_lib_dir}/lib.sh" evaluate_noise_gate() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[evaluate_noise_gate] expected 1 arg (labels_json), got %d\n' "$#" >&2 return 64 fi @@ -111,7 +111,7 @@ evaluate_noise_gate() { # Thin wrapper over the shared lookup: find the project item linked to a # given issue/PR node id (or empty string if it isn't on the board). find_content_item_id() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[find_content_item_id] expected 1 arg (content_node_id), got %d\n' "$#" >&2 return 64 fi @@ -119,7 +119,7 @@ find_content_item_id() { } reconcile_content_with_project() { - if [ "$#" -ne 3 ]; then + if [[ "$#" -ne 3 ]]; then printf '[reconcile_content_with_project] expected 3 args (content_node_id content_url labels_json), got %d\n' "$#" >&2 return 64 fi @@ -155,7 +155,7 @@ reconcile_content_with_project() { fi local existing existing=$(find_content_item_id "${content_node_id}") - if [ -n "${existing}" ]; then + if [[ -n "${existing}" ]]; then printf 'Removing %s from %s (no longer qualifies: %s); item %s\n' \ "${content_url}" "${PROJECT_URL:-the project}" "${reason}" "${existing}" delete_project_item "${existing}" @@ -170,7 +170,7 @@ reconcile_content_with_project() { esac } -if [ "${BASH_SOURCE[0]}" = "${0}" ]; then +if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then reconcile_content_with_project \ "${CONTENT_NODE_ID:?CONTENT_NODE_ID is required}" \ "${CONTENT_URL:?CONTENT_URL is required}" \ diff --git a/.github/scripts/add-to-project/classify-initiative.sh b/.github/scripts/add-to-project/classify-initiative.sh index e9b4b6bdc..c7fbe171d 100755 --- a/.github/scripts/add-to-project/classify-initiative.sh +++ b/.github/scripts/add-to-project/classify-initiative.sh @@ -68,7 +68,7 @@ CI_THEME_FIELD_ID="" # would make the `dev-lead agent` rule match every single item. Only labels # that actually signal an initiative survive into the signature. normalize_signature() { - if [ "$#" -ne 3 ]; then + if [[ "$#" -ne 3 ]]; then printf '[normalize_signature] expected 3 args (title labels_json repo), got %d\n' "$#" >&2 return 64 fi @@ -97,21 +97,21 @@ normalize_signature() { # classify_by_rules → first-matching Initiative name, or "". classify_by_rules() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[classify_by_rules] expected 1 arg (signature), got %d\n' "$#" >&2 return 64 fi local sig="$1" name rx local rules="${RULES_FILE:-${_ci_dir}/initiative-rules.tsv}" - if [ ! -f "${rules}" ]; then + if [[ ! -f "${rules}" ]]; then printf '[classify_by_rules] rules file not found: %s\n' "${rules}" >&2 return 65 fi - while IFS=$'\t' read -r name rx || [ -n "${name}" ]; do + while IFS=$'\t' read -r name rx || [[ -n "${name}" ]]; do name="${name%$'\r'}" rx="${rx%$'\r'}" case "${name}" in ''|'#'*) continue ;; esac - [ -n "${rx}" ] || continue + [[ -n "${rx}" ]] || continue if printf '%s' "${sig}" | grep -Eiq -- "${rx}"; then printf '%s' "${name}" return 0 @@ -122,21 +122,21 @@ classify_by_rules() { # theme_for → the Theme it rolls up to, or "". theme_for() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[theme_for] expected 1 arg (initiative), got %d\n' "$#" >&2 return 64 fi local want="$1" init theme local tax="${TAXONOMY_FILE:-${_ci_dir}/initiative-taxonomy.tsv}" - if [ ! -f "${tax}" ]; then + if [[ ! -f "${tax}" ]]; then printf '[theme_for] taxonomy file not found: %s\n' "${tax}" >&2 return 65 fi - while IFS=$'\t' read -r init theme || [ -n "${init}" ]; do + while IFS=$'\t' read -r init theme || [[ -n "${init}" ]]; do init="${init%$'\r'}" theme="${theme%$'\r'}" case "${init}" in ''|'#'*) continue ;; esac - if [ "${init}" = "${want}" ]; then + if [[ "${init}" = "${want}" ]]; then printf '%s' "${theme}" return 0 fi @@ -147,13 +147,13 @@ theme_for() { # decide_for_signature → "\t" (theme may be # empty), or "" when no rule matches. decide_for_signature() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[decide_for_signature] expected 1 arg (signature), got %d\n' "$#" >&2 return 64 fi local sig="$1" init theme init=$(classify_by_rules "${sig}") || return $? - [ -n "${init}" ] || return 0 + [[ -n "${init}" ]] || return 0 theme=$(theme_for "${init}") || return $? printf '%s\t%s' "${init}" "${theme}" } @@ -179,30 +179,30 @@ resolve_fields() { } }') - if [ "$(printf '%s' "${json}" | jq -r '.data.node?')" = "null" ]; then + if [[ "$(printf '%s' "${json}" | jq -r '.data.node?')" = "null" ]]; then printf '[resolve_fields] GraphQL returned data.node:null for PROJECT_ID=%s — token may lack access, or the project was deleted.\n' "${PROJECT_ID}" >&2 return 75 fi CI_INIT_FIELD_ID=$(printf '%s' "${json}" | jq -r '.data.node.initiative.id? // ""') CI_THEME_FIELD_ID=$(printf '%s' "${json}" | jq -r '.data.node.theme.id? // ""') - if [ -z "${CI_INIT_FIELD_ID}" ]; then + if [[ -z "${CI_INIT_FIELD_ID}" ]]; then printf '[resolve_fields] Initiative single-select field %q not found on the project.\n' "${INITIATIVE_FIELD:-Initiative}" >&2 return 65 fi local id name while IFS=$'\t' read -r id name; do - [ -n "${id}" ] && CI_INIT_OPT["${name}"]="${id}" + [[ -n "${id}" ]] && CI_INIT_OPT["${name}"]="${id}" done < <(printf '%s' "${json}" | jq -r '.data.node.initiative.options?[]? | "\(.id)\t\(.name)"') while IFS=$'\t' read -r id name; do - [ -n "${id}" ] && CI_THEME_OPT["${name}"]="${id}" + [[ -n "${id}" ]] && CI_THEME_OPT["${name}"]="${id}" done < <(printf '%s' "${json}" | jq -r '.data.node.theme.options?[]? | "\(.id)\t\(.name)"') } # _ci_report _ci_report() { local total="$1" already="$2" matched="$3" unmatched="$4" skipped="$5" failed="${6:-0}" - local mode="apply"; [ "${DRY_RUN:-}" = "1" ] && mode="dry-run" + local mode="apply"; [[ "${DRY_RUN:-}" = "1" ]] && mode="dry-run" printf '\n=== classify-initiative summary (%s) ===\n' "${mode}" printf ' board items scanned : %d\n' "${total}" printf ' already associated : %d (skipped; RECLASSIFY=all to re-evaluate)\n' "${already}" @@ -210,7 +210,7 @@ _ci_report() { printf ' unmatched (blank) : %d\n' "${unmatched}" printf ' option-missing skip : %d\n' "${skipped}" printf ' transient set fails : %d (left blank; refilled next sweep)\n' "${failed}" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { printf '### classify-initiative (%s)\n\n' "${mode}" printf '| scanned | already | matched | unmatched | skipped | failed |\n' @@ -257,7 +257,7 @@ sweep_project() { local total=0 already=0 matched=0 unmatched=0 skipped=0 failed=0 local node while IFS= read -r node; do - [ -n "${node}" ] || continue + [[ -n "${node}" ]] || continue total=$((total + 1)) local item_id cur title labels repo sig decided init theme optid @@ -267,7 +267,7 @@ sweep_project() { labels=$(printf '%s' "${node}" | jq -c '.content.labels.nodes? // []') repo=$(printf '%s' "${node}" | jq -r '.content.repository.nameWithOwner? // ""') - if [ -n "${cur}" ] && [ "${RECLASSIFY:-}" != "all" ]; then + if [[ -n "${cur}" ]] && [[ "${RECLASSIFY:-}" != "all" ]]; then already=$((already + 1)) continue fi @@ -281,14 +281,14 @@ sweep_project() { local log_title log_title=$(atp_log_safe "${title}") - if [ -z "${init}" ]; then + if [[ -z "${init}" ]]; then unmatched=$((unmatched + 1)) printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${log_title}" continue fi optid="${CI_INIT_OPT[${init}]:-}" - if [ -z "${optid}" ]; then + if [[ -z "${optid}" ]]; then printf '::warning::rule matched Initiative %q which is not a live project option; skipping «%s»\n' "${init}" "${log_title}" >&2 skipped=$((skipped + 1)) continue @@ -310,9 +310,9 @@ sweep_project() { # Theme is best-effort: co-assign only when the field and matching option # both exist live. A missing Theme field/option is silently tolerated, and # a transient set failure is non-fatal (Initiative already landed). - if [ -n "${theme}" ] && [ -n "${CI_THEME_FIELD_ID}" ]; then + if [[ -n "${theme}" ]] && [[ -n "${CI_THEME_FIELD_ID}" ]]; then local topt="${CI_THEME_OPT[${theme}]:-}" - if [ -n "${topt}" ] && ! set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}"; then + if [[ -n "${topt}" ]] && ! set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}"; then printf '::warning::failed to set Theme %q on «%s» (transient?); retry requires RECLASSIFY=all (Initiative already set)\n' "${theme}" "${log_title}" >&2 fi fi @@ -321,6 +321,6 @@ sweep_project() { _ci_report "${total}" "${already}" "${matched}" "${unmatched}" "${skipped}" "${failed}" } -if [ "${BASH_SOURCE[0]}" = "${0}" ]; then +if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then sweep_project fi diff --git a/.github/scripts/add-to-project/lib.sh b/.github/scripts/add-to-project/lib.sh index 79b8872fb..f822b3949 100644 --- a/.github/scripts/add-to-project/lib.sh +++ b/.github/scripts/add-to-project/lib.sh @@ -19,11 +19,11 @@ set -euo pipefail _atp_require_env() { - if [ -z "${PROJECT_ID:-}" ]; then + if [[ -z "${PROJECT_ID:-}" ]]; then printf '[%s] PROJECT_ID env var is required\n' "$1" >&2 return 64 fi - if [ -z "${GH_TOKEN:-}" ]; then + if [[ -z "${GH_TOKEN:-}" ]]; then printf '::error::[%s] GH_TOKEN is empty. INITIATIVES_APP_ID / INITIATIVES_APP_PRIVATE_KEY are likely unset or stale. See combsmith/.github#387.\n' "$1" >&2 return 64 fi @@ -60,13 +60,13 @@ atp_log_safe() { # "is it already on the board?" from memory and skip the API call unless a # genuine change is needed. The event path leaves both unset, so it always # falls through to the network and its behavior is unchanged. -_atp_membership_ready() { [ "${_ATP_MEMBERSHIP_READY:-}" = "1" ]; } +_atp_membership_ready() { [[ "${_ATP_MEMBERSHIP_READY:-}" = "1" ]]; } # _atp_on_board # Returns 0 if the cache says the content IS on the board, 1 if the cache # says it is NOT. MUST be gated by `_atp_membership_ready` — when no cache # has been populated the result is meaningless (there is nothing to consult). -_atp_on_board() { [ -n "${_ATP_ON_BOARD[$1]:-}" ]; } +_atp_on_board() { [[ -n "${_ATP_ON_BOARD[$1]:-}" ]]; } # find_project_item # kind=title-prefix → match items whose content.title startswith @@ -79,7 +79,7 @@ _atp_on_board() { [ -n "${_ATP_ON_BOARD[$1]:-}" ]; } # caller add duplicates. Lookup is paginated so the project can grow past # the first page without silently missing matches. find_project_item() { - if [ "$#" -ne 2 ]; then + if [[ "$#" -ne 2 ]]; then printf '[find_project_item] expected 2 args (kind value), got %d\n' "$#" >&2 return 64 fi @@ -109,7 +109,7 @@ find_project_item() { while true; do local json # shellcheck disable=SC2016 # $projectId/$pageSize/$cursor are GraphQL variables - if [ -n "${cursor}" ]; then + if [[ -n "${cursor}" ]]; then json=$(gh api graphql \ -F projectId="${PROJECT_ID}" \ -F pageSize="${page_size}" \ @@ -155,7 +155,7 @@ find_project_item() { }') fi - if [ "$(printf '%s' "${json}" | jq -r '.data.node')" = "null" ]; then + if [[ "$(printf '%s' "${json}" | jq -r '.data.node')" = "null" ]]; then printf '[find_project_item] GraphQL returned data.node:null. PROJECT_ID=%s — token may lack access, or the project was deleted.\n' "${PROJECT_ID}" >&2 return 75 fi @@ -198,8 +198,8 @@ find_project_item() { esac done <<< "${parsed}" - if [ -n "${match}" ]; then - if [ "${match_count}" != "1" ]; then + if [[ -n "${match}" ]]; then + if [[ "${match_count}" != "1" ]]; then # Multi-match on a single page indicates inconsistent state (manual # drafts shadowing automation). Warn so an operator can clean up, # but proceed with the first match so the state machine progresses. @@ -210,7 +210,7 @@ find_project_item() { return 0 fi - if [ "${has_next}" != "true" ]; then + if [[ "${has_next}" != "true" ]]; then return 0 fi cursor="${end_cursor}" @@ -218,7 +218,7 @@ find_project_item() { } add_content_to_project() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[add_content_to_project] expected 1 arg (content_node_id), got %d\n' "$#" >&2 return 64 fi @@ -234,7 +234,7 @@ add_content_to_project() { return 0 fi - if [ "${DRY_RUN:-}" = "1" ]; then + if [[ "${DRY_RUN:-}" = "1" ]]; then printf '[dry-run] would add content %s to project\n' "${content_node_id}" return 0 fi @@ -253,14 +253,14 @@ add_content_to_project() { } add_draft_item() { - if [ "$#" -ne 2 ]; then + if [[ "$#" -ne 2 ]]; then printf '[add_draft_item] expected 2 args (title body), got %d\n' "$#" >&2 return 64 fi local title="$1" local body="$2" - if [ "${DRY_RUN:-}" = "1" ]; then + if [[ "${DRY_RUN:-}" = "1" ]]; then printf '[dry-run] would add draft: %s\n' "$(atp_log_safe "${title}")" return 0 fi @@ -278,13 +278,13 @@ add_draft_item() { } delete_project_item() { - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[delete_project_item] expected 1 arg (item_id), got %d\n' "$#" >&2 return 64 fi local item_id="$1" - if [ "${DRY_RUN:-}" = "1" ]; then + if [[ "${DRY_RUN:-}" = "1" ]]; then printf '[dry-run] would delete item %s from project\n' "${item_id}" return 0 fi @@ -323,7 +323,7 @@ delete_project_item() { # Exit 0 on success; 64 on bad args. DRY_RUN=1 logs the intended write and # mutates nothing. set_item_single_select_value() { - if [ "$#" -ne 3 ]; then + if [[ "$#" -ne 3 ]]; then printf '[set_item_single_select_value] expected 3 args (item_id field_id option_id), got %d\n' "$#" >&2 return 64 fi @@ -331,7 +331,7 @@ set_item_single_select_value() { local field_id="$2" local option_id="$3" - if [ "${DRY_RUN:-}" = "1" ]; then + if [[ "${DRY_RUN:-}" = "1" ]]; then printf '[dry-run] would set item %s field %s = option %s\n' "${item_id}" "${field_id}" "${option_id}" return 0 fi diff --git a/.github/scripts/add-to-project/reconcile-backlog.sh b/.github/scripts/add-to-project/reconcile-backlog.sh index 2cea5c7db..58dbec589 100644 --- a/.github/scripts/add-to-project/reconcile-backlog.sh +++ b/.github/scripts/add-to-project/reconcile-backlog.sh @@ -38,12 +38,12 @@ pr_required="${REQUIRED_LABEL-dev-lead}" # Resolve target repos: explicit RECON_REPOS, else the App installation's repos. declare -a repos -if [ -n "${RECON_REPOS:-}" ]; then +if [[ -n "${RECON_REPOS:-}" ]]; then read -r -a repos <<< "${RECON_REPOS}" else mapfile -t repos < <(gh api --paginate /installation/repositories --jq '.repositories[].full_name') fi -if [ "${#repos[@]}" -eq 0 ]; then +if [[ "${#repos[@]}" -eq 0 ]]; then echo "::error::no repos to reconcile (set RECON_REPOS or check the App installation)" >&2 exit 64 fi @@ -60,7 +60,7 @@ prefetch_board_membership() { local cursor="" json count=0 while true; do # shellcheck disable=SC2016 # $projectId/$pageSize/$cursor are GraphQL variables - if [ -n "${cursor}" ]; then + if [[ -n "${cursor}" ]]; then json=$(gh api graphql \ -F projectId="${PROJECT_ID}" -F pageSize="${PAGE_SIZE:-100}" -F cursor="${cursor}" \ -f query='query($projectId:ID!,$pageSize:Int!,$cursor:String!){ @@ -80,20 +80,20 @@ prefetch_board_membership() { nodes{ content{ ... on Issue{ id } ... on PullRequest{ id } } } } } } }') fi - if [ "$(printf '%s' "${json}" | jq -r '.data?.node?.items? // "null"')" = "null" ]; then + if [[ "$(printf '%s' "${json}" | jq -r '.data?.node?.items? // "null"')" = "null" ]]; then echo "::error::[reconcile] membership prefetch got no items for PROJECT_ID=${PROJECT_ID} — token access, PROJECT_ID drift, or non-ProjectV2 node" >&2 return 75 fi local id - while IFS= read -r id || [ -n "${id}" ]; do + while IFS= read -r id || [[ -n "${id}" ]]; do id="${id%$'\r'}" - [ -n "$id" ] || continue + [[ -n "$id" ]] || continue _ATP_ON_BOARD["$id"]=1 count=$((count + 1)) done < <(printf '%s' "${json}" | jq -r '.data.node?.items?.nodes?[]?.content?.id? // empty') - [ "$(printf '%s' "${json}" | jq -r '.data.node?.items?.pageInfo?.hasNextPage?')" = "true" ] || break + [[ "$(printf '%s' "${json}" | jq -r '.data.node?.items?.pageInfo?.hasNextPage?')" = "true" ]] || break cursor=$(printf '%s' "${json}" | jq -r '.data.node?.items?.pageInfo?.endCursor? // ""') - if [ -z "${cursor}" ]; then + if [[ -z "${cursor}" ]]; then echo "::error::[reconcile] membership prefetch got hasNextPage=true with empty endCursor — API inconsistency, aborting" >&2 return 75 fi @@ -107,7 +107,7 @@ _ATP_MEMBERSHIP_READY=1 scanned=0 echo "Reconciling ${#repos[@]} repo(s) into ${PROJECT_URL:-the project}${DRY_RUN:+ (DRY RUN)}" for repo in "${repos[@]}"; do - [ -z "$repo" ] && continue + [[ -z "$repo" ]] && continue echo "::group::${repo}" # Open issues AND PRs (the issues endpoint returns both). A PR carries a @@ -122,12 +122,12 @@ for repo in "${repos[@]}"; do echo "::endgroup::" continue } - while IFS=$'\t' read -r nid url labels kind || [ -n "$kind" ]; do + while IFS=$'\t' read -r nid url labels kind || [[ -n "$kind" ]]; do kind="${kind%$'\r'}" - [ -z "$nid" ] && continue + [[ -z "$nid" ]] && continue scanned=$((scanned + 1)) # Un-gate issues (empty required label); PRs keep the gate. - if [ "$kind" = "pr" ]; then REQUIRED_LABEL="$pr_required"; else REQUIRED_LABEL=""; fi + if [[ "$kind" = "pr" ]]; then REQUIRED_LABEL="$pr_required"; else REQUIRED_LABEL=""; fi export REQUIRED_LABEL reconcile_content_with_project "$nid" "$url" "$labels" done <<< "$issues_tsv" diff --git a/.github/scripts/dependency-audit/lib/dependency-audit.sh b/.github/scripts/dependency-audit/lib/dependency-audit.sh index b6d89292e..6be290f75 100644 --- a/.github/scripts/dependency-audit/lib/dependency-audit.sh +++ b/.github/scripts/dependency-audit/lib/dependency-audit.sh @@ -79,16 +79,16 @@ dep_audit_python_run() { shown="$(dep_audit_display "$dir")" echo "::group::pip-audit ${shown}" audited=0 - if [ -f "$dir/pyproject.toml" ]; then + if [[ -f "$dir/pyproject.toml" ]]; then audited=1 pip-audit "$dir" &2 status=1 continue @@ -156,12 +156,12 @@ dep_audit_cargo_audit_roots() { ws="${ws%x}" seen=0 for s in ${roots[@]+"${roots[@]}"}; do - if [ "$s" = "$ws" ]; then + if [[ "$s" = "$ws" ]]; then seen=1 break fi done - if [ "$seen" -eq 0 ]; then + if [[ "$seen" -eq 0 ]]; then roots+=("$ws") printf '%s\0' "$ws" fi @@ -179,7 +179,7 @@ dep_audit_cargo_audit_dir() { local dir="$1" ( cd -- "$dir" || exit 1 - if [ ! -e Cargo.lock ]; then + if [[ ! -e Cargo.lock ]]; then echo "No committed Cargo.lock in $(dep_audit_display "$dir"); generating one to audit." if ! cargo generate-lockfile &2 return 64 fi - if [ -z "${GH_TOKEN:-}" ]; then + if [[ -z "${GH_TOKEN:-}" ]]; then printf '[collect-signals] GH_TOKEN env var is required\n' >&2 return 64 fi @@ -110,7 +110,7 @@ main() { # Caught by Copilot review on PR combsmith/.github#85. local raw_open_count raw_open_count=$(printf '%s' "$open_issues_raw" | jq 'length') - if [ "$raw_open_count" -ge "$issue_limit" ]; then + if [[ "$raw_open_count" -ge "$issue_limit" ]]; then truncation_warnings=$(printf '%s' "$truncation_warnings" \ | jq --arg src "open_issues" --argjson lim "$issue_limit" \ '. + [{source: $src, limit: $lim, message: "result count equals limit; possible truncation"}]') @@ -151,7 +151,7 @@ GRAPHQL | jq -r '[.[] | select(.name == "Ideas")][0].id // empty') local ideas_discussions='[]' - if [ -n "$ideas_cat_id" ]; then + if [[ -n "$ideas_cat_id" ]]; then printf '[collect-signals] fetching Ideas discussions (limit=%s)\n' "$discussion_limit" >&2 local discussions_query read -r -d '' discussions_query <<'GRAPHQL' || true @@ -185,7 +185,7 @@ GRAPHQL local has_next_page has_next_page=$(printf '%s' "$discussions_full" \ | jq -r '.data.repository.discussions.pageInfo.hasNextPage // false') - if [ "$has_next_page" = "true" ]; then + if [[ "$has_next_page" = "true" ]]; then truncation_warnings=$(printf '%s' "$truncation_warnings" \ | jq --arg src "ideas_discussions" --argjson lim "$discussion_limit" \ '. + [{source: $src, limit: $lim, message: "hasNextPage=true; results truncated"}]') @@ -251,7 +251,7 @@ GRAPHQL printf '[collect-signals] wrote %s\n' "$output_path" >&2 # --- Step summary (only when running inside GitHub Actions) ---------------- - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { printf '## Signals Collected\n\n' printf -- '- **Schema version:** %s\n' "$SCHEMA_VERSION" @@ -262,7 +262,7 @@ GRAPHQL printf -- '- **Existing Ideas discussions:** %s\n' "$(jq '.ideas_discussions.count' "$output_path")" local warn_count warn_count=$(jq '.truncation_warnings | length' "$output_path") - if [ "$warn_count" -gt 0 ]; then + if [[ "$warn_count" -gt 0 ]]; then printf -- '- **⚠️ Truncation warnings:** %s\n' "$warn_count" jq -r '.truncation_warnings[] | " - " + .source + " (limit " + (.limit|tostring) + "): " + .message' "$output_path" fi @@ -271,6 +271,6 @@ GRAPHQL } # Allow `source`-ing for tests; only run main when executed directly. -if [ "${BASH_SOURCE[0]}" = "${0}" ]; then +if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then main "$@" fi diff --git a/.github/scripts/feature-ideation/discussion-mutations.sh b/.github/scripts/feature-ideation/discussion-mutations.sh index 59e739329..99e7df5e3 100755 --- a/.github/scripts/feature-ideation/discussion-mutations.sh +++ b/.github/scripts/feature-ideation/discussion-mutations.sh @@ -36,11 +36,11 @@ _dry_run_log() { } _is_dry_run() { - [ "${DRY_RUN:-0}" = "1" ] + [[ "${DRY_RUN:-0}" = "1" ]] } create_discussion() { - if [ "$#" -ne 4 ]; then + if [[ "$#" -ne 4 ]]; then printf '[create_discussion] expected 4 args (repo_id category_id title body), got %d\n' "$#" >&2 return 64 fi @@ -90,7 +90,7 @@ GRAPHQL } comment_on_discussion() { - if [ "$#" -ne 2 ]; then + if [[ "$#" -ne 2 ]]; then printf '[comment_on_discussion] expected 2 args (discussion_id body), got %d\n' "$#" >&2 return 64 fi @@ -127,7 +127,7 @@ GRAPHQL } add_label_to_discussion() { - if [ "$#" -ne 2 ]; then + if [[ "$#" -ne 2 ]]; then printf '[add_label_to_discussion] expected 2 args (discussion_id label_id), got %d\n' "$#" >&2 return 64 fi diff --git a/.github/scripts/feature-ideation/lib/compose-signals.sh b/.github/scripts/feature-ideation/lib/compose-signals.sh index e59228dde..1bf53c3b0 100755 --- a/.github/scripts/feature-ideation/lib/compose-signals.sh +++ b/.github/scripts/feature-ideation/lib/compose-signals.sh @@ -26,7 +26,7 @@ set -euo pipefail compose_signals() { - if [ "$#" -ne 11 ]; then + if [[ "$#" -ne 11 ]]; then printf '[compose-signals] expected 11 args, got %d\n' "$#" >&2 return 64 # EX_USAGE fi diff --git a/.github/scripts/feature-ideation/lib/date-utils.sh b/.github/scripts/feature-ideation/lib/date-utils.sh index 8a9cf8099..8bbe4ef42 100755 --- a/.github/scripts/feature-ideation/lib/date-utils.sh +++ b/.github/scripts/feature-ideation/lib/date-utils.sh @@ -13,12 +13,12 @@ date_days_ago() { # Guard arg count before reading $1: under set -u a zero-arg call would abort # the shell with "unbound variable" instead of reaching the validation path. # Caught by CodeRabbit review on PR combsmith/.github#85. - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then printf '[date-utils] expected 1 arg (days), got: %d\n' "$#" >&2 return 64 fi local days="$1" - if [ -z "$days" ] || ! printf '%s' "$days" | grep -Eq '^[0-9]+$'; then + if [[ -z "$days" ]] || ! printf '%s' "$days" | grep -Eq '^[0-9]+$'; then printf '[date-utils] days must be a non-negative integer, got: %s\n' "$days" >&2 return 64 fi diff --git a/.github/scripts/feature-ideation/lib/filter-bots.sh b/.github/scripts/feature-ideation/lib/filter-bots.sh index 44b5fed6c..5a72aa6d8 100755 --- a/.github/scripts/feature-ideation/lib/filter-bots.sh +++ b/.github/scripts/feature-ideation/lib/filter-bots.sh @@ -34,7 +34,7 @@ DEFAULT_BOT_AUTHORS=( # Build the active bot list from defaults + env override. filter_bots_build_list() { local list=("${DEFAULT_BOT_AUTHORS[@]}") - if [ -n "${FEATURE_IDEATION_BOT_AUTHORS:-}" ]; then + if [[ -n "${FEATURE_IDEATION_BOT_AUTHORS:-}" ]]; then # Use `IFS=',' read` (not unquoted expansion) to avoid pathname-globbing # against the filesystem if any entry contains wildcard characters. # Caught by CodeRabbit review on PR combsmith/.github#85. @@ -49,7 +49,7 @@ filter_bots_build_list() { # Strip leading whitespace, then trailing whitespace. entry="${entry#"${entry%%[![:space:]]*}"}" entry="${entry%"${entry##*[![:space:]]}"}" - [ -n "$entry" ] && trimmed+=("$entry") + [[ -n "$entry" ]] && trimmed+=("$entry") done list+=("${trimmed[@]}") fi diff --git a/.github/scripts/feature-ideation/lib/gh-safe.sh b/.github/scripts/feature-ideation/lib/gh-safe.sh index 90e5ab08d..601ee360e 100755 --- a/.github/scripts/feature-ideation/lib/gh-safe.sh +++ b/.github/scripts/feature-ideation/lib/gh-safe.sh @@ -35,7 +35,7 @@ _gh_safe_err() { # Validate that a string is well-formed JSON. Returns 0 if valid. gh_safe_is_json() { local input="$1" - [ -n "$input" ] || return 1 + [[ -n "$input" ]] || return 1 printf '%s' "$input" | jq -e . >/dev/null 2>&1 } @@ -56,14 +56,14 @@ gh_safe_rest() { stderr=$(cat "$tmp_err") rm -f "$tmp_err" - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then _gh_safe_err "rest-failure" "exit=$rc args=$* stderr=$stderr" return "$rc" fi # Empty stdout from a successful gh call means the result set is empty. # Normalize to an empty JSON array so downstream jq composition never sees "". - if [ -z "$stdout" ]; then + if [[ -z "$stdout" ]]; then printf '%s' "$GH_SAFE_EMPTY_ARRAY" return 0 fi @@ -91,12 +91,12 @@ gh_safe_graphql() { local has_jq=0 local jq_filter="" local i=0 - while [ "$i" -lt "${#args[@]}" ]; do - if [ "${args[$i]}" = "--jq" ]; then + while [[ "$i" -lt "${#args[@]}" ]]; do + if [[ "${args[$i]}" = "--jq" ]]; then # Guard bounds before dereferencing args[i+1]: under set -u an out-of- # bounds access aborts the shell. Caught by CodeRabbit review on PR # combsmith/.github#85. - if [ $((i + 1)) -ge "${#args[@]}" ]; then + if [[ $((i + 1)) -ge "${#args[@]}" ]]; then _gh_safe_err "graphql-bad-args" "--jq requires a jq filter argument" return 64 fi @@ -110,8 +110,8 @@ gh_safe_graphql() { # Build a no-jq variant for envelope validation. local raw_args=() i=0 - while [ "$i" -lt "${#args[@]}" ]; do - if [ "${args[$i]}" = "--jq" ]; then + while [[ "$i" -lt "${#args[@]}" ]]; do + if [[ "${args[$i]}" = "--jq" ]]; then i=$((i + 2)) continue fi @@ -128,7 +128,7 @@ gh_safe_graphql() { stderr=$(cat "$tmp_err") rm -f "$tmp_err" - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then _gh_safe_err "graphql-failure" "exit=$rc stderr=$stderr" return "$rc" fi @@ -154,7 +154,7 @@ gh_safe_graphql() { fi # If caller asked for a jq filter, apply it now and return that. - if [ "$has_jq" -eq 1 ]; then + if [[ "$has_jq" -eq 1 ]]; then # NB: do NOT swallow jq errors with `|| true`. A typo or wrong path in # the filter must surface as a hard failure, not a silent empty result — # otherwise we re-introduce R1 in a different shape. Caught by Copilot @@ -165,13 +165,13 @@ gh_safe_graphql() { filtered=$(printf '%s' "$raw" | jq -c "$jq_filter" 2>"$jq_err") jq_rc=$? set -e - if [ "$jq_rc" -ne 0 ]; then + if [[ "$jq_rc" -ne 0 ]]; then _gh_safe_err "graphql-jq-failed" "filter='$jq_filter' err=$(cat "$jq_err")" rm -f "$jq_err" return 65 fi rm -f "$jq_err" - if [ "$filtered" = "null" ] || [ -z "$filtered" ]; then + if [[ "$filtered" = "null" ]] || [[ -z "$filtered" ]]; then # Filter resolved to JSON null (e.g. nodes path returned null) — this # is the documented "no results" case. Normalize to empty array. printf '%s' "$GH_SAFE_EMPTY_ARRAY" @@ -195,7 +195,7 @@ gh_safe_graphql_input() { # Guard arg count before reading $1: under set -u a zero-arg call aborts the # shell instead of reaching the JSON validation. Caught by CodeRabbit review # on PR combsmith/.github#85. - if [ "$#" -ne 1 ]; then + if [[ "$#" -ne 1 ]]; then _gh_safe_err "graphql-bad-input" "expected 1 arg: JSON request body, got $#" return 64 fi @@ -214,7 +214,7 @@ gh_safe_graphql_input() { stderr=$(cat "$tmp_err") rm -f "$tmp_err" - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then _gh_safe_err "graphql-failure" "exit=$rc stderr=$stderr" return "$rc" fi diff --git a/.github/scripts/feature-ideation/lib/trusted-authors.sh b/.github/scripts/feature-ideation/lib/trusted-authors.sh index 960e053a3..6dbec72bb 100644 --- a/.github/scripts/feature-ideation/lib/trusted-authors.sh +++ b/.github/scripts/feature-ideation/lib/trusted-authors.sh @@ -37,7 +37,7 @@ trusted_author_check() { local known for known in "${TRUSTED_PIPELINE_AUTHORS[@]}"; do - [ "$login" = "$known" ] && return 0 + [[ "$login" = "$known" ]] && return 0 done # Other bots and malformed names never have a collaborator record. Checking @@ -56,7 +56,7 @@ trusted_author_check() { stderr=$(cat "$tmp_err") rm -f "$tmp_err" - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then case "$stderr" in *"HTTP 404"*) return 1 ;; esac @@ -77,7 +77,7 @@ trusted_authors_allowlist() { local allowed='[]' local login rc while IFS= read -r login; do - [ -n "$login" ] || continue + [[ -n "$login" ]] || continue rc=0 trusted_author_check "$repo" "$login" || rc=$? case "$rc" in diff --git a/.github/scripts/feature-ideation/lint-caller.sh b/.github/scripts/feature-ideation/lint-caller.sh index 0b8bde1a7..f94204c9d 100755 --- a/.github/scripts/feature-ideation/lint-caller.sh +++ b/.github/scripts/feature-ideation/lint-caller.sh @@ -118,7 +118,7 @@ PY } main() { - if [ "$#" -eq 0 ]; then + if [[ "$#" -eq 0 ]]; then # Default: the two feature-ideation caller stubs (template + this repo's own). local repo_root repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" @@ -130,7 +130,7 @@ main() { local exit=0 local file_rc=0 for file in "$@"; do - if [ ! -f "$file" ]; then + if [[ ! -f "$file" ]]; then printf '[lint-caller] not found: %s\n' "$file" >&2 exit=2 continue @@ -140,7 +140,7 @@ main() { scan_file "$file" && file_rc=0 || file_rc=$? case "$file_rc" in 0) ;; - 1) if [ "$exit" -eq 0 ]; then exit=1; fi ;; + 1) if [[ "$exit" -eq 0 ]]; then exit=1; fi ;; 2) exit=2 ;; *) return "$file_rc" ;; esac diff --git a/.github/scripts/feature-ideation/lint-prompt.sh b/.github/scripts/feature-ideation/lint-prompt.sh index 2d22b24f9..34bb71cad 100755 --- a/.github/scripts/feature-ideation/lint-prompt.sh +++ b/.github/scripts/feature-ideation/lint-prompt.sh @@ -130,7 +130,7 @@ PY } main() { - if [ "$#" -eq 0 ]; then + if [[ "$#" -eq 0 ]]; then # Default: scan every workflow file. local repo_root repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" @@ -144,7 +144,7 @@ main() { local exit=0 local file_rc=0 for file in "$@"; do - if [ ! -f "$file" ]; then + if [[ ! -f "$file" ]]; then printf '[lint-prompt] not found: %s\n' "$file" >&2 exit=2 continue @@ -159,7 +159,7 @@ main() { fi case "$file_rc" in 0) ;; - 1) if [ "$exit" -eq 0 ]; then exit=1; fi ;; + 1) if [[ "$exit" -eq 0 ]]; then exit=1; fi ;; 2) exit=2 ;; *) return "$file_rc" ;; esac diff --git a/.github/scripts/feature-ideation/load-discussion-context.sh b/.github/scripts/feature-ideation/load-discussion-context.sh index e49de0043..ad4280c26 100644 --- a/.github/scripts/feature-ideation/load-discussion-context.sh +++ b/.github/scripts/feature-ideation/load-discussion-context.sh @@ -51,7 +51,7 @@ DISCUSSION_FIELDS=' ' set_proceed() { - if [ -n "${GITHUB_OUTPUT:-}" ]; then + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf 'proceed=%s\n' "$1" >>"$GITHUB_OUTPUT" fi printf '[load-discussion-context] proceed=%s\n' "$1" >&2 @@ -94,14 +94,14 @@ load_single() { discussion=$(printf '%s' "$discussion" | jq -c '.[0]') local reason="" - if [ "$(printf '%s' "$discussion" | jq -r '.category')" != "ideas" ]; then + if [[ "$(printf '%s' "$discussion" | jq -r '.category')" != "ideas" ]]; then reason="not in the Ideas category" - elif [ "$(printf '%s' "$discussion" | jq -r '.author_trusted')" != "true" ]; then + elif [[ "$(printf '%s' "$discussion" | jq -r '.author_trusted')" != "true" ]]; then reason="author does not have write access" - elif [ "$(printf '%s' "$discussion" | jq -r '.enhanced')" = "true" ]; then + elif [[ "$(printf '%s' "$discussion" | jq -r '.enhanced')" = "true" ]]; then reason="already enhanced" fi - if [ -n "$reason" ]; then + if [[ -n "$reason" ]]; then printf '::notice title=Feature ideation::Discussion %s not enhanced: %s.\n' "$TARGET_DISCUSSION" "$reason" set_proceed false return 0 @@ -124,13 +124,13 @@ load_backlog() { } }" while :; do - if [ -n "$cursor" ]; then + if [[ -n "$cursor" ]]; then page=$(gh_safe_graphql -f query="$query" -f owner="$owner" -f name="$name" -f cursor="$cursor") else page=$(gh_safe_graphql -f query="$query" -f owner="$owner" -f name="$name") fi nodes=$(jq -cn --argjson a "$nodes" --argjson b "$(printf '%s' "$page" | jq -c '.data.repository.discussions.nodes // []')" '$a + $b') - [ "$(printf '%s' "$page" | jq -r '.data.repository.discussions.pageInfo.hasNextPage // false')" = "true" ] || break + [[ "$(printf '%s' "$page" | jq -r '.data.repository.discussions.pageInfo.hasNextPage // false')" = "true" ]] || break cursor=$(printf '%s' "$page" | jq -r '.data.repository.discussions.pageInfo.endCursor') done @@ -151,24 +151,24 @@ main() { printf '[load-discussion-context] REPO must be in owner/name format\n' >&2 return 64 fi - if [ -z "${GH_TOKEN:-}" ] || [ -z "${DISCUSSION_CONTEXT_PATH:-}" ]; then + if [[ -z "${GH_TOKEN:-}" ]] || [[ -z "${DISCUSSION_CONTEXT_PATH:-}" ]]; then printf '[load-discussion-context] GH_TOKEN and DISCUSSION_CONTEXT_PATH are required\n' >&2 return 64 fi - if [ -n "${TARGET_DISCUSSION:-}" ]; then + if [[ -n "${TARGET_DISCUSSION:-}" ]]; then if [[ ! $TARGET_DISCUSSION =~ ^[1-9][0-9]*$ ]]; then printf '[load-discussion-context] TARGET_DISCUSSION must be a positive integer\n' >&2 return 64 fi load_single - elif [ "${ENHANCE_BACKLOG:-0}" = "1" ]; then + elif [[ "${ENHANCE_BACKLOG:-0}" = "1" ]]; then load_backlog else set_proceed true fi } -if [ "${BASH_SOURCE[0]}" = "${0}" ]; then +if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then main "$@" fi diff --git a/.github/scripts/feature-ideation/match-discussions.sh b/.github/scripts/feature-ideation/match-discussions.sh index 8e3985cc7..a08ee3f05 100755 --- a/.github/scripts/feature-ideation/match-discussions.sh +++ b/.github/scripts/feature-ideation/match-discussions.sh @@ -54,11 +54,11 @@ match_discussions_main() { return 64 fi - if [ ! -f "$signals_path" ]; then + if [[ ! -f "$signals_path" ]]; then printf '[match-discussions] signals not found: %s\n' "$signals_path" >&2 return 64 fi - if [ ! -f "$proposals_path" ]; then + if [[ ! -f "$proposals_path" ]]; then printf '[match-discussions] proposals not found: %s\n' "$proposals_path" >&2 return 64 fi @@ -192,8 +192,8 @@ print(json.dumps(result, indent=2)) PY } -if [ "${BASH_SOURCE[0]}" = "${0}" ]; then - if [ "$#" -ne 2 ]; then +if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then + if [[ "$#" -ne 2 ]]; then printf 'usage: %s \n' "$0" >&2 exit 64 fi diff --git a/.github/scripts/pr-auto-review/lib/ready-check.sh b/.github/scripts/pr-auto-review/lib/ready-check.sh index 172392224..fec66858a 100644 --- a/.github/scripts/pr-auto-review/lib/ready-check.sh +++ b/.github/scripts/pr-auto-review/lib/ready-check.sh @@ -169,7 +169,7 @@ pr_auto_review_ready() { self_name="$5" review_decision="$6" blocking_thread_count="${7:-0}" # 1. PR must be open and not a draft. - if [ "$state" != "OPEN" ] || [ "$is_draft" = "true" ]; then + if [[ "$state" != "OPEN" ]] || [[ "$is_draft" = "true" ]]; then echo "skip-draft" return 1 fi @@ -179,7 +179,7 @@ pr_auto_review_ready() { # delegated to pr_auto_review_checks_ready (issue #680, unchanged). local total total=$(printf '%s' "$checks_json" | jq 'if type == "array" then length else 0 end' 2>/dev/null) - if [ -z "$total" ] || [ "$total" -eq 0 ] \ + if [[ -z "$total" ]] || [[ "$total" -eq 0 ]] \ || ! printf '%s' "$checks_json" \ | pr_auto_review_checks_ready "$required_json" "$self_name" >/dev/null; then echo "skip-checks-pending" @@ -187,13 +187,13 @@ pr_auto_review_ready() { fi # 3. Effective review decision must not be CHANGES_REQUESTED. - if [ "$review_decision" = "CHANGES_REQUESTED" ]; then + if [[ "$review_decision" = "CHANGES_REQUESTED" ]]; then echo "skip-changes-requested" return 1 fi # 4. No blocking review threads (unresolved AND not outdated). - if [ "$blocking_thread_count" -gt 0 ]; then + if [[ "$blocking_thread_count" -gt 0 ]]; then echo "skip-unresolved-threads" return 1 fi diff --git a/.github/scripts/pr-auto-review/lib/sweep.sh b/.github/scripts/pr-auto-review/lib/sweep.sh index 10c5765a2..b09948b5a 100644 --- a/.github/scripts/pr-auto-review/lib/sweep.sh +++ b/.github/scripts/pr-auto-review/lib/sweep.sh @@ -34,7 +34,7 @@ pr_auto_review_sweep_candidates() { # Back-pressure guard: only a positive integer bounds the run. Anything else # (0, negative, non-numeric) selects nothing so a misconfigured cap can never # fire an unbounded dispatch burst. - if ! [[ "$max" =~ ^[0-9]+$ ]] || [ "$max" -le 0 ]; then + if ! [[ "$max" =~ ^[0-9]+$ ]] || [[ "$max" -le 0 ]]; then return 0 fi diff --git a/.github/scripts/pr-auto-review/sweep-dispatch.sh b/.github/scripts/pr-auto-review/sweep-dispatch.sh index 7fbdf6c94..e8042cbfe 100644 --- a/.github/scripts/pr-auto-review/sweep-dispatch.sh +++ b/.github/scripts/pr-auto-review/sweep-dispatch.sh @@ -55,6 +55,8 @@ if ! [[ "${DISPATCH_RETRIES}" =~ ^[1-9][0-9]*$ ]]; then echo "::error::DISPATCH_RETRIES must be a positive integer, got: '${DISPATCH_RETRIES}'" >&2 exit 1 fi +# Force base 10 so a zero-padded value like "08" is not read as invalid octal. +DISPATCH_RETRIES=$((10#${DISPATCH_RETRIES})) if ! [[ "${DISPATCH_RETRY_SLEEP}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then echo "::error::DISPATCH_RETRY_SLEEP must be a non-negative number, got: '${DISPATCH_RETRY_SLEEP}'" >&2 exit 1 @@ -74,7 +76,7 @@ PR_LIST=$(gh search prs \ --order asc \ --limit 100 \ --json url,isDraft 2>/dev/null || true) -if [ -z "${PR_LIST}" ]; then +if [[ -z "${PR_LIST}" ]]; then PR_LIST="[]" fi @@ -82,10 +84,10 @@ fi mapfile -t CANDIDATES < <(printf '%s' "$PR_LIST" | pr_auto_review_sweep_candidates "$MAX_PER_RUN") total_open=$(printf '%s' "$PR_LIST" | jq 'if type == "array" then length else 0 end') -[ "$DRY_RUN" = "1" ] && dry_note=" (DRY_RUN)" || dry_note="" +[[ "$DRY_RUN" = "1" ]] && dry_note=" (DRY_RUN)" || dry_note="" echo "Sweep: ${total_open} open '${SWEEP_LABEL}' PR(s) in ${SEARCH_OWNER}; evaluating ${#CANDIDATES[@]} this run (MAX_PER_RUN=${MAX_PER_RUN})${dry_note}." -if [ "${#CANDIDATES[@]}" -eq 0 ]; then +if [[ "${#CANDIDATES[@]}" -eq 0 ]]; then echo "No candidate PRs to evaluate — nothing to do." exit 0 fi @@ -105,7 +107,7 @@ evaluate_pr() { # hard fetch failure emit the skip-error class and skip this PR — the sweep is # idempotent and re-runs every cycle, so it is retried next time (#947). if ! pr_meta=$(gh pr view "$pr_url" --json state,isDraft,number,reviewDecision,baseRefName 2>/dev/null) \ - || [ -z "$pr_meta" ]; then + || [[ -z "$pr_meta" ]]; then echo "::warning::could not fetch PR metadata for ${pr_url} — skipping this cycle" >&2 echo "skip-error" return 1 @@ -117,14 +119,14 @@ evaluate_pr() { base_branch=$(printf '%s' "$pr_meta" | jq -r '.baseRefName') checks=$(gh pr checks "$pr_url" --json bucket,name 2>/dev/null || true) - if [ -z "${checks}" ]; then checks="[]"; fi + if [[ -z "${checks}" ]]; then checks="[]"; fi if rules_json=$(gh api "/repos/${repo}/rules/branches/${base_branch}" 2>/dev/null); then required_json=$(printf '%s' "$rules_json" | pr_auto_review_required_contexts 2>/dev/null || echo "[]") else required_json="[]" fi - if [ -z "${required_json}" ]; then required_json="[]"; fi + if [[ -z "${required_json}" ]]; then required_json="[]"; fi # shellcheck disable=SC2016 # $owner/$repo/$number are GraphQL variable refs, not shell vars local gql='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo)' @@ -136,7 +138,7 @@ evaluate_pr() { -f owner="${repo%%/*}" \ -f repo="${repo##*/}" \ -F number="${pr_number}" 2>/dev/null) \ - || [ -z "$threads_json" ]; then + || [[ -z "$threads_json" ]]; then echo "::warning::could not fetch review threads for ${pr_url} — skipping this cycle" >&2 echo "skip-error" return 1 @@ -182,7 +184,7 @@ dispatch_review() { # Stable within this retry loop: all retries send the same id so the consumer # can deduplicate ambiguous double-fires (accepted POST + network error). local dispatch_id="${RANDOM}${RANDOM}" - while [ "$attempt" -le "$DISPATCH_RETRIES" ]; do + while [[ "$attempt" -le "$DISPATCH_RETRIES" ]]; do if gh api \ --method POST \ --header "Accept: application/vnd.github+json" \ @@ -194,7 +196,7 @@ dispatch_review() { fi echo "::warning::dispatch attempt ${attempt}/${DISPATCH_RETRIES} failed for ${pr_url}" >&2 attempt=$((attempt + 1)) - if [ "$attempt" -le "$DISPATCH_RETRIES" ]; then + if [[ "$attempt" -le "$DISPATCH_RETRIES" ]]; then sleep "$DISPATCH_RETRY_SLEEP" fi done @@ -204,10 +206,10 @@ dispatch_review() { dispatched=0 failed=0 for pr_url in "${CANDIDATES[@]}"; do - [ -z "$pr_url" ] && continue + [[ -z "$pr_url" ]] && continue echo "::group::${pr_url}" if decision=$(evaluate_pr "$pr_url"); then - if [ "$DRY_RUN" = "1" ]; then + if [[ "$DRY_RUN" = "1" ]]; then echo "[dry-run] would dispatch review agent for ${pr_url} (decision=${decision})" dispatched=$((dispatched + 1)) elif dispatch_review "$pr_url"; then @@ -226,7 +228,7 @@ for pr_url in "${CANDIDATES[@]}"; do echo "::endgroup::" done -if [ "$failed" -gt 0 ]; then +if [[ "$failed" -gt 0 ]]; then echo "Sweep complete — dispatched ${dispatched} of ${#CANDIDATES[@]} evaluated PR(s), ${failed} dispatch(es) failed (will retry next cycle)." else echo "Sweep complete — dispatched ${dispatched} of ${#CANDIDATES[@]} evaluated PR(s)." diff --git a/scripts/agent-rate-limit-gate.sh b/scripts/agent-rate-limit-gate.sh index a9482b4c4..2af22b0a4 100755 --- a/scripts/agent-rate-limit-gate.sh +++ b/scripts/agent-rate-limit-gate.sh @@ -93,7 +93,7 @@ argate_log() { printf 'agent-rate-limit-gate: %s\n' "$*" >&2; } # --------------------------------------------------------------------------- argate_iso_to_epoch() { local iso="${1:-}" epoch - [ -z "$iso" ] && return 0 + [[ -z "$iso" ]] && return 0 epoch="$(date -u -d "$iso" +%s 2>/dev/null || printf '')" [[ "$epoch" =~ ^[0-9]+$ ]] && printf '%s' "$epoch" } @@ -135,7 +135,7 @@ argate_daily_count() { local runs="${1:-[]}" now threshold_iso n current_run_id="${GITHUB_RUN_ID:-}" now="$(arl_sanitize_int "${2:-}")" threshold_iso="$(date -u -d "@$(( now - ARGATE_DAILY_WINDOW_SECONDS ))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf '')" - if [ -z "$threshold_iso" ]; then + if [[ -z "$threshold_iso" ]]; then printf '0' return 0 fi @@ -156,11 +156,11 @@ argate_daily_count() { argate_consecutive_failures() { local runs="${1:-[]}" concl streak=0 while IFS= read -r concl; do - [ -z "$concl" ] && continue + [[ -z "$concl" ]] && continue case " $ARGATE_FAILURE_CONCLUSIONS " in *" $concl "*) streak=$(( streak + 1 )) ;; *) - if [ "$concl" = "success" ]; then + if [[ "$concl" = "success" ]]; then break fi # cancelled / skipped / neutral / action_required: ignore, keep scanning. @@ -210,12 +210,12 @@ argate_fetch_runs() { case "$workflow" in *.yml|*.yaml) ;; *) - if [ "$(printf '%s' "$workflow" | tr '[:upper:] ' '[:lower:]-')" = "$(agent_ingress_role_for_workflow "$AGENT_INGRESS_WORKFLOW")" ]; then + if [[ "$(printf '%s' "$workflow" | tr '[:upper:] ' '[:lower:]-')" = "$(agent_ingress_role_for_workflow "$AGENT_INGRESS_WORKFLOW")" ]]; then workflow="$AGENT_INGRESS_WORKFLOW" fi ;; esac - [ -z "$role" ] && role="$(agent_ingress_role_for_workflow "$workflow")" + [[ -z "$role" ]] && role="$(agent_ingress_role_for_workflow "$workflow")" out="$(arl_resolve_agent_runs "$workflow" "$role" "$repo" "$limit")" || rc=$? case "$rc" in 0) @@ -248,13 +248,13 @@ argate_escalate() { marker="$(arl_breaker_marker "$agent_type")" label="$(arl_breaker_label)" - if [ -z "$tracking_issue" ]; then + if [[ -z "$tracking_issue" ]]; then argate_log "breaker OPEN for '${agent_type}' but no --tracking-issue configured — not posting; marker would be: ${marker}" return 0 fi local repo_args=() - [ -n "$tracking_repo" ] && repo_args=(--repo "$tracking_repo") + [[ -n "$tracking_repo" ]] && repo_args=(--repo "$tracking_repo") body="$(gh issue view "$tracking_issue" "${repo_args[@]}" --json body --jq '.body' 2>/dev/null || printf '')" if ! arl_should_escalate "$body" "$agent_type"; then @@ -295,13 +295,13 @@ argate_token_escalate() { marker="$(arl_token_breaker_marker "$window")" label="$(arl_breaker_label)" - if [ -z "$tracking_issue" ]; then + if [[ -z "$tracking_issue" ]]; then argate_log "token-budget[${window}] breaker OPEN but no --tracking-issue configured — not posting; marker would be: ${marker}" return 0 fi local repo_args=() - [ -n "$tracking_repo" ] && repo_args=(--repo "$tracking_repo") + [[ -n "$tracking_repo" ]] && repo_args=(--repo "$tracking_repo") body="$(gh issue view "$tracking_issue" "${repo_args[@]}" \ --json body,comments \ @@ -366,9 +366,9 @@ argate_token_budget() { s_percent="$(arl_token_extract_percent "$body" session)" w_percent="$(arl_token_extract_percent "$body" weekly_all)" - if [ "$status" -eq 200 ] && { [ -n "$s_percent" ] || [ -n "$w_percent" ]; }; then + if [[ "$status" -eq 200 ]] && { [[ -n "$s_percent" ]] || [[ -n "$w_percent" ]]; }; then argate_log "token-budget: telemetry OBTAINED (status=200) — evaluating pause-worthy account windows (session, weekly_all)" - elif [ "$status" -eq 200 ]; then + elif [[ "$status" -eq 200 ]]; then argate_log "token-budget: telemetry status=200 but NO usable window data (empty/malformed body) — the breaker fails open; any allow below is DEGRADED, not a genuine under-threshold allow" else argate_log "token-budget: telemetry NOT obtained (status=${status}) — the breaker fails open; any allow below is DEGRADED, not a genuine under-threshold allow" @@ -379,7 +379,7 @@ argate_token_budget() { s_threshold="$(arl_token_pause_threshold session)" argate_log "token-budget[session]: percent=${s_percent:-} threshold=${s_threshold:-}%" s_decision="$(arl_token_budget_gate session "$envelope")" || true - if [ "$s_decision" = "decision=defer" ]; then + if [[ "$s_decision" = "decision=defer" ]]; then decision="defer" tripped+=(session) argate_log "token-budget[session] TRIP — escalation marker: $(arl_token_breaker_marker session)" @@ -390,14 +390,14 @@ argate_token_budget() { local w_decision argate_log "token-budget[weekly_all]: percent=${w_percent:-} threshold=glide-path (config-enabled=$(arl_token_glide_enabled))" w_decision="$(arl_token_weekly_glide_gate "$envelope")" || true - if [ "$w_decision" = "decision=defer" ]; then + if [[ "$w_decision" = "decision=defer" ]]; then decision="defer" tripped+=(weekly_all) argate_log "token-budget[weekly_all] TRIP — escalation marker: $(arl_token_breaker_marker weekly_all)" fi printf '%s' "$decision" - [ "${#tripped[@]}" -gt 0 ] && printf ' %s' "${tripped[@]}" + [[ "${#tripped[@]}" -gt 0 ]] && printf ' %s' "${tripped[@]}" } # --------------------------------------------------------------------------- @@ -408,7 +408,7 @@ argate_token_budget() { argate_emit() { local decision="$1" printf 'decision=%s\n' "$decision" - if [ -n "${GITHUB_OUTPUT:-}" ]; then + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf 'decision=%s\n' "$decision" >>"$GITHUB_OUTPUT" fi } @@ -425,10 +425,10 @@ argate_gate() { local tracking_repo="" tracking_issue="" history_limit="100" # First positional is the agent type; the rest are flags. - if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then + if [[ $# -gt 0 ]] && [[ "${1#-}" = "$1" ]]; then agent_type="$1"; shift fi - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in --mode) mode="${2:-}"; shift 2 ;; --workflow) workflow="${2:-}"; shift 2 ;; @@ -442,17 +442,17 @@ argate_gate() { esac done - if [ -z "$agent_type" ]; then + if [[ -z "$agent_type" ]]; then argate_log "error: an argument is required" argate_emit "allow" # fail-open: a usage error must not block the fleet return 0 fi - [ -z "$workflow" ] && workflow="$agent_type" + [[ -z "$workflow" ]] && workflow="$agent_type" # Config unreadable → allow (never block the fleet on a config outage; AC #1). local config config="$(arl_config_path)" - if [ ! -f "$config" ] || ! jq -e . "$config" >/dev/null 2>&1; then + if [[ ! -f "$config" ]] || ! jq -e . "$config" >/dev/null 2>&1; then argate_log "warning: config at '$config' is missing or malformed — allowing dispatch (degraded)" argate_emit "allow" return 0 @@ -460,12 +460,12 @@ argate_gate() { # Exempt actors / labels are never blocked and never counted (same policy as # the library and the PR-limit gate). - if [ -n "$actor" ] && arl_is_exempt_actor "$actor"; then + if [[ -n "$actor" ]] && arl_is_exempt_actor "$actor"; then argate_log "actor '${actor}' is exempt — allowing (not subject to the limits)" argate_emit "allow" return 0 fi - if [ -n "$labels" ] && arl_is_exempt_label "$labels"; then + if [[ -n "$labels" ]] && arl_is_exempt_label "$labels"; then argate_log "run carries an exempt label — allowing (not subject to the limits)" argate_emit "allow" return 0 @@ -491,21 +491,21 @@ argate_gate() { # decision rides on captured stdout and a defer returns non-zero. local decision="allow" breaker_open=0 breaker admission breaker="$(arl_breaker_decision "$agent_type" "$failures" "$last_failure" "$now")" || true - if [ "$breaker" != "decision=allow" ]; then + if [[ "$breaker" != "decision=allow" ]]; then decision="defer" breaker_open=1 fi # Admission — concurrency / cooldown / daily budget (only if the breaker is closed). - if [ "$decision" = "allow" ]; then + if [[ "$decision" = "allow" ]]; then admission="$(arl_admission_decision "$agent_type" "$concurrent" "$last_run" "$daily_count" "$now")" || true - [ "$admission" != "decision=allow" ] && decision="defer" + [[ "$admission" != "decision=allow" ]] && decision="defer" fi # Unresolved run history (#1226): no per-role workflow and no agent-ingress.yml # role job to derive from. The zeroed counters above would read as "idle" and # never throttle, so fail CLOSED instead of allowing. - if [ "$history_rc" -eq 3 ]; then + if [[ "$history_rc" -eq 3 ]]; then argate_log "run history for '${agent_type}' is UNRESOLVED — failing closed (defer) rather than reading it as zero runs" decision="defer" fi @@ -518,13 +518,13 @@ argate_gate() { # of whether a per-agent limit already deferred, so the observability log always # records the budget state (AC #1/#5). local -a tripped_token_windows=() - if [ "${AGENT_TOKEN_BUDGET_ENABLED:-false}" = "true" ]; then + if [[ "${AGENT_TOKEN_BUDGET_ENABLED:-false}" = "true" ]]; then local -a token_fields=() local token_decision read -r -a token_fields < <(argate_token_budget) token_decision="${token_fields[0]:-allow}" tripped_token_windows=("${token_fields[@]:1}") - if [ "$token_decision" = "defer" ]; then + if [[ "$token_decision" = "defer" ]]; then argate_log "token-budget breaker deferred dispatch for '${agent_type}' (org-wide account budget over threshold)" decision="defer" fi @@ -538,7 +538,7 @@ argate_gate() { fi # Log-only canary split: compute + log, but never act (AC #5). - if [ "$mode" != "enforce" ]; then + if [[ "$mode" != "enforce" ]]; then argate_log "log-only mode for '${agent_type}' — computed decision=${decision} but NOT acting (emitting allow)" argate_emit "allow" return 0 @@ -547,7 +547,7 @@ argate_gate() { # Enforcing. Escalate an open breaker exactly once (AC #4), then emit the # computed decision. A defer is a clean no-op — the caller simply skips # dispatch; it is never a job failure. - if [ "$breaker_open" -eq 1 ]; then + if [[ "$breaker_open" -eq 1 ]]; then argate_escalate "$agent_type" "$tracking_repo" "$tracking_issue" fi # Escalate each tripped org-wide token-budget window the same way (dedup marker + @@ -563,6 +563,6 @@ argate_gate() { } # Run main unless sourced for unit testing (ARGATE_LIB_ONLY=1) or dot-sourced. -if [ -z "${ARGATE_LIB_ONLY:-}" ] && [ "${BASH_SOURCE[0]:-$0}" = "${0}" ]; then +if [[ -z "${ARGATE_LIB_ONLY:-}" ]] && [[ "${BASH_SOURCE[0]:-$0}" = "${0}" ]]; then argate_gate "$@" fi diff --git a/scripts/agents-md-cycle-log.sh b/scripts/agents-md-cycle-log.sh index de51b0453..63adea5aa 100755 --- a/scripts/agents-md-cycle-log.sh +++ b/scripts/agents-md-cycle-log.sh @@ -60,7 +60,7 @@ AMCL_FS=$'\037' # --------------------------------------------------------------------------- amcl_data_rows() { local log="$1" - [ -f "$log" ] || return 0 + [[ -f "$log" ]] || return 0 # Strip CR before splitting so a CRLF-saved log does not leave a trailing '\r' # on the last cell (Clean?), which would break the Clean?/fps agreement check # in amcl_validate_log. Returns non-zero (via awk's END exit) if a dated row is @@ -127,7 +127,7 @@ amcl_validate_log() { rc=1 fi while IFS="$AMCL_FS" read -r cycle findings fps details maintainer clean; do - [ -n "$cycle" ] || continue + [[ -n "$cycle" ]] || continue : "${details:-}" if ! amcl_is_uint "$findings"; then printf 'cycle %s: structural-findings count "%s" is not a non-negative integer\n' "$cycle" "$findings" >&2 @@ -137,7 +137,7 @@ amcl_validate_log() { printf 'cycle %s: confirmed-false-positive count "%s" is not a non-negative integer\n' "$cycle" "$fps" >&2 rc=1; continue fi - if [ -z "$maintainer" ] || [ "$maintainer" = "—" ] || [ "$maintainer" = "-" ]; then + if [[ -z "$maintainer" ]] || [[ "$maintainer" = "—" ]] || [[ "$maintainer" = "-" ]]; then printf 'cycle %s: no maintainer named in "Determined by" — every determination must name a maintainer\n' "$cycle" >&2 rc=1; continue fi @@ -145,10 +145,13 @@ amcl_validate_log() { printf 'cycle %s: "Determined by" must contain a valid GitHub @handle (got "%s")\n' "$cycle" "$maintainer" >&2 rc=1; continue fi + # Normalise to base 10: amcl_is_uint accepts leading zeros ("08"), which bash + # arithmetic would otherwise reject as an invalid octal operand. + findings=$((10#$findings)); fps=$((10#$fps)) # Confirmed false positives are SELECTED FROM the structural findings, so the # count can never exceed the finding count; a row that claims otherwise is # malformed (both are validated as non-negative integers above). - if [ "$fps" -gt "$findings" ]; then + if [[ "$fps" -gt "$findings" ]]; then printf 'cycle %s: confirmed false positives (%s) cannot exceed structural findings (%s) — false positives are selected from the findings\n' \ "$cycle" "$fps" "$findings" >&2 rc=1; continue @@ -156,16 +159,16 @@ amcl_validate_log() { # A nonzero false-positive count must record WHAT was confirmed; an empty (or # placeholder "—"/"-") details cell would leave the committed audit record # silent about the determination it claims to make. - if [ "$fps" -ne 0 ] && { [ -z "$details" ] || [ "$details" = "—" ] || [ "$details" = "-" ]; }; then + if [[ "$fps" -ne 0 ]] && { [[ -z "$details" ]] || [[ "$details" = "—" ]] || [[ "$details" = "-" ]]; }; then printf 'cycle %s: %s confirmed false positive(s) recorded but the "False-positive details" cell is empty — record what was confirmed\n' \ "$cycle" "$fps" >&2 rc=1; continue fi local expected_clean - if [ "$fps" -eq 0 ]; then expected_clean="yes"; else expected_clean="no"; fi + if [[ "$fps" -eq 0 ]]; then expected_clean="yes"; else expected_clean="no"; fi local clean_lc clean_lc=$(printf '%s' "$clean" | tr '[:upper:]' '[:lower:]') - if [ "$clean_lc" != "$expected_clean" ]; then + if [[ "$clean_lc" != "$expected_clean" ]]; then printf 'cycle %s: Clean? is "%s" but %s confirmed false positive(s) recorded (expected "%s")\n' \ "$cycle" "$clean" "$fps" "$expected_clean" >&2 rc=1; continue @@ -181,6 +184,13 @@ amcl_validate_log() { # --------------------------------------------------------------------------- amcl_clean_cycles_met() { local log="$1" required="$2" + # Validate and normalise to base 10 so a zero-padded value ("010") is not read + # as octal by the arithmetic comparisons below. + if ! amcl_is_uint "$required"; then + printf 'false' + return 0 + fi + required=$((10#$required)) # A promotion precondition must never be computed over an unvalidated log. If # the log is malformed (bad field count, a Clean?/fps disagreement, an # unattributed determination), the invariants amcl_validate_log enforces are @@ -210,21 +220,21 @@ amcl_clean_cycles_met() { } }' | sort) count="$(printf '%s' "$deduped" | grep -c . || true)" - if [ "$count" -lt "$required" ]; then + if [[ "$count" -lt "$required" ]]; then printf 'false' return 0 fi recent="$(printf '%s' "$deduped" | tail -n "$required")" local cycle findings fps details maintainer cln while IFS="$AMCL_FS" read -r cycle findings fps details maintainer cln; do - [ -n "$cycle" ] || continue + [[ -n "$cycle" ]] || continue : "${findings:-}" "${details:-}" "${cln:-}" - if amcl_is_uint "$fps" && [ "$fps" -eq 0 ] && [ -n "$maintainer" ] \ - && [ "$maintainer" != "—" ] && [ "$maintainer" != "-" ]; then + if amcl_is_uint "$fps" && [[ "$((10#$fps))" -eq 0 ]] && [[ -n "$maintainer" ]] \ + && [[ "$maintainer" != "—" ]] && [[ "$maintainer" != "-" ]]; then clean=$((clean + 1)) fi done < <(printf '%s\n' "$recent") - if [ "$clean" -eq "$required" ]; then + if [[ "$clean" -eq "$required" ]]; then printf 'true' else printf 'false' @@ -255,12 +265,12 @@ amcl_main() { case "$cmd" in validate) local log="${2:-}" - if [ -z "$log" ]; then + if [[ -z "$log" ]]; then printf 'agents-md-cycle-log: validate needs a \n\n' >&2 amcl_usage >&2 return 2 fi - if [ ! -f "$log" ]; then + if [[ ! -f "$log" ]]; then printf 'agents-md-cycle-log: log file not found: %s\n' "$log" >&2 return 2 fi @@ -272,19 +282,20 @@ amcl_main() { ;; eligibility) local log="${2:-}" required="${3:-2}" - if [ -z "$log" ]; then + if [[ -z "$log" ]]; then printf 'agents-md-cycle-log: eligibility needs a \n\n' >&2 amcl_usage >&2 return 2 fi - if [ ! -f "$log" ]; then + if [[ ! -f "$log" ]]; then printf 'agents-md-cycle-log: log file not found: %s\n' "$log" >&2 return 2 fi - if ! amcl_is_uint "$required" || [ "$required" -lt 1 ]; then + if ! amcl_is_uint "$required" || [[ "$((10#$required))" -lt 1 ]]; then printf 'agents-md-cycle-log: required-clean-cycles must be a positive integer, got "%s"\n' "$required" >&2 return 2 fi + required=$((10#$required)) local met met="$(amcl_clean_cycles_met "$log" "$required")" # The report is deliberately explicit that meeting the clean-cycle @@ -295,7 +306,7 @@ amcl_main() { printf 'clean-cycles-met=%s\n' "$met" printf 'maintainer_sign_off_required=true\n' printf 'auto_promote=false\n' - if [ "$met" = "true" ]; then + if [[ "$met" = "true" ]]; then printf 'note=clean-cycle precondition met; promotion still requires explicit maintainer sign-off and a deliberate, reviewable flip — it is never automatic\n' else printf 'note=clean-cycle precondition NOT met; the check remains informational\n' @@ -316,7 +327,7 @@ amcl_main() { # Run the CLI only when executed directly, not when sourced by the bats tests # that exercise the pure helper functions above. -if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then +if [[ "${BASH_SOURCE[0]:-$0}" = "$0" ]]; then set -euo pipefail amcl_main "$@" fi diff --git a/scripts/agents-md-lint.sh b/scripts/agents-md-lint.sh index 7232ebe0b..f83db50a3 100755 --- a/scripts/agents-md-lint.sh +++ b/scripts/agents-md-lint.sh @@ -74,25 +74,25 @@ amdl_extract_headings() { local file="$1" local lineno=0 in_fence=0 fence_char="" fence_len=0 line trimmed hashes text level local _fc _flen - while IFS= read -r line || [ -n "$line" ]; do + while IFS= read -r line || [[ -n "$line" ]]; do lineno=$((lineno + 1)) trimmed="${line#"${line%%[![:space:]]*}"}" if [[ "$trimmed" =~ ^(\`{3,}|~{3,}) ]]; then _fc="${BASH_REMATCH[1]:0:1}" _flen="${#BASH_REMATCH[1]}" - if [ "$in_fence" -eq 0 ]; then + if [[ "$in_fence" -eq 0 ]]; then in_fence=1 fence_char="$_fc" fence_len="$_flen" elif [[ "$trimmed" =~ ^(\`{3,}|~{3,})[[:space:]]*$ ]] && \ - [ "$_fc" = "$fence_char" ] && [ "$_flen" -ge "$fence_len" ]; then + [[ "$_fc" = "$fence_char" ]] && [[ "$_flen" -ge "$fence_len" ]]; then in_fence=0 fence_char="" fence_len=0 fi continue fi - [ "$in_fence" -eq 0 ] || continue + [[ "$in_fence" -eq 0 ]] || continue if [[ "$line" =~ ^(#{1,6})[[:space:]]+(.*)$ ]]; then hashes="${BASH_REMATCH[1]}" text="${BASH_REMATCH[2]}" @@ -118,18 +118,18 @@ amdl_check_single_h1() { headings="$(amdl_extract_headings "$file")" h1_lines="$(printf '%s\n' "$headings" | awk -F'\t' '$1==1{print $2}')" count="$(printf '%s\n' "$headings" | awk -F'\t' 'BEGIN{c=0} $1==1{c++} END{print c}')" - if [ "$count" -eq 0 ]; then + if [[ "$count" -eq 0 ]]; then printf '1\tno level-1 (H1) heading found; exactly one is required\n' return 0 fi - if [ "$count" -gt 1 ]; then + if [[ "$count" -gt 1 ]]; then extra="$(printf '%s\n' "$h1_lines" | sed -n '2p')" printf '%s\tmultiple H1 headings found (%s); exactly one is required\n' "$extra" "$count" return 0 fi first_line="$(printf '%s\n' "$headings" | sed -n '1p' | awk -F'\t' '{print $2}')" h1_line="$(amdl_trim "$h1_lines")" - if [ "$first_line" != "$h1_line" ]; then + if [[ "$first_line" != "$h1_line" ]]; then printf '%s\tthe single H1 heading is not the first heading in the file\n' "$h1_line" fi return 0 @@ -161,26 +161,26 @@ amdl_check_fenced_code_closure() { local file="$1" local lineno=0 in_fence=0 fence_char="" fence_len=0 open_line=0 line trimmed local _fc _flen - while IFS= read -r line || [ -n "$line" ]; do + while IFS= read -r line || [[ -n "$line" ]]; do lineno=$((lineno + 1)) trimmed="${line#"${line%%[![:space:]]*}"}" if [[ "$trimmed" =~ ^(\`{3,}|~{3,}) ]]; then _fc="${BASH_REMATCH[1]:0:1}" _flen="${#BASH_REMATCH[1]}" - if [ "$in_fence" -eq 0 ]; then + if [[ "$in_fence" -eq 0 ]]; then in_fence=1 fence_char="$_fc" fence_len="$_flen" open_line="$lineno" elif [[ "$trimmed" =~ ^(\`{3,}|~{3,})[[:space:]]*$ ]] && \ - [ "$_fc" = "$fence_char" ] && [ "$_flen" -ge "$fence_len" ]; then + [[ "$_fc" = "$fence_char" ]] && [[ "$_flen" -ge "$fence_len" ]]; then in_fence=0 fence_char="" fence_len=0 fi fi done < "$file" - if [ "$in_fence" -ne 0 ]; then + if [[ "$in_fence" -ne 0 ]]; then printf '%s\tfenced code block opened here is never closed\n' "$open_line" fi return 0 @@ -210,7 +210,7 @@ amdl_classify_reference() { target="${target%%[[:space:]]*}" # drop any link title after the URL target="${target#<}" target="${target%>}" - [ -n "$target" ] || return 0 + [[ -n "$target" ]] || return 0 if [[ "$target" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]] || [[ "$target" == //* ]]; then return 0 fi @@ -225,8 +225,8 @@ amdl_classify_reference() { fi path="${target%%#*}" path="${path%%\?*}" - [ -n "$path" ] || return 0 - if [ ! -e "$base/$path" ]; then + [[ -n "$path" ]] || return 0 + if [[ ! -e "$base/$path" ]]; then printf '%s\trelative reference "%s" does not resolve to an existing path\n' "$lineno" "$path" fi return 0 @@ -276,11 +276,11 @@ amdl_check_cross_references() { declare -A slug_count while IFS=$'\t' read -r lvl ln text; do : "${lvl:-}" "${ln:-}" - [ -n "$text" ] || continue + [[ -n "$text" ]] || continue slug="$(amdl_slugify "$text")" n="${slug_count["$slug"]:-0}" slug_count["$slug"]=$((n + 1)) - if [ "$n" -eq 0 ]; then + if [[ "$n" -eq 0 ]]; then slugs+="$slug"$'\n' else slugs+="${slug}-${n}"$'\n' @@ -290,37 +290,37 @@ amdl_check_cross_references() { local lineno=0 in_fence=0 fence_char="" fence_len=0 line trimmed target local _fc _flen _rec="" _rl _have=0 local ref_regex='^[[:space:]]*\[[^]]+\]:[[:space:]]*(.*)' - while IFS= read -r line || [ -n "$line" ]; do + while IFS= read -r line || [[ -n "$line" ]]; do lineno=$((lineno + 1)) trimmed="${line#"${line%%[![:space:]]*}"}" if [[ "$trimmed" =~ ^(\`{3,}|~{3,}) ]]; then _fc="${BASH_REMATCH[1]:0:1}" _flen="${#BASH_REMATCH[1]}" - if [ "$in_fence" -eq 0 ]; then + if [[ "$in_fence" -eq 0 ]]; then in_fence=1 fence_char="$_fc" fence_len="$_flen" elif [[ "$trimmed" =~ ^(\`{3,}|~{3,})[[:space:]]*$ ]] && \ - [ "$_fc" = "$fence_char" ] && [ "$_flen" -ge "$fence_len" ]; then + [[ "$_fc" = "$fence_char" ]] && [[ "$_flen" -ge "$fence_len" ]]; then in_fence=0 fence_char="" fence_len=0 fi continue fi - [ "$in_fence" -eq 0 ] || continue + [[ "$in_fence" -eq 0 ]] || continue # Inline links: [text](target) — consume this line's records from the # single-pass extractor on fd 9 (records for skipped fence lines are dropped). while :; do - if [ "$_have" -eq 0 ]; then + if [[ "$_have" -eq 0 ]]; then IFS= read -r -u 9 _rec || break _have=1 fi _rl="${_rec%%$'\t'*}" - [ "$_rl" -le "$lineno" ] || break + [[ "$_rl" -le "$lineno" ]] || break _have=0 - [ "$_rl" -ne "$lineno" ] || amdl_classify_reference "${_rec#*$'\t'}" "$base" "$slugs" "$lineno" + [[ "$_rl" -ne "$lineno" ]] || amdl_classify_reference "${_rec#*$'\t'}" "$base" "$slugs" "$lineno" done # Extract reference definitions: [label]: target @@ -403,13 +403,13 @@ amdl_lint() { fi while IFS=$'\t' read -r id level applies element; do : "${element:-}" - if [ "$applies" != "all" ] && [ "$applies" != "$scope" ]; then + if [[ "$applies" != "all" ]] && [[ "$applies" != "$scope" ]]; then continue fi check_out="$(amdl_dispatch_rule "$id" "$file")" - [ -n "$check_out" ] || continue + [[ -n "$check_out" ]] || continue while IFS= read -r finding; do - [ -n "$finding" ] || continue + [[ -n "$finding" ]] || continue printf '%s\t%s\t%s\n' "$level" "$id" "$finding" done < <(printf '%s\n' "$check_out") done < <(jq -r '.rules[] | [.id, .level, .applies_to, .element] | @tsv' "$rules") @@ -436,7 +436,7 @@ USAGE # --------------------------------------------------------------------------- amdl_main() { local mode="informational" scope="canonical" rules="$AMDL_DEFAULT_RULES" target="" - while [ "$#" -gt 0 ]; do + while [[ "$#" -gt 0 ]]; do case "$1" in --mode) mode="${2:-}"; shift 2 ;; --mode=*) mode="${1#*=}"; shift ;; @@ -463,27 +463,27 @@ amdl_main() { printf 'agents-md-lint: jq is required but was not found on PATH\n' >&2 return 2 fi - if [ -z "$target" ]; then + if [[ -z "$target" ]]; then printf 'agents-md-lint: no AGENTS.md path given\n\n' >&2 amdl_usage >&2 return 2 fi - if [ ! -f "$rules" ]; then + if [[ ! -f "$rules" ]]; then printf 'agents-md-lint: rule-set file not found: %s\n' "$rules" >&2 return 2 fi - if [ ! -f "$target" ]; then + if [[ ! -f "$target" ]]; then printf 'agents-md-lint: target AGENTS.md not found: %s\n' "$target" >&2 return 2 fi local findings findings="$(amdl_lint "$target" "$rules" "$scope")" - if [ -n "$findings" ]; then + if [[ -n "$findings" ]]; then printf '%s\n' "$findings" fi - if [ "$mode" = "failing" ] && printf '%s\n' "$findings" | grep -q "^required$(printf '\t')"; then + if [[ "$mode" = "failing" ]] && printf '%s\n' "$findings" | grep -q "^required$(printf '\t')"; then return 1 fi return 0 @@ -491,7 +491,7 @@ amdl_main() { # Run the CLI only when executed directly, not when sourced by the bats tests # that exercise the pure helper functions above. -if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then +if [[ "${BASH_SOURCE[0]:-$0}" = "$0" ]]; then set -euo pipefail amdl_main "$@" fi diff --git a/scripts/apply-repo-settings.sh b/scripts/apply-repo-settings.sh index 06182cb9c..c9198c4d0 100644 --- a/scripts/apply-repo-settings.sh +++ b/scripts/apply-repo-settings.sh @@ -25,7 +25,7 @@ set -euo pipefail -if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then +if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then echo "[ERROR] Bash 4+ required (associative arrays). Found: $BASH_VERSION" >&2 echo " On macOS: brew install bash, then run with /opt/homebrew/bin/bash" >&2 exit 1 @@ -116,7 +116,7 @@ apply_labels() { # the function in a subshell whose EXIT CODE is unreachable (mapfile reports its # own status), so a failure there could never be seen — which is exactly how the # fail-open survived review. $( ) is also a subshell, but its status propagates. - if [ "$_PERSONA_OPT_OUT_CONFIGS_CACHED" != true ]; then + if [[ "$_PERSONA_OPT_OUT_CONFIGS_CACHED" != true ]]; then local persona_out="" if persona_out=$(persona_opt_out_label_configs); then # Cache only a GOOD derivation. Caching a failure would mean a transient @@ -132,11 +132,11 @@ apply_labels() { _PERSONA_OPT_OUT_CONFIGS_CACHE=() # Guard the empty case: `mapfile <<< ""` yields one empty element, which would # become a bogus "||" label config. - if [ -n "$persona_out" ]; then + if [[ -n "$persona_out" ]]; then mapfile -t _PERSONA_OPT_OUT_CONFIGS_CACHE <<< "$persona_out" fi fi - if [ "${#_PERSONA_OPT_OUT_CONFIGS_CACHE[@]}" -gt 0 ]; then + if [[ "${#_PERSONA_OPT_OUT_CONFIGS_CACHE[@]}" -gt 0 ]]; then label_configs+=("${_PERSONA_OPT_OUT_CONFIGS_CACHE[@]}") fi @@ -149,7 +149,7 @@ apply_labels() { local label_failed=false for config in "${label_configs[@]}"; do IFS='|' read -r name color description <<< "$config" - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "DRY_RUN=true — would create/update label '$name' (#$color) in $ORG/$repo" else if gh label create "$name" \ @@ -165,7 +165,7 @@ apply_labels() { fi done - if [ "$label_failed" = true ]; then + if [[ "$label_failed" = true ]]; then return 1 fi } @@ -190,7 +190,7 @@ apply_settings() { squash_merge_commit_message: .squash_merge_commit_message }' 2>/dev/null || echo "{}") - if [ "$current" = "{}" ] || [ "$current" = "null" ]; then + if [[ "$current" = "{}" ]] || [[ "$current" = "null" ]]; then err "Could not parse settings for $ORG/$repo" return 1 fi @@ -215,7 +215,7 @@ apply_settings() { actual=$(printf '%s' "$current" | jq -r --arg key "$key" '.[$key] | if . == null then "null" else tostring end') local expected="${EXPECTED[$key]}" - if [ "$actual" != "$expected" ]; then + if [[ "$actual" != "$expected" ]]; then info " $key: $actual → $expected" needs_patch=true patch_args+=(-F "$key=$expected") @@ -227,7 +227,7 @@ apply_settings() { # Check string settings separately (jq -f flag for strings) local squash_title squash_title=$(printf '%s' "$current" | jq -r '.squash_merge_commit_title // "null"') - if [ "$squash_title" != "PR_TITLE" ]; then + if [[ "$squash_title" != "PR_TITLE" ]]; then info " squash_merge_commit_title: $squash_title → PR_TITLE" needs_patch=true patch_args+=(-f squash_merge_commit_title=PR_TITLE) @@ -237,7 +237,7 @@ apply_settings() { local squash_msg squash_msg=$(printf '%s' "$current" | jq -r '.squash_merge_commit_message // "null"') - if [ "$squash_msg" != "COMMIT_MESSAGES" ]; then + if [[ "$squash_msg" != "COMMIT_MESSAGES" ]]; then info " squash_merge_commit_message: $squash_msg → COMMIT_MESSAGES" needs_patch=true patch_args+=(-f squash_merge_commit_message=COMMIT_MESSAGES) @@ -245,12 +245,12 @@ apply_settings() { ok " squash_merge_commit_message: already COMMIT_MESSAGES" fi - if [ "$needs_patch" = false ]; then + if [[ "$needs_patch" = false ]]; then ok "$ORG/$repo is already fully compliant — no changes needed" return 0 fi - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "DRY_RUN=true — skipping PATCH for $ORG/$repo" return 0 fi @@ -306,12 +306,12 @@ apply_codeql_default_setup() { local current_state current_state=$(gh api "repos/$ORG/$repo/code-scanning/default-setup" --jq '.state' 2>/dev/null || echo "") - if [ "$current_state" = "configured" ]; then + if [[ "$current_state" = "configured" ]]; then ok " CodeQL default setup already configured" return 0 fi - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "DRY_RUN=true — would enable CodeQL default setup (current state: ${current_state:-unknown})" return 0 fi @@ -358,7 +358,7 @@ apply_check_suite_prefs() { setting=$(jq -r --argjson id "$app_id" \ '.preferences.auto_trigger_checks // [] | map(select(.app_id == $id)) | first | .setting | if . == null then "missing" else . end' <<< "$prefs") # "missing" means the app has never run in this repo — no orphaned suite possible, skip - if [ "$setting" != "false" ] && [ "$setting" != "missing" ]; then + if [[ "$setting" != "false" ]] && [[ "$setting" != "missing" ]]; then all_disabled=false fi done @@ -367,12 +367,12 @@ apply_check_suite_prefs() { all_disabled=false fi - if [ "$all_disabled" = true ]; then + if [[ "$all_disabled" = true ]]; then ok "$ORG/$repo check-suite prefs already correct" return 0 fi - if [ "${DRY_RUN:-false}" = "true" ]; then + if [[ "${DRY_RUN:-false}" = "true" ]]; then skip "DRY_RUN — skipping check-suite prefs PATCH for $repo" return 0 fi @@ -447,22 +447,22 @@ apply_repo() { # Main # --------------------------------------------------------------------------- main() { -if [ $# -eq 0 ]; then +if [[ $# -eq 0 ]]; then usage fi -if [ -z "${GH_TOKEN:-}" ]; then +if [[ -z "${GH_TOKEN:-}" ]]; then err "GH_TOKEN is required — provide a personal access token or GitHub App token with admin:repo scope" exit 1 fi export GH_TOKEN -if [ "$1" = "--all" ]; then +if [[ "$1" = "--all" ]]; then info "Fetching all non-archived repos in $ORG ..." repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500) - if [ -z "$repos" ]; then + if [[ -z "$repos" ]]; then err "No repositories found in $ORG — check GH_TOKEN permissions" exit 1 fi @@ -486,7 +486,7 @@ if [ "$1" = "--all" ]; then done done - if [ "${#all_failed[@]}" -gt 0 ]; then + if [[ "${#all_failed[@]}" -gt 0 ]]; then echo "::error::apply-repo-settings: failed steps: ${all_failed[*]}" err "${#all_failed[@]} step(s) failed — check output above for details" exit 1 @@ -495,7 +495,7 @@ if [ "$1" = "--all" ]; then # The persona opt-out family is the escape hatch §4 rule 4 mandates. If it could # not be derived faithfully, the static labels still landed — but this run did # NOT do what it claims, and saying so is the whole point (#755). - if [ "$_PERSONA_OPT_OUT_SYNC_FAILED" = true ]; then + if [[ "$_PERSONA_OPT_OUT_SYNC_FAILED" = true ]]; then err "persona opt-out labels could not be derived faithfully — static labels applied, but the :hands-off family is incomplete or guessed" exit 1 fi @@ -513,7 +513,7 @@ else # Same guard as --all: a single-repo run must not exit 0 while the mandated # opt-out hatch is missing from that repo. - if [ "$_PERSONA_OPT_OUT_SYNC_FAILED" = true ]; then + if [[ "$_PERSONA_OPT_OUT_SYNC_FAILED" = true ]]; then err "persona opt-out labels could not be derived faithfully — static labels applied, but the :hands-off family is incomplete or guessed" exit 1 fi @@ -521,7 +521,7 @@ else # A cosmetic step failing must not have aborted the security-critical ones — but # the run still exits non-zero, naming the failed steps, so the failure is visible # (issue #1038). - if [ "${#FAILED_STEPS[@]}" -gt 0 ]; then + if [[ "${#FAILED_STEPS[@]}" -gt 0 ]]; then echo "::error::apply-repo-settings: failed steps for $1: ${FAILED_STEPS[*]}" err "${#FAILED_STEPS[@]} step(s) failed for $ORG/$1 — check output above for details" exit 1 @@ -530,6 +530,6 @@ fi } # Run main only when executed directly, not when sourced (e.g. by the bats suite). -if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then +if [[ "${BASH_SOURCE[0]:-$0}" = "$0" ]]; then main "$@" fi diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh index 99452e4e9..503dc5914 100755 --- a/scripts/apply-rulesets.sh +++ b/scripts/apply-rulesets.sh @@ -85,7 +85,7 @@ read_declared_checks() { err="$(mktemp)" raw="$(gh api -H "Accept: application/vnd.github.raw+json" \ "repos/${repo}/contents/${RC_DECLARED_PATH}" 2>"$err")" && rc=0 || rc=$? - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then if grep -q 'HTTP 404' "$err"; then rm -f "$err"; echo '[]'; return 0; fi echo "::error::failed to read ${RC_DECLARED_PATH} on ${repo}: $(head -c 200 "$err")" >&2 rm -f "$err" @@ -100,7 +100,7 @@ ruleset_id_by_name() { local repo="$1" name="$2" local output rc=0 output=$(gh api --paginate "repos/${repo}/rulesets" 2>/dev/null) && rc=0 || rc=$? - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then echo "::error::failed to fetch rulesets for ${repo} (exit code ${rc})" >&2 return "$rc" fi @@ -112,9 +112,9 @@ apply_one() { local repo="$1" file="$2" local name id id_rc=0 name="$(jq -r '.name' "$file")" - [ -n "$name" ] && [ "$name" != "null" ] || { echo "::error::$file has no .name" >&2; return 1; } + [[ -n "$name" ]] && [[ "$name" != "null" ]] || { echo "::error::$file has no .name" >&2; return 1; } id="$(ruleset_id_by_name "$repo" "$name")" && id_rc=0 || id_rc=$? - if [ "$id_rc" -ne 0 ]; then + if [[ "$id_rc" -ne 0 ]]; then echo "::error::failed to resolve ruleset ID for '${name}' on ${repo}" >&2 return "$id_rc" fi @@ -122,14 +122,14 @@ apply_one() { # code-quality carries the repo's declared extra checks (#1410); read them before # any write so an unreadable declaration stops the update instead of dropping them. local declared='[]' - if [ "$name" = "code-quality" ]; then + if [[ "$name" = "code-quality" ]]; then declared="$(read_declared_checks "$repo")" || { echo "::error::not applying ruleset '${name}' on ${repo}, so its declared required checks are not dropped" >&2 return 1 } fi - if [ -n "$id" ]; then + if [[ -n "$id" ]]; then echo " update ruleset '${name}' (id ${id}) on ${repo}" # A full-replace PUT of the file would lower a stricter live code_scanning # threshold (e.g. security_alerts_threshold `all` → `medium_or_higher`). Read @@ -146,7 +146,7 @@ apply_one() { echo "::error::failed to build the payload for ruleset '${name}' on ${repo}" >&2 return 1 } - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then echo " [dry-run] PUT repos/${repo}/rulesets/${id}" # Compare only the code_scanning rule: the payload also differs from the # file by any declared checks, which _dry_run_declared reports below. @@ -166,7 +166,7 @@ apply_one() { rm -f "$payload" else echo " create ruleset '${name}' on ${repo}" - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then echo " [dry-run] POST repos/${repo}/rulesets" _dry_run_declared "$declared" return 0 @@ -190,7 +190,7 @@ apply_one() { # _dry_run_declared — say which declared checks a dry run keeps. _dry_run_declared() { - [ "$1" = "[]" ] && return 0 + [[ "$1" = "[]" ]] && return 0 echo " [dry-run] adds the repo's declared required checks (${RC_DECLARED_PATH}): $(jq -r 'map("\(.context) (app \(.integration_id))") | join(", ")' <<< "$1")" } @@ -237,10 +237,10 @@ resolve_repo() { main() { local target="" all=false local names=() - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in --repo) - [ "$#" -ge 2 ] || { echo "::error::--repo requires a value" >&2; return 2; } + [[ "$#" -ge 2 ]] || { echo "::error::--repo requires a value" >&2; return 2; } target="$2"; shift 2 ;; --all) all=true; shift ;; --dry-run) DRY_RUN=true; shift ;; @@ -249,32 +249,32 @@ main() { # A bare token is the target repo (back-compat with the retired applier's # positional ) unless --repo/--all already set it, in which case # remaining bare tokens filter which ruleset names to apply. - if [ -z "$target" ] && [ "$all" = false ]; then target="$1"; else names+=("$1"); fi + if [[ -z "$target" ]] && [[ "$all" = false ]]; then target="$1"; else names+=("$1"); fi shift ;; esac done gh auth status >/dev/null 2>&1 || { echo "::error::GitHub authentication failed — set GH_TOKEN or run 'gh auth login'" >&2; return 1; } - [ -d "$RULESETS_DIR" ] || { echo "::error::rulesets dir not found: $RULESETS_DIR" >&2; return 1; } + [[ -d "$RULESETS_DIR" ]] || { echo "::error::rulesets dir not found: $RULESETS_DIR" >&2; return 1; } # Select the ruleset files: the named ones, else the fleet allowlist (NOT every # *.json — that would sweep release-channel-tags fleet-wide; see FLEET_RULESETS). local files=() - if [ "${#names[@]}" -eq 0 ]; then names=("${FLEET_RULESETS[@]}"); fi + if [[ "${#names[@]}" -eq 0 ]]; then names=("${FLEET_RULESETS[@]}"); fi local n for n in "${names[@]}"; do - [ -f "${RULESETS_DIR}/${n}.json" ] && files+=("${RULESETS_DIR}/${n}.json") \ + [[ -f "${RULESETS_DIR}/${n}.json" ]] && files+=("${RULESETS_DIR}/${n}.json") \ || { echo "::error::no ruleset file ${n}.json in ${RULESETS_DIR}" >&2; return 1; } done - [ "${#files[@]}" -gt 0 ] || { echo " no ruleset files to apply"; return 0; } + [[ "${#files[@]}" -gt 0 ]] || { echo " no ruleset files to apply"; return 0; } - if [ "$all" = true ]; then - [ -z "$target" ] || { echo "::error::--all and a repo argument are mutually exclusive" >&2; return 2; } + if [[ "$all" = true ]]; then + [[ -z "$target" ]] || { echo "::error::--all and a repo argument are mutually exclusive" >&2; return 2; } echo "[apply-rulesets] fetching non-archived repos in ${ORG} ..." local repos_json classified action repo failed=0 repos_rc=0 local repos=() repos_json="$(gh repo list "$ORG" --json name,isArchived,isPrivate,hasIssuesEnabled --limit 1000)" && repos_rc=0 || repos_rc=$? - if [ "$repos_rc" -ne 0 ]; then + if [[ "$repos_rc" -ne 0 ]]; then echo "::error::failed to list repositories for organization ${ORG} (exit code ${repos_rc})" >&2 return "$repos_rc" fi @@ -288,21 +288,21 @@ main() { excluded) echo "[apply-rulesets] skip ${ORG}/${repo}: in FLEET_EXCLUDE (no dependency-audit workflow)" ;; esac done <<< "$classified" - [ "${#repos[@]}" -gt 0 ] || { echo "::error::no repositories found in ${ORG} — check GH_TOKEN" >&2; return 1; } + [[ "${#repos[@]}" -gt 0 ]] || { echo "::error::no repositories found in ${ORG} — check GH_TOKEN" >&2; return 1; } for repo in "${repos[@]}"; do apply_repo "${ORG}/${repo}" "${files[@]}" || { failed=$((failed + 1)); echo "::warning::failed on ${ORG}/${repo}"; } done - [ "$failed" -eq 0 ] || { echo "::error::${failed} repo(s) failed" >&2; return 1; } + [[ "$failed" -eq 0 ]] || { echo "::error::${failed} repo(s) failed" >&2; return 1; } echo "[apply-rulesets] done (${#files[@]} ruleset(s) across the fleet)" return 0 fi - [ -n "$target" ] || { echo "::error::usage: $0 --repo owner/repo | | --all [--dry-run] [...]" >&2; return 2; } + [[ -n "$target" ]] || { echo "::error::usage: $0 --repo owner/repo | | --all [--dry-run] [...]" >&2; return 2; } apply_repo "$(resolve_repo "$target")" "${files[@]}" echo "[apply-rulesets] done (${#files[@]} ruleset(s))" } # Source-guard: tests source this to exercise ruleset_id_by_name / apply_one. -if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then +if [[ "${BASH_SOURCE[0]:-$0}" = "$0" ]]; then main "$@" fi diff --git a/scripts/automerge-standards-sync.sh b/scripts/automerge-standards-sync.sh index 31c02ba05..71c1bf31a 100755 --- a/scripts/automerge-standards-sync.sh +++ b/scripts/automerge-standards-sync.sh @@ -81,7 +81,7 @@ PR_NUMBER="" DRY_RUN=false APPROVER_LOGIN="" -while [ "$#" -gt 0 ]; do +while [[ "$#" -gt 0 ]]; do case "$1" in --repo) REPO="${2:?--repo needs a value}"; shift 2 ;; --pr) PR_NUMBER="${2:?--pr needs a value}"; shift 2 ;; @@ -92,8 +92,8 @@ while [ "$#" -gt 0 ]; do esac done -[ -n "$REPO" ] || { echo "::error::--repo is required" >&2; exit 2; } -if [ "$DRY_RUN" != "true" ] && [ -z "${APPROVER_TOKEN:-}" ]; then +[[ -n "$REPO" ]] || { echo "::error::--repo is required" >&2; exit 2; } +if [[ "$DRY_RUN" != "true" ]] && [[ -z "${APPROVER_TOKEN:-}" ]]; then echo "::error::APPROVER_TOKEN is required for a live run (the distinct approver's token). Use --dry-run to preview." >&2 exit 2 fi @@ -104,7 +104,7 @@ in_list() { IFS=',' read -ra _items <<< "$hay" for item in "${_items[@]}"; do item="${item#"${item%%[![:space:]]*}"}"; item="${item%"${item##*[![:space:]]}"}" - [ "$item" = "$needle" ] && return 0 + [[ "$item" = "$needle" ]] && return 0 done return 1 } @@ -116,8 +116,8 @@ resolve_approver() { IFS=',' read -ra _members <<< "$ORG_LEADS_MEMBERS" for item in "${_members[@]}"; do item="${item#"${item%%[![:space:]]*}"}"; item="${item%"${item##*[![:space:]]}"}" - [ -z "$item" ] && continue - if [ "$item" != "$author" ] && [ "$item" != "$last" ]; then + [[ -z "$item" ]] && continue + if [[ "$item" != "$author" ]] && [[ "$item" != "$last" ]]; then printf '%s' "$item" return 0 fi @@ -141,7 +141,7 @@ process_pr() { url=$(jq -r '.url // empty' <<< "$pr_json") last=$(jq -r '.commits[-1].authors[0].login // empty' <<< "$pr_json") labels=$(jq -r '.labels[].name' <<< "$pr_json" 2>/dev/null || true) - [ -n "$url" ] || url="${REPO}#${pr}" + [[ -n "$url" ]] || url="${REPO}#${pr}" # --- Eligibility gates (auditable trusted source) --- if ! grep -qxF "$SYNC_LABEL" <<< "$labels"; then @@ -169,12 +169,12 @@ process_pr() { # --- Resolve the distinct review identity (Option 1) --- local approver - if [ -n "$APPROVER_LOGIN" ]; then + if [[ -n "$APPROVER_LOGIN" ]]; then if ! in_list "$APPROVER_LOGIN" "$ORG_LEADS_MEMBERS"; then echo "::error::approver '${APPROVER_LOGIN}' is not an org-leads code-owner (${ORG_LEADS_MEMBERS})" >&2 return 1 fi - if [ "$APPROVER_LOGIN" = "$author" ] || [ "$APPROVER_LOGIN" = "$last" ]; then + if [[ "$APPROVER_LOGIN" = "$author" ]] || [[ "$APPROVER_LOGIN" = "$last" ]]; then echo "::error::${url} — pinned approver '${APPROVER_LOGIN}' is the author or last pusher; cannot satisfy require_last_push_approval / not-own-PR" >&2 return 1 fi @@ -183,13 +183,13 @@ process_pr() { approver=$(resolve_approver "$author" "$last") fi - if [ -z "$approver" ]; then + if [[ -z "$approver" ]]; then echo "::error::${url} — reviewer DEADLOCK: author='${author}' and last-pusher='${last}' consume every code-owner {${ORG_LEADS_MEMBERS}}, so no distinct code-owner can approve. Resolve by giving the PR a single push identity (author == last pusher) or by adding a distinct org-leads code-owner. NOT merging via --admin." >&2 return 1 fi # --- Approve as the distinct identity + enable native auto-merge (Option 2) --- - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then echo " [dry-run] ${url} — would approve as '${approver}' (distinct code-owner) and enable native auto-merge (squash); admin=none" echo "AUDIT pr=${url} approver=${approver} merge=native-auto-merge admin=none dry_run=true" return 0 @@ -201,11 +201,11 @@ process_pr() { # post a misattributed code-owner approval). local token_login token_login=$(GH_TOKEN="$APPROVER_TOKEN" gh api user --jq '.login' 2>/dev/null || true) - if [ -z "$token_login" ]; then + if [[ -z "$token_login" ]]; then echo "::error::${url} — APPROVER_TOKEN did not authenticate (gh api user failed); refusing to post an unattributable approval" >&2 return 1 fi - if [ "$token_login" != "$approver" ]; then + if [[ "$token_login" != "$approver" ]]; then echo "::error::${url} — APPROVER_TOKEN authenticates as '${token_login}', not the resolved approver '${approver}'; refusing to post a misattributed approval" >&2 return 1 fi @@ -225,7 +225,7 @@ process_pr() { # for a commit that is no longer the head and auto-merge must not be enabled. local now_sha now_sha=$(gh pr view "$pr" --repo "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || true) - if [ "$now_sha" != "$head_sha" ]; then + if [[ "$now_sha" != "$head_sha" ]]; then echo "::error::${url} — head moved from ${head_sha} to '${now_sha}' after verification; approval is bound to ${head_sha}, NOT enabling auto-merge" >&2 return 1 fi @@ -251,23 +251,23 @@ echo " Dry run: ${DRY_RUN}" echo "" rc=0 -if [ -n "$PR_NUMBER" ]; then +if [[ -n "$PR_NUMBER" ]]; then process_pr "$PR_NUMBER" || rc=1 else numbers=$(gh pr list --repo "$REPO" --state open --label "$SYNC_LABEL" \ --json number --jq '.[].number' 2>/dev/null || true) - if [ -z "$numbers" ]; then + if [[ -z "$numbers" ]]; then echo " No open '${SYNC_LABEL}' PRs in ${REPO}." else while IFS= read -r n; do - [ -z "$n" ] && continue + [[ -z "$n" ]] && continue process_pr "$n" || rc=1 done <<< "$numbers" fi fi echo "" -if [ "$rc" -eq 0 ]; then +if [[ "$rc" -eq 0 ]]; then echo "=== Done — no --admin bypass used ===" else echo "=== Completed with unresolved deadlock(s); see errors above (no --admin used) ===" >&2 diff --git a/scripts/bootstrap-new-repo.sh b/scripts/bootstrap-new-repo.sh index ba0654291..cff7b7da6 100755 --- a/scripts/bootstrap-new-repo.sh +++ b/scripts/bootstrap-new-repo.sh @@ -82,7 +82,7 @@ readonly -a BOOTSTRAP_LABELS=( "automerge|0e8a16|Eligible for auto-merge once required checks pass" ) -_is_dry() { [ "$DRY_RUN" = "true" ]; } +_is_dry() { [[ "$DRY_RUN" = "true" ]]; } # ── Release-ring confirmation & registration (issue #968, epic #964) ────────── # The default ring `stable` is record-only: a new repo is already covered by the @@ -103,7 +103,7 @@ _ring_channels() { _ring_is_valid() { local agent="$1" ring="$2" c while IFS= read -r c; do - [ "$c" = "$ring" ] && return 0 + [[ "$c" = "$ring" ]] && return 0 done < <(_ring_channels "$agent") return 1 } @@ -115,10 +115,10 @@ _record_ring_decision() { op="$(_ring_operator)" ts="$(_ring_timestamp)" decision="recorded" - [ "$ring" != "stable" ] && decision="registered" + [[ "$ring" != "stable" ]] && decision="registered" line="[ring-audit] repo=${repo} agent=${agent} ring=${ring} operator=${op} at=${ts} decision=${decision}" echo " $line" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then printf '%s\n' "$line" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true fi } @@ -181,13 +181,13 @@ _cross_repo_file_pr() { local -a sha_arg=() default_branch="$(gh api "repos/${repo}" --jq '.default_branch' 2>/dev/null || echo main)" base_sha="$(gh api "repos/${repo}/git/ref/heads/${default_branch}" --jq '.object.sha' 2>/dev/null || true)" - [ -n "$base_sha" ] || { echo " [warn] ${repo}: cannot read ${default_branch} head" >&2; return 1; } + [[ -n "$base_sha" ]] || { echo " [warn] ${repo}: cannot read ${default_branch} head" >&2; return 1; } gh api "repos/${repo}/git/refs" --method POST \ --field "ref=refs/heads/${branch}" --field "sha=${base_sha}" --silent 2>/dev/null \ || gh api "repos/${repo}/git/ref/heads/${branch}" --silent 2>/dev/null \ || { echo " [warn] ${repo}: cannot create branch ${branch}" >&2; return 1; } sha="$(gh api "repos/${repo}/contents/${path}?ref=${branch}" --jq '.sha' 2>/dev/null || true)" - [ -n "$sha" ] && sha_arg=(--field "sha=${sha}") + [[ -n "$sha" ]] && sha_arg=(--field "sha=${sha}") # Portable unwrapped base64: GNU wraps at 76 cols and BSD has no -w0, so strip # newlines explicitly — the Contents API rejects a wrapped payload. encoded="$(printf '%s' "$content" | base64 | tr -d '\n')" @@ -198,7 +198,7 @@ _cross_repo_file_pr() { # gh/jq availability is checked once in main(); no need to re-check here. local existing_pr existing_pr="$(gh pr list --repo "$repo" --head "$branch" --state open --json number --jq '.[0].number' 2>/dev/null || true)" - if [ -n "$existing_pr" ]; then + if [[ -n "$existing_pr" ]]; then echo " [ring] PR #$existing_pr already open for branch $branch on $repo" else gh pr create --repo "$repo" --head "$branch" --base "$default_branch" \ @@ -215,7 +215,7 @@ _cross_repo_file_pr() { _cross_repo_ring_pins_pr() { local repo="$1" agent="$2" ring="$3" content new branch content="$(gh api "repos/${STANDARDS_REPO}/contents/${RING_PINS_PATH}" --jq '.content' 2>/dev/null | { base64 -d 2>/dev/null || base64 -D 2>/dev/null; } || true)" - [ -n "$content" ] || { echo "::error::cannot fetch ${STANDARDS_REPO}:${RING_PINS_PATH} for ring-pins registration" >&2; return 1; } + [[ -n "$content" ]] || { echo "::error::cannot fetch ${STANDARDS_REPO}:${RING_PINS_PATH} for ring-pins registration" >&2; return 1; } # Ring-specific: only treat as in-sync if the repo is already in THIS ring's array # line (`=( … )`). A bare repo-wide match would false-positive when the repo is # registered in a *different* ring, leaving canary-rings.json and ring-pins.sh divergent. @@ -246,10 +246,10 @@ _cross_repo_repin_stub_pr() { local repo="$1" agent="$2" ring="$3" path content new branch path=".github/workflows/${agent}.yml" content="$(gh api "repos/${repo}/contents/${path}" --jq '.content' 2>/dev/null | { base64 -d 2>/dev/null || base64 -D 2>/dev/null; } || true)" - [ -n "$content" ] || { echo " [warn] ${repo}: no ${path} stub to repin" >&2; return 0; } + [[ -n "$content" ]] || { echo " [warn] ${repo}: no ${path} stub to repin" >&2; return 0; } new="$(printf '%s' "$content" \ | sed -E "s#@${agent}/[A-Za-z0-9_-]+#@${agent}/${ring}#g; s#(agent_ref:[[:space:]]*[\"']?)${agent}/[A-Za-z0-9_-]+#\\1${agent}/${ring}#g")" - if [ "$new" = "$content" ]; then + if [[ "$new" = "$content" ]]; then echo " [ring] ${repo} stub already pins @${agent}/${ring}" return 0 fi @@ -264,7 +264,7 @@ _cross_repo_repin_stub_pr() { step_ring() { local repo="${1:-}" proposed echo "[bootstrap] (1/6) release ring confirmation (${RING_AGENT}/${RING})" - if [ ! -f "$CANARY_RINGS" ]; then + if [[ ! -f "$CANARY_RINGS" ]]; then echo "::error::ring SoT not found at $CANARY_RINGS" >&2 return 1 fi @@ -274,7 +274,7 @@ step_ring() { fi _record_ring_decision "$repo" "$RING_AGENT" "$RING" - if [ "$RING" = "stable" ]; then + if [[ "$RING" = "stable" ]]; then echo " ring=stable — record-only; no central-file change required (covered by the '*' catch-all)" return 0 fi @@ -375,10 +375,10 @@ step_codeowners() { local path for path in .github/CODEOWNERS CODEOWNERS docs/CODEOWNERS; do encoded="$(gh api "repos/${repo}/contents/${path}" --jq '.content' 2>/dev/null || true)" - [ -n "$encoded" ] && [ "$encoded" != "null" ] && break + [[ -n "$encoded" ]] && [[ "$encoded" != "null" ]] && break encoded="" done - if [ -z "$encoded" ]; then + if [[ -z "$encoded" ]]; then echo " [warn] CODEOWNERS not found on ${repo} — cannot verify team ownership" >&2 return 0 fi @@ -387,7 +387,7 @@ step_codeowners() { first_owner="$(printf '%s\n' "$decoded" \ | sed 's/#.*//' \ | awk 'NF >= 2 {print $2; exit}')" - if [ "$first_owner" = "$CODEOWNERS_TEAM" ]; then + if [[ "$first_owner" = "$CODEOWNERS_TEAM" ]]; then echo " verified: ${CODEOWNERS_TEAM} is the first CODEOWNERS owner" else echo " [warn] CODEOWNERS first owner is '${first_owner:-}', expected ${CODEOWNERS_TEAM}" >&2 @@ -426,7 +426,7 @@ _gitignore_l2_lost() { local existing="$1" upserted="$2" block="$3" local before before="$(_gitignore_l2_lines "$existing")" - [ -n "$before" ] || return 0 + [[ -n "$before" ]] || return 0 awk ' FILENAME == ARGV[1] { after[$0] = 1; next } FILENAME == ARGV[2] { blk[$0] = 1; next } @@ -447,7 +447,7 @@ _gitignore_l2_lost() { step_gitignore() { local repo="${1:-}" block echo "[bootstrap] (6/6) .gitignore secrets baseline" - if [ ! -f "$GITIGNORE_CANONICAL" ]; then + if [[ ! -f "$GITIGNORE_CANONICAL" ]]; then echo "::error::canonical .gitignore baseline not found at $GITIGNORE_CANONICAL" >&2 return 1 fi @@ -488,7 +488,7 @@ step_gitignore() { trap - EXIT current="$(printf '%s' "$existing")" - if [ "$current" = "$upserted" ]; then + if [[ "$current" = "$upserted" ]]; then echo " ${repo}:.gitignore already carries the current baseline — nothing to do" return 0 fi @@ -498,7 +498,7 @@ step_gitignore() { # so the no-change shortcut above runs first (#1403). local lost lost="$(_gitignore_l2_lost "$existing" "$upserted" "$block")" || return 1 - if [ -n "$lost" ]; then + if [[ -n "$lost" ]]; then echo "::error::gitignore upsert for ${repo} would drop $(printf '%s\n' "$lost" | grep -c '') per-repo L2 line(s) that are not part of the baseline block — refusing to open a PR (first: $(printf '%s\n' "$lost" | head -1))" >&2 printf '%s\n' "$lost" | sed 's/^/ would drop: /' >&2 return 1 @@ -515,19 +515,19 @@ step_gitignore() { main() { local repo="" - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in - --ring) [ $# -ge 2 ] || { echo "::error::--ring requires a value" >&2; return 2; }; RING="$2"; shift 2 ;; + --ring) [[ $# -ge 2 ]] || { echo "::error::--ring requires a value" >&2; return 2; }; RING="$2"; shift 2 ;; --ring=*) RING="${1#--ring=}"; shift ;; - --agent) [ $# -ge 2 ] || { echo "::error::--agent requires a value" >&2; return 2; }; RING_AGENT="$2"; shift 2 ;; + --agent) [[ $# -ge 2 ]] || { echo "::error::--agent requires a value" >&2; return 2; }; RING_AGENT="$2"; shift 2 ;; --agent=*) RING_AGENT="${1#--agent=}"; shift ;; --) shift; break ;; -*) echo "::error::unknown flag: $1" >&2; return 2 ;; - *) if [ -z "$repo" ]; then repo="$1"; else echo "::error::unexpected argument: $1" >&2; return 2; fi; shift ;; + *) if [[ -z "$repo" ]]; then repo="$1"; else echo "::error::unexpected argument: $1" >&2; return 2; fi; shift ;; esac done - if [ -z "$repo" ] && [ $# -gt 0 ]; then repo="$1"; fi - if [ -z "$repo" ] || [[ "$repo" != ?*/?* || "$repo" == */*/* ]]; then + if [[ -z "$repo" ]] && [[ $# -gt 0 ]]; then repo="$1"; fi + if [[ -z "$repo" ]] || [[ "$repo" != ?*/?* || "$repo" == */*/* ]]; then echo "::error::usage: $0 [--ring ] [--agent ] owner/new-repo (DRY_RUN=true for a no-write preview)" >&2 return 2 fi @@ -540,12 +540,12 @@ main() { fi done - if [ ! -f "$APPLY_REPO_SETTINGS" ]; then + if [[ ! -f "$APPLY_REPO_SETTINGS" ]]; then echo "::error::apply-repo-settings.sh not found at $APPLY_REPO_SETTINGS" >&2 return 1 fi - if [ ! -f "$APPLY_RULESETS" ]; then + if [[ ! -f "$APPLY_RULESETS" ]]; then echo "::error::apply-rulesets.sh not found at $APPLY_RULESETS" >&2 return 1 fi @@ -559,10 +559,10 @@ main() { if ! step_codeowners "$repo"; then fail_summary "$repo" "codeowners"; return 1; fi if ! step_gitignore "$repo"; then fail_summary "$repo" "gitignore"; return 1; fi - pass_summary "$repo" "$([ "$DRY_RUN" = "true" ] && echo "dry-run")" + pass_summary "$repo" "$([[ "$DRY_RUN" = "true" ]] && echo "dry-run")" } # Source-guard: tests source this to exercise individual step_* helpers. -if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then +if [[ "${BASH_SOURCE[0]:-$0}" = "$0" ]]; then main "$@" fi diff --git a/scripts/canary-rollout.sh b/scripts/canary-rollout.sh index 3264f7eda..04abd9ebf 100755 --- a/scripts/canary-rollout.sh +++ b/scripts/canary-rollout.sh @@ -77,7 +77,7 @@ THIS_REPO="${GITHUB_REPOSITORY:-combsmith/.github}" # is treated as UNRESOLVABLE and fails safe to a major bump (#1023 defect 1b), never silently to # patch. 50 pages = 5000 commits — far beyond any real inter-cut range. CANARY_MAX_COMMIT_PAGES="${CANARY_MAX_COMMIT_PAGES:-50}" -if ! [[ "$CANARY_MAX_COMMIT_PAGES" =~ ^[0-9]+$ ]] || [ "$CANARY_MAX_COMMIT_PAGES" -le 0 ]; then +if ! [[ "$CANARY_MAX_COMMIT_PAGES" =~ ^[0-9]+$ ]] || [[ "$CANARY_MAX_COMMIT_PAGES" -le 0 ]]; then CANARY_MAX_COMMIT_PAGES=50 fi @@ -107,7 +107,7 @@ _registry_preload() { # Invalidate first so a failed (re)load falls back to the jq lookups instead of serving a stale memo. _REG_LOADED_FOR=""; _REG_RUN_WF=(); _REG_INGRESS_JOB=(); _REG_INGRESS_WF="" local path="${CANARY_RINGS:-}" dump agent wf job iwf - [ -n "$path" ] && [ -r "$path" ] || return 1 + [[ -n "$path" ]] && [[ -r "$path" ]] || return 1 # A value containing a newline or U+001F would corrupt the framing; refuse to preload (jq errors) so # every lookup uses the exact per-call jq path instead. dump="$(jq -r 'def chk: if test("[\n\u001f]") then error("delimiter in registry value") else . end; @@ -118,7 +118,7 @@ _registry_preload() { _REG_RUN_WF=(); _REG_INGRESS_JOB=() { IFS= read -r iwf || true while IFS=$'\x1f' read -r agent wf job; do - [ -n "$agent" ] || continue + [[ -n "$agent" ]] || continue _REG_RUN_WF["$agent"]="$wf"; _REG_INGRESS_JOB["$agent"]="$job" done; } <<< "$dump" _REG_INGRESS_WF="${iwf:-Agent Ingress}" @@ -127,7 +127,7 @@ _registry_preload() { # Accessors set _REG_VAL (no command substitution, so no fork). Each returns the same value as the # jq lookup it replaces ("null" for a missing run_workflow, "" for a missing ingress_job). -_reg_loaded() { [ -n "$_REG_LOADED_FOR" ] && [ "$_REG_LOADED_FOR" = "${CANARY_RINGS:-}" ]; } +_reg_loaded() { [[ -n "$_REG_LOADED_FOR" ]] && [[ "$_REG_LOADED_FOR" = "${CANARY_RINGS:-}" ]]; } _reg_run_workflow() { if _reg_loaded && [[ -v _REG_RUN_WF["$1"] ]]; then _REG_VAL="${_REG_RUN_WF[$1]}" else _REG_VAL="$(_agent_field "$1" run_workflow)"; fi @@ -170,9 +170,9 @@ _cache_name() { *) printf -v c '%%%02X' "'$c"; out+="$c" ;; esac done - if [ "${#out}" -gt 200 ]; then + if [[ "${#out}" -gt 200 ]]; then h="$(printf '%s' "$key" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | cut -d' ' -f1)" || h="" - [ -z "$h" ] || out="h_${h}" + [[ -z "$h" ]] || out="h_${h}" fi _CACHE_NAME="$out" } @@ -188,12 +188,12 @@ resolve_members() { local agent="$1" channel="$2" host t r host="$(_agent_field "$agent" host)" while IFS= read -r t; do - [ -z "$t" ] && continue + [[ -z "$t" ]] && continue case "$t" in '$host') printf '%s\n' "$host" ;; '$org_infra') while IFS= read -r r; do - if [ "$r" != "$host" ]; then printf '%s\n' "$r"; fi + if [[ "$r" != "$host" ]]; then printf '%s\n' "$r"; fi done < <(_jq -r '.org_infra_repos[]') ;; '*') printf '%s\n' '*' ;; *) printf '%s\n' "$t" ;; @@ -209,10 +209,10 @@ resolve_members() { # verdict is never cached, so a transient failure cannot stick. _repo_readable() { local repo="$1" marker="" - [ -n "${_RUNS_CACHE_DIR:-}" ] && marker="$_RUNS_CACHE_DIR/readable.${repo//\//__}" - [ -n "$marker" ] && [ -e "$marker" ] && return 0 + [[ -n "${_RUNS_CACHE_DIR:-}" ]] && marker="$_RUNS_CACHE_DIR/readable.${repo//\//__}" + [[ -n "$marker" ]] && [[ -e "$marker" ]] && return 0 gh api "repos/$repo" --jq '.full_name' >/dev/null 2>&1 || return 1 - [ -n "$marker" ] && : > "$marker" 2>/dev/null + [[ -n "$marker" ]] && : > "$marker" 2>/dev/null return 0 } @@ -227,7 +227,7 @@ _gh_tag_commit() { local repo="$1" tag="$2" ref_info obj type err rc=0 commit _tag_unrecorded=0 err="$(mktemp 2>/dev/null || echo /dev/null)" ref_info="$(gh api "repos/$repo/git/ref/tags/$tag" --jq '[(.object?.sha // "" | tostring), (.object?.type // "" | tostring)] | @tsv' 2>"$err")" || rc=$? - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then # Absence is an explicit HTTP 404 ONLY; any other failure is a lookup error. A 404 is also what # GitHub returns for a repo the App cannot access, so confirm the host stays readable before # accepting it as an absent tag (otherwise an inaccessible host reads as "every ring empty"). @@ -236,14 +236,14 @@ _gh_tag_commit() { elif ! _repo_readable "$repo"; then _tag_lookup_failed "$repo" "$tag" "404 but host repository is not readable (access lost?)" || _tag_unrecorded=1 fi - [ "$err" != /dev/null ] && rm -f "$err" - [ "$_tag_unrecorded" -eq 1 ] && printf '%s\n' "$_TAG_LOOKUP_UNRECORDED" + [[ "$err" != /dev/null ]] && rm -f "$err" + [[ "$_tag_unrecorded" -eq 1 ]] && printf '%s\n' "$_TAG_LOOKUP_UNRECORDED" return 0 fi - [ "$err" != /dev/null ] && rm -f "$err" - [ -z "$ref_info" ] && return 0 + [[ "$err" != /dev/null ]] && rm -f "$err" + [[ -z "$ref_info" ]] && return 0 read -r obj type <<< "$ref_info" - if [ "$type" = "tag" ]; then + if [[ "$type" = "tag" ]]; then # The ref EXISTS, so a failed deref is an error, never "absent". if commit="$(gh api "repos/$repo/git/tags/$obj" --jq '(.object?.sha // "" | tostring)' 2>/dev/null)"; then printf '%s\n' "$commit" @@ -264,7 +264,7 @@ _gh_tag_commit() { _TAG_LOOKUP_UNRECORDED="!tag-lookup-error-unrecorded" _tag_lookup_failed() { echo "::warning::_gh_tag_commit: could not resolve $2 on $1 (lookup error, treated as UNKNOWN — not absent): $3" >&2 - if [ -n "${_CANARY_TAG_FAIL_FLAG:-}" ]; then + if [[ -n "${_CANARY_TAG_FAIL_FLAG:-}" ]]; then printf '%s %s\n' "$1" "$2" >> "$_CANARY_TAG_FAIL_FLAG" 2>/dev/null || return 1 fi return 0 @@ -281,7 +281,7 @@ _tag_lookup_failed() { # stable-tier enumeration). Absent CANARY_WRITE_TOKEN (e.g. unit tests / local runs) it is a # transparent pass-through, so every existing GH_TOKEN-only path is unchanged. _gh_write() { - if [ -n "${CANARY_WRITE_TOKEN:-}" ]; then + if [[ -n "${CANARY_WRITE_TOKEN:-}" ]]; then GH_TOKEN="$CANARY_WRITE_TOKEN" gh "$@" else gh "$@" @@ -302,7 +302,7 @@ _gh_write() { # PATCH is a genuine 404/"not found" (the ref does not yet exist) — for any OTHER failure a # POST would 422 "Reference already exists" and MASK the real rejection, so we stop and report. _gh_move_tag() { - [ $# -lt 3 ] && return 1 + [[ $# -lt 3 ]] && return 1 local repo="$1" tag="$2" sha="$3" out rc # #1402: this writer force-moves with the release-manager App's ruleset bypass, so it moves # mutable CHANNEL tags only — never an immutable release tag (/vX.Y.Z) or a malformed @@ -356,24 +356,24 @@ _gh_move_tag() { _gh_403_diag() { local repo="$1" hdr inst count="" echo "::group::_gh_move_tag 403 effective-permission diagnostic (#749)" - if [ -n "${CANARY_WRITE_TOKEN:-}" ]; then + if [[ -n "${CANARY_WRITE_TOKEN:-}" ]]; then echo "diag: introspecting with CANARY_WRITE_TOKEN (repo-scoped write token)" else echo "diag: introspecting with ambient GH_TOKEN (no CANARY_WRITE_TOKEN set)" fi # (1) What permission does the API advertise for this repo endpoint? hdr="$(_gh_write api -i "repos/$repo" 2>/dev/null | grep -i '^x-accepted-github-permissions:' || true)" - if [ -n "$hdr" ]; then + if [[ -n "$hdr" ]]; then printf 'diag: %s\n' "${hdr%$'\r'}" else echo "diag: X-Accepted-GitHub-Permissions header not present on repos/$repo response" fi # (2) How many repositories does the installation token cover? inst="$(_gh_write api /installation/repositories 2>/dev/null || true)" - if [ -n "$inst" ]; then + if [[ -n "$inst" ]]; then count="$(jq -r '.total_count? // empty' <<<"$inst" 2>/dev/null || true)" fi - if [ -n "$count" ]; then + if [[ -n "$count" ]]; then echo "diag: installation/repositories total_count=${count}" else echo "diag: installation/repositories total_count=" @@ -392,12 +392,12 @@ _gh_403_diag() { # dev-lead agent whose reusable lives in .github-private, #613 relocation). Returns non-zero # on API failure so the caller can degrade best-effort. _gh_create_annotated_tag() { - [ $# -lt 4 ] && return 1 + [[ $# -lt 4 ]] && return 1 local repo="$1" tag="$2" sha="$3" message="$4" obj obj="$(_gh_write api -X POST "repos/$repo/git/tags" \ -f tag="$tag" -f message="$message" -f object="$sha" -f type=commit \ --jq '.sha // empty')" - if [ $? -ne 0 ] || [ -z "$obj" ]; then + if [[ $? -ne 0 ]] || [[ -z "$obj" ]]; then echo "::error::_gh_create_annotated_tag: could not create the annotated release tag on $repo or read back its object SHA" >&2 return 1 fi @@ -427,7 +427,7 @@ _agent_current_major() { versions="$(_host_release_versions "$agent")" # shellcheck disable=SC2086 highest="$(max_semver $versions)" - if [ -n "$highest" ]; then + if [[ -n "$highest" ]]; then major="$(major_component "$highest")" fi _AGENT_MAJOR_CACHE["$agent"]="$major" @@ -447,7 +447,7 @@ _channel_tag_commit() { fi local commit="" host="$(_agent_field "$agent" host)" - if [ -n "$host" ] && [ "$host" != "$THIS_REPO" ]; then + if [[ -n "$host" ]] && [[ "$host" != "$THIS_REPO" ]]; then commit="$(_gh_tag_commit "$host" "$agent/$suffix")" else commit="$(git rev-parse -q --verify "refs/tags/$agent/$suffix^{commit}" 2>/dev/null \ @@ -466,7 +466,7 @@ _channel_tag_commit() { # or is empty, so resolution for such agents stays byte-identical to pre-F4. _agent_has_channel_major() { local agent="$1" major="$2" - [ -n "$major" ] || return 1 + [[ -n "$major" ]] || return 1 _looks_like_oid "$(_channel_tag_commit "$agent" "v${major}-next")" } @@ -534,7 +534,7 @@ channel_commit() { # closed). Local-git agents have no API failure mode, so they are never unknown. _ring_commits() { local agent="$1" chans="${2:-}" flag ch c u - [ -n "$chans" ] || chans="$(ordered_channels "$agent")" + [[ -n "$chans" ]] || chans="$(ordered_channels "$agent")" local chan_array=() IFS=, read -r -a chan_array <<< "$chans" flag="$(mktemp 2>/dev/null || true)" @@ -542,32 +542,32 @@ _ring_commits() { local any_known=0 for ch in "${chan_array[@]}"; do u=0 - if [ -n "$flag" ] && : > "$flag" 2>/dev/null; then + if [[ -n "$flag" ]] && : > "$flag" 2>/dev/null; then # Drop the (subshell-local) lookup cache first: an earlier UNARMED failed lookup may have # cached an empty commit, which would skip the API call, leave the flag empty, and read an # outage as an absent tag (unknown=0). c="$(_CHANNEL_COMMIT_CACHE=(); _AGENT_CHANNEL_MAJOR_CACHE=(); _CANARY_TAG_FAIL_FLAG="$flag" channel_commit "$agent" "$ch" || true)" - [ -s "$flag" ] && u=1 + [[ -s "$flag" ]] && u=1 if [[ "$c" == *"$_TAG_LOOKUP_UNRECORDED"* ]]; then c=""; u=1; fi else c="$(channel_commit "$agent" "$ch" || true)"; u=1 [[ "$c" == *"$_TAG_LOOKUP_UNRECORDED"* ]] && c="" fi - { [ -n "$c" ] || [ "$u" != 0 ]; } && any_known=1 + { [[ -n "$c" ]] || [[ "$u" != 0 ]]; } && any_known=1 lines+=("$ch ${c:--} $u") done - [ -n "$flag" ] && rm -f "$flag" + [[ -n "$flag" ]] && rm -f "$flag" # A LOCAL-git agent with EVERY ring tag absent is not "fully rolled out": the rollout state # cannot be read (e.g. a local run from the wrong repo, or a clone without ring tags, #1177). # /vX.Y.Z release tags being present make no difference. Fail loudly and report every # ring unknown, so the frontier holds it BLOCKED (#1225 path) instead of emitting a false COMPLETE. local host; host="$(_agent_field "$agent" host)" - if [ "$any_known" -eq 0 ] && { [ -z "$host" ] || [ "$host" = "$THIS_REPO" ]; }; then + if [[ "$any_known" -eq 0 ]] && { [[ -z "$host" ]] || [[ "$host" = "$THIS_REPO" ]]; }; then echo "::error::cannot read tags for $agent on ${host:-$THIS_REPO}: this checkout has none of its ring tags (THIS_REPO=$THIS_REPO). Run from a checkout of ${host:-$THIS_REPO} with its tags fetched, or set GITHUB_REPOSITORY to the checkout's repo. Holding every ring BLOCKED (fail closed, #1177)." >&2 local i for i in "${!lines[@]}"; do lines[i]="${lines[i]% *} 1"; done fi - if [ "${#lines[@]}" -gt 0 ]; then printf '%s\n' "${lines[@]}"; fi + if [[ "${#lines[@]}" -gt 0 ]]; then printf '%s\n' "${lines[@]}"; fi return 0 } @@ -582,7 +582,7 @@ _iso_now_minus_days() { || date -u -v"-${1}d" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "" } _to_z() { # normalise any parseable timestamp to ISO-8601 Zulu (empty passes through) - [ -z "${1:-}" ] && { echo ""; return 0; } + [[ -z "${1:-}" ]] && { echo ""; return 0; } date -u -d "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ || date -u -v "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ || echo "$1" @@ -608,15 +608,15 @@ _epoch() { _gh_candidate_cut_date() { local repo="$1" agent="$2" commit="$3" ref obj type csha cdate while IFS=$'\t' read -r ref obj type; do - [ -z "$obj" ] && continue + [[ -z "$obj" ]] && continue _is_release_tag_suffix "${ref#refs/tags/"$agent"/}" || continue - if [ "$type" = "tag" ]; then + if [[ "$type" = "tag" ]]; then IFS=$'\t' read -r csha cdate < <(gh api "repos/$repo/git/tags/$obj" \ --jq '[(.object?.sha // "" | tostring), (.tagger?.date // "" | tostring)] | @tsv' 2>/dev/null) || true else csha="$obj"; cdate="" fi - if [ "$csha" = "$commit" ]; then _to_z "$cdate"; return 0; fi + if [[ "$csha" = "$commit" ]]; then _to_z "$cdate"; return 0; fi done < <(gh api "repos/$repo/git/matching-refs/tags/$agent/v" \ --paginate \ --jq '.[]? | [.ref, (.object?.sha // "" | tostring), (.object?.type // "" | tostring)] | @tsv' 2>/dev/null) @@ -641,14 +641,14 @@ _gh_candidate_cut_date() { candidate_cut_date() { local agent="$1" commit="$2" host ref deref cdate host="$(_agent_field "$agent" host)" - if [ -n "$host" ] && [ "$host" != "$THIS_REPO" ]; then + if [[ -n "$host" ]] && [[ "$host" != "$THIS_REPO" ]]; then _gh_candidate_cut_date "$host" "$agent" "$commit" return 0 fi while IFS='|' read -r ref _ deref cdate; do _is_release_tag_suffix "${ref#refs/tags/"$agent"/}" || continue - [ -z "$deref" ] && continue # lightweight release-named tag — no tagger date to trust - if [ "$deref" = "$commit" ]; then _to_z "$cdate"; return 0; fi + [[ -z "$deref" ]] && continue # lightweight release-named tag — no tagger date to trust + if [[ "$deref" = "$commit" ]]; then _to_z "$cdate"; return 0; fi done < <(git for-each-ref \ --format='%(refname)|%(objectname)|%(*objectname)|%(creatordate:iso-strict)' \ "refs/tags/${agent}/v*" 2>/dev/null) @@ -668,18 +668,18 @@ _gh_err_summary() { lc="${err,,}" if [[ "$lc" == *"secondary rate limit"* ]]; then class="secondary-rate-limit" elif [[ "$lc" == *"rate limit"* ]]; then class="rate-limit" - elif [ "$status" = "401" ] || [[ "$lc" == *"bad credentials"* ]] || [[ "$lc" == *"requires authentication"* ]]; then class="auth" - elif [ -n "$status" ] && [ "$status" -ge 500 ]; then class="server-error" - elif [ "$status" = "403" ]; then class="forbidden" - elif [ -n "$status" ] && [ "$status" -ge 400 ]; then class="client-error" + elif [[ "$status" = "401" ]] || [[ "$lc" == *"bad credentials"* ]] || [[ "$lc" == *"requires authentication"* ]]; then class="auth" + elif [[ -n "$status" ]] && [[ "$status" -ge 500 ]]; then class="server-error" + elif [[ "$status" = "403" ]]; then class="forbidden" + elif [[ -n "$status" ]] && [[ "$status" -ge 400 ]]; then class="client-error" else class="network-or-unknown"; fi # first non-blank line of gh's stderr, trimmed, capped so an annotation stays readable - while IFS= read -r line || [ -n "$line" ]; do + while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" - if [ -n "${line//[[:space:]]/}" ]; then reason="$line"; break; fi + if [[ -n "${line//[[:space:]]/}" ]]; then reason="$line"; break; fi done <<< "$err" reason="${reason:0:200}" - if [ -n "$status" ]; then printf 'HTTP %s %s: %s' "$status" "$class" "$reason" + if [[ -n "$status" ]]; then printf 'HTTP %s %s: %s' "$status" "$class" "$reason" else printf '%s: %s' "$class" "$reason"; fi } @@ -693,7 +693,7 @@ _gh_retry_after() { if [[ "$lc" =~ retry[-\ ]?after:?[\ ]*([0-9]+) ]]; then secs="${BASH_REMATCH[1]}" elif [[ "$lc" =~ x-ratelimit-reset:?[\ ]*([0-9]+) ]]; then - now="$(date +%s)"; secs=$(( BASH_REMATCH[1] - now )); [ "$secs" -lt 0 ] && secs=0 + now="$(date +%s)"; secs=$(( BASH_REMATCH[1] - now )); [[ "$secs" -lt 0 ]] && secs=0 fi printf '%s' "$secs" } @@ -737,26 +737,26 @@ _repo_wf_runs_cached() { case "$base" in ''|*[!0-9]*) base=2 ;; esac # Cache hit? A non-empty cache file for this (repo, workflow) — including a cached "[]" # for a no-runs / not-found workflow, so it is never re-queried within the sweep. - if [ -n "${_RUNS_CACHE_DIR:-}" ]; then + if [[ -n "${_RUNS_CACHE_DIR:-}" ]]; then # Encode the (repo, workflow, limit) key into the filename so distinct keys can never collide # (see _cache_name: injective, and fork-free for names that fit in a filename, #1259). A plain # char-substitution would map a workflow "A B" and "A/B" on one repo to the same file and # cross-contaminate their run history. The run limit is part of the key: a shorter list cached for # the default limit must never serve the ingress read that asks for more runs. key="${repo}//${wf}//${limit}" - [ -z "$from" ] || key+="//${from}" # a date-bounded list is a different list from the unbounded one - [ -z "$rstatus" ] || key+="//status=${rstatus}" # and so is a status-filtered one + [[ -z "$from" ]] || key+="//${from}" # a date-bounded list is a different list from the unbounded one + [[ -z "$rstatus" ]] || key+="//status=${rstatus}" # and so is a status-filtered one _cache_name "$key" cachef="$_RUNS_CACHE_DIR/${_CACHE_NAME}.json" - if [ -s "$cachef" ]; then + if [[ -s "$cachef" ]]; then _cat_file "$cachef" - [ "$strict" = "1" ] && [ -e "${cachef}.nf" ] && return 3 + [[ "$strict" = "1" ]] && [[ -e "${cachef}.nf" ]] && return 3 return 0 fi fi local -a cargs=() - [ -z "$from" ] || cargs=(--created ">=$from") - [ -z "$rstatus" ] || cargs+=(--status "$rstatus") + [[ -z "$from" ]] || cargs=(--created ">=$from") + [[ -z "$rstatus" ]] || cargs+=(--status "$rstatus") errfile="$(mktemp)" declare -p tmpfiles &>/dev/null || declare -g -a tmpfiles=() tmpfiles+=("$errfile") @@ -774,13 +774,13 @@ _repo_wf_runs_cached() { # caller" (#1244 item 15). Fall into the retry / fail-closed path below instead. if jq -e 'type == "array"' >/dev/null 2>&1 <<< "$out"; then rm -f "$errfile" - [ -n "${cachef:-}" ] && [ -d "$_RUNS_CACHE_DIR" ] && printf '%s' "$out" > "$cachef" 2>/dev/null || true + [[ -n "${cachef:-}" ]] && [[ -d "$_RUNS_CACHE_DIR" ]] && printf '%s' "$out" > "$cachef" 2>/dev/null || true printf '%s\n' "$out"; return 0 fi printf '%s\n' "unexpected non-array run list body" > "$errfile" fi err="" - if [ -r "$errfile" ]; then + if [[ -r "$errfile" ]]; then err="$(<"$errfile")" fi # A workflow with no runs on this repo is NOT a fetch failure — `gh` just can't resolve @@ -790,33 +790,33 @@ _repo_wf_runs_cached() { rm -f "$errfile" # Marker FIRST, then the cached []: a concurrent strict reader that sees the cache file is # guaranteed to see the .nf marker too (it never reads "workflow exists, no runs"). - [ -n "${cachef:-}" ] && [ -d "$_RUNS_CACHE_DIR" ] && : > "${cachef}.nf" 2>/dev/null && printf '%s' '[]' > "$cachef" 2>/dev/null || true + [[ -n "${cachef:-}" ]] && [[ -d "$_RUNS_CACHE_DIR" ]] && : > "${cachef}.nf" 2>/dev/null && printf '%s' '[]' > "$cachef" 2>/dev/null || true echo '[]' - [ "$strict" = "1" ] && return 3 + [[ "$strict" = "1" ]] && return 3 return 0 fi summary="$(_gh_err_summary "$err")" - if [ "$attempt" -ge "$attempts" ]; then + if [[ "$attempt" -ge "$attempts" ]]; then echo "::error::_run_json: failed to fetch run list for $repo (workflow=$wf) after $attempts attempt(s) — $summary" >&2 # Record the fetch failure for the sync-issues resilience layer (#820). A sustained # outage does NOT cleanly abort the caller (command substitutions don't inherit errexit), # so _frontier_state can limp on and emit a misleadingly-clean line; a file flag survives # the subshell and lets _frontier_state_resilient know the data is a partial/failed fetch. # No-op unless sync-issues armed the flag, so every other read path is unchanged. - [ -n "${_CANARY_FETCH_FAIL_FLAG:-}" ] && printf '%s\n' "$repo" >> "$_CANARY_FETCH_FAIL_FLAG" 2>/dev/null || true + [[ -n "${_CANARY_FETCH_FAIL_FLAG:-}" ]] && printf '%s\n' "$repo" >> "$_CANARY_FETCH_FAIL_FLAG" 2>/dev/null || true rm -f "$errfile"; return 1 fi # Prefer the server's explicit throttle hint; otherwise exponential + full jitter. ra="$(_gh_retry_after "$err")" - if [ -n "$ra" ] && [ "$ra" -gt 0 ]; then + if [[ -n "$ra" ]] && [[ "$ra" -gt 0 ]]; then delay="$ra" - [ "$delay" -gt "$ra_cap" ] && delay="$ra_cap" # honor hint but cap runaway values + [[ "$delay" -gt "$ra_cap" ]] && delay="$ra_cap" # honor hint but cap runaway values else - expo=$((attempt - 1)); [ "$expo" -gt 20 ] && expo=20 # guard the exponent from overflow + expo=$((attempt - 1)); [[ "$expo" -gt 20 ]] && expo=20 # guard the exponent from overflow delay=$(( base << expo )) - span=$(( delay - base )); [ "$span" -lt 0 ] && span=0 + span=$(( delay - base )); [[ "$span" -lt 0 ]] && span=0 delay=$(( base + RANDOM % (span + 1) )) # full jitter in [base, delay] - [ "$delay" -gt 30 ] && delay=30 + [[ "$delay" -gt 30 ]] && delay=30 fi echo "::warning::_run_json: transient failure fetching run list for $repo (workflow=$wf), attempt $attempt/$attempts — $summary — retrying in ${delay}s" >&2 sleep "$delay" @@ -831,7 +831,7 @@ _repo_wf_runs_cached() { # green-lights a bad promotion. Empty since = no lower bound. _run_json() { local repo="$1" wf="$2" since="$3" raw - if [ -z "$repo" ] || [ "$repo" = '*' ]; then echo '[]'; return 0; fi + if [[ -z "$repo" ]] || [[ "$repo" = '*' ]]; then echo '[]'; return 0; fi raw="$(_repo_wf_runs_cached "$repo" "$wf")" || return 1 # Local since cut, inclusive — the same window the old server-side `--created ">=$since"` # produced. jq on an empty/garbage payload falls back to []. @@ -864,14 +864,14 @@ _timing_enabled() { 1|true|on) return 0 ;; 0|false|off) return 1 ;; esac - [ "${GITHUB_ACTIONS:-}" = "true" ] || return 1 + [[ "${GITHUB_ACTIONS:-}" = "true" ]] || return 1 case "${GITHUB_EVENT_NAME:-}" in schedule|workflow_dispatch) return 0 ;; esac return 1 } _timing_start() { _timing_enabled || return 0 - [ -n "${EPOCHREALTIME:-}" ] && [ -n "${_RUNS_CACHE_DIR:-}" ] && [ -d "$_RUNS_CACHE_DIR" ] || return 0 + [[ -n "${EPOCHREALTIME:-}" ]] && [[ -n "${_RUNS_CACHE_DIR:-}" ]] && [[ -d "$_RUNS_CACHE_DIR" ]] || return 0 _TIMING_LOG="$_RUNS_CACHE_DIR/.gh-timing.$$.tsv"; _TIMING_CALLS="$_RUNS_CACHE_DIR/.agent-run-calls.$$" : > "$_TIMING_LOG" 2>/dev/null || { unset _TIMING_LOG _TIMING_CALLS; return 0; } : > "$_TIMING_CALLS" 2>/dev/null || true @@ -889,7 +889,7 @@ _timing_start() { # (tag, run id, ...) dropped, so per-tag lookups share a row instead of each becoming its own. _timing_kind() { _TIMING_KIND="${1:-} ${2:-}" - if [ "${1:-}" = run ] && [ "${2:-}" = list ]; then + if [[ "${1:-}" = run ]] && [[ "${2:-}" = list ]]; then # Which list is slow: the workflow, the -L cap and whether it was date-bounded (#1259). local w="" l="" b="" st="" a prev="" for a in "$@"; do @@ -899,11 +899,11 @@ _timing_kind() { _TIMING_KIND="run list [${w:-?}] L=${l:-default}$b$st" return 0 fi - [ "${1:-}" = api ] || return 0 + [[ "${1:-}" = api ]] || return 0 shift local a skip=0 p for a in "$@"; do - if [ "$skip" = 1 ]; then skip=0; continue; fi + if [[ "$skip" = 1 ]]; then skip=0; continue; fi case "$a" in -X|-H|-f|-F|-q|-t|--method|--header|--field|--raw-field|--jq|--template) skip=1; continue ;; -*) continue ;; @@ -912,16 +912,16 @@ _timing_kind() { IFS=/ read -r -a p <<< "$a" # repos///[/[/...]]: keep the resource, and the sub-resource only when an id # follows it (so repos/o/r/commits/ and .../contents/ collapse to one row each). - if [ "${#p[@]}" -ge 6 ] && [ "${p[3]:-}" != contents ]; then _TIMING_KIND="api ${p[0]:-}/${p[3]:-}/${p[4]:-}" + if [[ "${#p[@]}" -ge 6 ]] && [[ "${p[3]:-}" != contents ]]; then _TIMING_KIND="api ${p[0]:-}/${p[3]:-}/${p[4]:-}" else _TIMING_KIND="api ${p[0]:-}/${p[3]:-}"; fi return 0 done } _timing_report() { - [ -n "${_TIMING_LOG:-}" ] && [ -f "$_TIMING_LOG" ] || return 0 + [[ -n "${_TIMING_LOG:-}" ]] && [[ -f "$_TIMING_LOG" ]] || return 0 local calls=0 raw head kinds out - [ -f "${_TIMING_CALLS:-}" ] && calls="$(wc -c < "$_TIMING_CALLS" 2>/dev/null | tr -d ' ')" + [[ -f "${_TIMING_CALLS:-}" ]] && calls="$(wc -c < "$_TIMING_CALLS" 2>/dev/null | tr -d ' ')" raw="$(awk -F'\t' -v t0="${_TIMING_T0:-0}" -v t1="${EPOCHREALTIME:-0}" -v calls="${calls:-0}" -v subcmd="${_TIMING_SUB:-?}" ' function f(x) { gsub(",", ".", x); return x + 0 } { d = f($2) - f($1); n++; sum += d; k[$3]++; ks[$3] += d; if (d > mx[$3]) mx[$3] = d } @@ -931,11 +931,11 @@ _timing_report() { for (c in k) printf "K\t%.3f\t %-22s calls=%-5d total=%7.1fs avg=%6.2fs max=%6.1fs\n", ks[c], c, k[c], ks[c], ks[c] / k[c], mx[c] }' "$_TIMING_LOG" 2>/dev/null)" || return 0 head="$(printf '%s\n' "$raw" | awk -F'\t' '$1=="H"{print $3}')" - [ -n "$head" ] || return 0 + [[ -n "$head" ]] || return 0 kinds="$(printf '%s\n' "$raw" | awk -F'\t' '$1=="K"' | sort -t$'\t' -k2,2 -rn | cut -f3 | head -12)" - out="$head"; [ -n "$kinds" ] && out="$head"$'\n'"$kinds" + out="$head"; [[ -n "$kinds" ]] && out="$head"$'\n'"$kinds" printf '%s\n' "$out" >&2 - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then printf '\n
canary timing (%s)\n\n```\n%s\n```\n
\n' "${_TIMING_SUB:-?}" "$out" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true fi return 0 @@ -952,7 +952,7 @@ _ingress_workflow() { _record_unresolved() { local agent="$1" repo="$2" reason="$3" line line="$(printf '%s\t%s' "$repo" "$reason")" - if [ -n "${_CANARY_UNRESOLVED_FLAG:-}" ]; then + if [[ -n "${_CANARY_UNRESOLVED_FLAG:-}" ]]; then grep -qxF -- "$line" "$_CANARY_UNRESOLVED_FLAG" 2>/dev/null && return 0 # A failed append (temp fs full) must not leave a clean-looking empty flag: remove it, so the # gate sees the flag GONE and fails closed (see the "evidence write failed" check in _frontier_state). @@ -969,9 +969,9 @@ _record_unresolved() { # (a unit test calling a reader directly) every call prints. _note_once() { local key="$1" msg="$2" f - if [ -n "${_RUNS_CACHE_DIR:-}" ] && [ -d "$_RUNS_CACHE_DIR" ]; then + if [[ -n "${_RUNS_CACHE_DIR:-}" ]] && [[ -d "$_RUNS_CACHE_DIR" ]]; then _cache_name "note//$key"; f="$_RUNS_CACHE_DIR/${_CACHE_NAME}.note" - [ -e "$f" ] && return 0 + [[ -e "$f" ]] && return 0 : > "$f" 2>/dev/null || true fi echo "$msg" >&2 @@ -995,30 +995,30 @@ _run_jobs_json() { local attempts="${3:-${CANARY_GH_RETRIES:-6}}" base="${CANARY_GH_RETRY_SLEEP:-2}" delay span expo case "$attempts" in ''|*[!0-9]*) attempts=6 ;; esac case "$base" in ''|*[!0-9]*) base=2 ;; esac - [ "$attempts" -lt 1 ] && attempts=1 - if [ -n "${_RUNS_CACHE_DIR:-}" ]; then + [[ "$attempts" -lt 1 ]] && attempts=1 + if [[ -n "${_RUNS_CACHE_DIR:-}" ]]; then _cache_name "jobs//${repo}//${id}" cachef="$_RUNS_CACHE_DIR/${_CACHE_NAME}.jobs.json" - if [ -s "$cachef" ]; then + if [[ -s "$cachef" ]]; then _cat_file "$cachef"; return 0 fi fi local errf; errf="$(mktemp 2>/dev/null || echo /dev/null)" while :; do if out="$(gh run view "$id" --repo "$repo" --json jobs 2>"$errf")" && jq -e '.jobs|type=="array"' >/dev/null 2>&1 <<< "$out"; then - [ "$errf" != /dev/null ] && rm -f "$errf" - [ -n "$cachef" ] && [ -d "$_RUNS_CACHE_DIR" ] && printf '%s' "$out" > "$cachef" 2>/dev/null || true + [[ "$errf" != /dev/null ]] && rm -f "$errf" + [[ -n "$cachef" ]] && [[ -d "$_RUNS_CACHE_DIR" ]] && printf '%s' "$out" > "$cachef" 2>/dev/null || true printf '%s\n' "$out"; return 0 fi # A run deleted/expired since `gh run list` is permanent — fail fast, don't burn the backoff. if grep -qiE 'could not find (any )?(workflow )?run|HTTP 404|run [0-9]+ not found' "$errf" 2>/dev/null; then - [ "$errf" != /dev/null ] && rm -f "$errf"; return 2 + [[ "$errf" != /dev/null ]] && rm -f "$errf"; return 2 fi - if [ "$attempt" -ge "$attempts" ]; then [ "$errf" != /dev/null ] && rm -f "$errf"; return 1; fi + if [[ "$attempt" -ge "$attempts" ]]; then [[ "$errf" != /dev/null ]] && rm -f "$errf"; return 1; fi # Same policy as the run-list path: exponential backoff with full jitter in [base, 30s]. - expo=$((attempt - 1)); [ "$expo" -gt 20 ] && expo=20 - delay=$(( base << expo )); span=$(( delay - base )); [ "$span" -lt 0 ] && span=0 - delay=$(( base + RANDOM % (span + 1) )); [ "$delay" -gt 30 ] && delay=30 + expo=$((attempt - 1)); [[ "$expo" -gt 20 ]] && expo=20 + delay=$(( base << expo )); span=$(( delay - base )); [[ "$span" -lt 0 ]] && span=0 + delay=$(( base + RANDOM % (span + 1) )); [[ "$delay" -gt 30 ]] && delay=30 sleep "$delay"; attempt=$((attempt + 1)) done } @@ -1041,17 +1041,17 @@ _ingress_horizon() { _horizon_from() { local since="$1" tiers="$2" s now days t _HORIZON_FROM="" - [ -n "$since" ] || return 0 + [[ -n "$since" ]] || return 0 s="$(_epoch "$since")" case "$s" in ''|*[!0-9]*|0) return 0 ;; esac printf -v now '%(%s)T' -1 # Whole days back, rounded UP (a partial day counts). The horizon DATE is the start (00:00Z) of the day # t days ago, which is never later than the instant t days ago, so t >= days is enough to cover . days=$(( (now - s + 86399) / 86400 )) - [ "$days" -ge 1 ] || days=1 + [[ "$days" -ge 1 ]] || days=1 for t in $tiers; do case "$t" in ''|*[!0-9]*) continue ;; esac - if [ "$days" -le "$t" ]; then + if [[ "$days" -le "$t" ]]; then _HORIZON_FROM="$(date -u -d "-${t} days" +%Y-%m-%d 2>/dev/null || date -u -v"-${t}d" +%Y-%m-%d 2>/dev/null || true)" return 0 fi @@ -1072,9 +1072,9 @@ _ingress_runs_fetch() { local repo="$1" iwf="$2" since="$3" out rc=0 n oldest _ingress_run_limit; _ingress_horizon "$since" out="$(_repo_wf_runs_cached "$repo" "$iwf" 1 "$_INGRESS_RUN_LIMIT" "$_INGRESS_FROM")" || rc=$? - if [ "$rc" -eq 0 ] && [ -n "$_INGRESS_FROM" ]; then + if [[ "$rc" -eq 0 ]] && [[ -n "$_INGRESS_FROM" ]]; then n="$(jq 'if type == "array" then length else 0 end' <<< "${out:-[]}" 2>/dev/null || echo 0)" - if [ "${n:-0}" -ge "$_INGRESS_BOUNDED_MAX" ]; then + if [[ "${n:-0}" -ge "$_INGRESS_BOUNDED_MAX" ]]; then oldest="$(jq -r '[.[]? | (.createdAt // "")] | min // ""' <<< "$out" 2>/dev/null || true)" if ! run_list_covers "$n" "$_INGRESS_BOUNDED_MAX" "$oldest" "$since"; then out="$(_repo_wf_runs_cached "$repo" "$iwf" 1 "$_INGRESS_RUN_LIMIT" "")" || rc=$? @@ -1091,7 +1091,7 @@ _ingress_runs_fetch() { _ingress_run_limit() { local n="${CANARY_INGRESS_RUN_LIMIT:-5000}" case "$n" in ''|*[!0-9]*) n=5000 ;; esac - [ "$n" -lt 1 ] && n=5000 + [[ "$n" -lt 1 ]] && n=5000 _INGRESS_RUN_LIMIT="$n" # a variable, not stdout: callers avoid a `$(...)` fork per call (#1259) } @@ -1114,7 +1114,7 @@ _ingress_agent_runs() { # older one, rather than blocking a busy collapsed repo forever. Gating windows stay strict. local jobs_max="${CANARY_INGRESS_JOBS_MAX:-300}" nread=0 jrc role_oldest="" skipkind norole_created="" ncr case "$jobs_max" in ''|*[!0-9]*) jobs_max=300 ;; esac - [ "$jobs_max" -lt 1 ] && jobs_max=300 + [[ "$jobs_max" -lt 1 ]] && jobs_max=300 # The RUN LIST itself is capped too (`gh run list -L CANARY_INGRESS_RUN_LIMIT`, default 5000). A list # that hit its cap AND does not reach back past the window start may be missing older in-window runs, # so a very busy ingress would look quieter than it is (#1244 item 11). Fail closed exactly like the @@ -1130,10 +1130,10 @@ _ingress_agent_runs() { else list_n="$(jq 'length' <<< "${iraw:-[]}" 2>/dev/null || echo 0)" fi - if [ "${list_n:-0}" -ge "$list_max" ]; then + if [[ "${list_n:-0}" -ge "$list_max" ]]; then list_oldest="$(jq -r '[.[]? | (.createdAt // "")] | min // ""' <<< "$iraw" 2>/dev/null || true)" if ! run_list_covers "$list_n" "$list_max" "$list_oldest" "$since"; then - if [ -n "$list_oldest" ] && [ -n "${_CANARY_INGRESS_TRUNC_FLAG:-}" ] && printf '%s\n' "${list_oldest:0:10}" >> "$_CANARY_INGRESS_TRUNC_FLAG" 2>/dev/null; then + if [[ -n "$list_oldest" ]] && [[ -n "${_CANARY_INGRESS_TRUNC_FLAG:-}" ]] && printf '%s\n' "${list_oldest:0:10}" >> "$_CANARY_INGRESS_TRUNC_FLAG" 2>/dev/null; then echo "::warning::$agent $repo: the ingress run list reached CANARY_INGRESS_RUN_LIMIT ($list_max) in the baseline window; using the newest days only." >&2 else _record_unresolved "$agent" "$repo" "the ingress run list reached CANARY_INGRESS_RUN_LIMIT ($list_max) before the window start; older in-window runs may be missing (raise CANARY_INGRESS_RUN_LIMIT if this repo legitimately has that many runs)" @@ -1141,10 +1141,10 @@ _ingress_agent_runs() { fi fi while IFS=$'\t' read -r id created rconc; do - [ -z "$id" ] && continue - [ "$created" = "-" ] && created="" # "-" kept the empty field from collapsing in the tab-split read - if [ "$nread" -ge "$jobs_max" ]; then - if [ -n "${_CANARY_INGRESS_TRUNC_FLAG:-}" ] && printf '%s\n' "${created:0:10}" >> "$_CANARY_INGRESS_TRUNC_FLAG" 2>/dev/null; then + [[ -z "$id" ]] && continue + [[ "$created" = "-" ]] && created="" # "-" kept the empty field from collapsing in the tab-split read + if [[ "$nread" -ge "$jobs_max" ]]; then + if [[ -n "${_CANARY_INGRESS_TRUNC_FLAG:-}" ]] && printf '%s\n' "${created:0:10}" >> "$_CANARY_INGRESS_TRUNC_FLAG" 2>/dev/null; then echo "::warning::$agent $repo: more than $jobs_max ingress runs in the baseline window; using the newest days only (CANARY_INGRESS_JOBS_MAX)." >&2 else _record_unresolved "$agent" "$repo" "more than $jobs_max ingress runs in the window; job reads capped (CANARY_INGRESS_JOBS_MAX)" @@ -1153,12 +1153,12 @@ _ingress_agent_runs() { fi nread=$((nread + 1)) jrc=0; jobs="$(_run_jobs_json "$repo" "$id")" || jrc=$? - if [ "$jrc" -eq 1 ]; then + if [[ "$jrc" -eq 1 ]]; then # Circuit breaker: the jobs endpoint stayed unreadable after the full backoff, so every later # run would burn the same ~30s+ of retries (300 runs can outlive the job timeout before # sync-issues records the fail-closed state). Stop reading; the member is UNRESOLVED. _record_unresolved "$agent" "$repo" "jobs endpoint unreadable (run $id) — stopped reading jobs for this member"; break - elif [ "$jrc" -ne 0 ]; then + elif [[ "$jrc" -ne 0 ]]; then _record_unresolved "$agent" "$repo" "jobs of ingress run $id are unreadable"; continue fi rec="$(jq -c --arg role "$role" --arg wf "$wf" --arg id "$id" --arg created "$created" --arg rconc "$rconc" ' @@ -1199,24 +1199,24 @@ _ingress_agent_runs() { | if . == "UNATTRIBUTED" or . == "NOROLE" or (type == "object" and has("skipped")) then . else . + {createdAt: $created, databaseId: ($id | tonumber), workflowName: $wf, role: $role} end ' <<< "$jobs" 2>/dev/null)" || rec='"UNATTRIBUTED"' - if [ "$rec" = '"UNATTRIBUTED"' ]; then + if [[ "$rec" = '"UNATTRIBUTED"' ]]; then _record_unresolved "$agent" "$repo" "ingress run $id has no attributable jobs"; continue fi - if [ "$rec" = '"NOROLE"' ]; then + if [[ "$rec" = '"NOROLE"' ]]; then # Defer: pre-adoption runs are benign, a renamed ingress_job is not (see after the loop). # Keep the run's own createdAt (no payload re-parse later); "-" stands in for a missing one so # word-splitting cannot drop it — a run with an unknown date must still count as blind. norole_created+="${created:--} "; continue fi skipkind="$(jq -r 'if type == "object" then (.skipped // empty) else empty end' <<< "$rec" 2>/dev/null || true)" - if [ -n "$skipkind" ]; then - [ "$skipkind" = "role" ] && role_oldest="$created" # the role job ran (skipped): it exists here + if [[ -n "$skipkind" ]]; then + [[ "$skipkind" = "role" ]] && role_oldest="$created" # the role job ran (skipped): it exists here continue fi # newest→oldest: the last assignment is the OLDEST run carrying the role. A job-less startup_failure # (nojobs) carries NO role job, so it must not count as the role's appearance. - [ "$(jq -r '.nojobs // empty' <<< "$rec" 2>/dev/null || true)" = "true" ] || role_oldest="$created" - [ -n "$rec" ] && recs+="$(jq -c 'del(.nojobs)' <<< "$rec")"$'\n' + [[ "$(jq -r '.nojobs // empty' <<< "$rec" 2>/dev/null || true)" = "true" ]] || role_oldest="$created" + [[ -n "$rec" ]] && recs+="$(jq -c 'del(.nojobs)' <<< "$rec")"$'\n' done < <(jq -r --arg since "$since" \ '[ .[]? | select(.conclusion != null and .conclusion != "") | select($since == "" or (.createdAt // "") >= $since) ] @@ -1226,12 +1226,12 @@ _ingress_agent_runs() { # older still predates the role's adoption by the ingress and is benign; one NEWER than the role's # oldest appearance, or any when no run in the window carries the role at all (renamed/misspelled # ingress_job), cannot be attributed → UNRESOLVED. The member stays blocked only for that. - if [ -n "$norole_created" ]; then + if [[ -n "$norole_created" ]]; then local blind=0 for ncr in $norole_created; do - if [ -z "$role_oldest" ] || [ "$ncr" = "-" ] || ! [[ "$ncr" < "$role_oldest" ]]; then blind=$((blind + 1)); fi + if [[ -z "$role_oldest" ]] || [[ "$ncr" = "-" ]] || ! [[ "$ncr" < "$role_oldest" ]]; then blind=$((blind + 1)); fi done - [ "$blind" -gt 0 ] && _record_unresolved "$agent" "$repo" "$blind ingress run(s) carry no '$role' job and are not older than the role's first appearance (ingress_job renamed or misspelled?)" + [[ "$blind" -gt 0 ]] && _record_unresolved "$agent" "$repo" "$blind ingress run(s) carry no '$role' job and are not older than the role's first appearance (ingress_job renamed or misspelled?)" fi printf '%s' "$recs" | jq -cs '.' } @@ -1267,8 +1267,8 @@ _ingress_agent_runs() { _LEGACY_RUN_LIMIT=1000 _agent_run_json() { local agent="$1" repo="$2" since="$3" wf iwf raw iraw role rc=0 - [ -n "${_TIMING_CALLS:-}" ] && { printf . >> "$_TIMING_CALLS" 2>/dev/null || true; } # call counter (CANARY_TIMING) - if [ -z "$repo" ] || [ "$repo" = '*' ]; then echo '[]'; return 0; fi + [[ -n "${_TIMING_CALLS:-}" ]] && { printf . >> "$_TIMING_CALLS" 2>/dev/null || true; } # call counter (CANARY_TIMING) + if [[ -z "$repo" ]] || [[ "$repo" = '*' ]]; then echo '[]'; return 0; fi _reg_run_workflow "$agent"; wf="$_REG_VAL" # Date-bound the per-role list (#1259, option B): every window the gate samples starts at most ~2 weeks back, so the # newest 1000 runs of a busy workflow are mostly older than anything read. One fixed tier (CANARY_RUNS_HORIZON_TIERS, @@ -1278,10 +1278,10 @@ _agent_run_json() { # instead (#1244). A window older than the tier (a long soak) gets the unbounded list as before. _horizon_from "$since" "${CANARY_RUNS_HORIZON_TIERS-15}" raw="$(_repo_wf_runs_cached "$repo" "$wf" 1 "$_LEGACY_RUN_LIMIT" "$_HORIZON_FROM")" || rc=$? - if [ "$rc" -eq 3 ]; then + if [[ "$rc" -eq 3 ]]; then # Not an ingress role (no ingress_job): the member's ingress cannot carry it, so it is not read (#1247). _reg_ingress_job "$agent"; role="$_REG_VAL" - if [ -z "$role" ]; then echo '[]'; return 0; fi + if [[ -z "$role" ]]; then echo '[]'; return 0; fi rc=0; _reg_ingress_wf; iwf="$_REG_VAL" iraw="$(_ingress_runs_fetch "$repo" "$iwf" "$since")" || rc=$? case "$rc" in @@ -1290,7 +1290,7 @@ _agent_run_json() { 3) echo '[]'; return 0 ;; *) return 1 ;; esac - elif [ "$rc" -ne 0 ]; then + elif [[ "$rc" -ne 0 ]]; then return 1 fi # One jq pass (this runs thousands of times per sweep, #1259): "\t" on the first @@ -1304,15 +1304,15 @@ _agent_run_json() { meta="${out%%$'\n'*}"; body="${out#*$'\n'}" n="${meta%%$'\t'*}"; oldest="${meta#*$'\t'}" case "$n" in ''|*[!0-9]*) echo '[]'; return 0 ;; esac - if [ -z "$since" ] || run_list_covers "$n" "$_LEGACY_RUN_LIMIT" "$oldest" "$since"; then + if [[ -z "$since" ]] || run_list_covers "$n" "$_LEGACY_RUN_LIMIT" "$oldest" "$since"; then printf '%s\n' "$body"; return 0 fi # Truncated for this window. Baseline read: report the oldest listed day and let _baseline_daily drop the unseen # days. An armed flag that cannot record the cutoff (undated oldest run, unwritable flag) would let # _baseline_daily read the unseen days as zero, so that member is UNRESOLVED instead. (An unarmed flag is a # gating read and falls through to the treatment below.) - if [ -n "${_CANARY_INGRESS_TRUNC_FLAG:-}" ]; then - if [ -n "$oldest" ] && printf '%s\n' "${oldest:0:10}" >> "$_CANARY_INGRESS_TRUNC_FLAG" 2>/dev/null; then + if [[ -n "${_CANARY_INGRESS_TRUNC_FLAG:-}" ]]; then + if [[ -n "$oldest" ]] && printf '%s\n' "${oldest:0:10}" >> "$_CANARY_INGRESS_TRUNC_FLAG" 2>/dev/null; then _note_once "trunc-baseline//${repo}//${wf}" "::warning::$agent $repo: the '$wf' run list reached $_LEGACY_RUN_LIMIT runs in the baseline window; using the newest days only." else _record_unresolved "$agent" "$repo" "the '$wf' run list reached $_LEGACY_RUN_LIMIT runs in the baseline window and the cutoff could not be recorded; unseen days would read as zero" @@ -1349,7 +1349,7 @@ _tier_sample() { json="$(_agent_run_json "$agent" "$repo" "$since")" executed=$(( executed + $(jq '[.[]?|select(.conclusion=="success" or .conclusion=="failure")]|length' 2>/dev/null <<< "${json:-[]}" || echo 0) )) e="$(jq -r '[.[]?|select(.conclusion=="success" or .conclusion=="failure")|.createdAt?]|min // empty' 2>/dev/null <<< "$json" || echo "")" - if [ -n "$e" ] && { [ -z "$earliest" ] || [[ "$e" < "$earliest" ]]; }; then earliest="$e"; fi + if [[ -n "$e" ]] && { [[ -z "$earliest" ]] || [[ "$e" < "$earliest" ]]; }; then earliest="$e"; fi done echo "$executed ${earliest:--}" } @@ -1411,27 +1411,27 @@ _suspect_class_counts() { local repo json rid rwf rrole sig sig_rc matched=0 executed=0 incomplete=0 count local exec_filter='.[]?|select(.conclusion=="success" or .conclusion=="failure")' local fail_filter='.[]?|select(.conclusion=="failure")' - if [ "$before" != "-" ]; then + if [[ "$before" != "-" ]]; then exec_filter="$exec_filter|select(.createdAt < \"$before\")" fail_filter="$fail_filter|select(.createdAt < \"$before\")" fi for repo in "$@"; do - { [ -z "$repo" ] || [ "$repo" = '*' ]; } && continue + { [[ -z "$repo" ]] || [[ "$repo" = '*' ]]; } && continue json="$(_agent_run_json "$agent" "$repo" "$since")" count="$(jq "[${exec_filter}]|length" 2>/dev/null <<< "$json" || echo 0)" executed=$(( executed + ${count:-0} )) - while IFS=$'\t' read -r rid rwf rrole || [ -n "$rid" ]; do + while IFS=$'\t' read -r rid rwf rrole || [[ -n "$rid" ]]; do rid="${rid%$'\r'}" rwf="${rwf%$'\r'}" rrole="${rrole%$'\r'}" - [ -z "$rid" ] && continue + [[ -z "$rid" ]] && continue sig_rc=0 sig="$(_run_signature "$repo" "$rid" "$rrole")" || sig_rc=$? # || prevents set -e on lookup failure - if [ -z "$sig" ]; then - [ "$sig_rc" -ne 0 ] && incomplete=$(( incomplete + 1 )) # lookup failed; genuine empty sig is fine + if [[ -z "$sig" ]]; then + [[ "$sig_rc" -ne 0 ]] && incomplete=$(( incomplete + 1 )) # lookup failed; genuine empty sig is fine continue fi - if [ "$(benign_match "$rwf" "$sig" "$wf_re" "$step_re")" = "yes" ]; then + if [[ "$(benign_match "$rwf" "$sig" "$wf_re" "$step_re")" = "yes" ]]; then matched=$(( matched + 1 )) fi done < <(jq -r "[${fail_filter}]|.[]|[(.databaseId // \"\"|tostring),(.workflowName // \"\"),(.role // \"\")]|@tsv" 2>/dev/null <<< "$json") @@ -1454,10 +1454,10 @@ declare -A _RUN_SIG_CACHE=() # The $'\x01' sentinel in the cache marks a prior lookup failure so it is not retried. _run_signature() { local repo="$1" id="$2" role="${3:-}" cache_key sig json - { [ -z "$repo" ] || [ "$repo" = '*' ] || [ -z "$id" ]; } && { echo ""; return 0; } + { [[ -z "$repo" ]] || [[ "$repo" = '*' ]] || [[ -z "$id" ]]; } && { echo ""; return 0; } cache_key="${repo}:${id}:${role}" if [[ -v _RUN_SIG_CACHE["$cache_key"] ]]; then - if [ "${_RUN_SIG_CACHE[$cache_key]}" = $'\x01' ]; then + if [[ "${_RUN_SIG_CACHE[$cache_key]}" = $'\x01' ]]; then echo ""; return 1 # cached lookup failure — signal to caller fi echo "${_RUN_SIG_CACHE[$cache_key]}" @@ -1481,10 +1481,10 @@ _run_signature() { _failure_benign() { local repo="$1" rid="$2" rwf="$3" patterns="$4" role="${5:-}" sig wf_re step_re sig="$(_run_signature "$repo" "$rid" "$role")" - [ -z "$sig" ] && return 1 + [[ -z "$sig" ]] && return 1 while IFS=$'\t' read -r wf_re step_re; do - [ -z "$step_re" ] && continue - if [ "$(benign_match "$rwf" "$sig" "$wf_re" "$step_re")" = "yes" ]; then return 0; fi + [[ -z "$step_re" ]] && continue + if [[ "$(benign_match "$rwf" "$sig" "$wf_re" "$step_re")" = "yes" ]]; then return 0; fi done <<< "$patterns" return 1 } @@ -1497,12 +1497,12 @@ _failure_benign() { _failure_suspect() { local repo="$1" rid="$2" rwf="$3" patterns="$4" role="${5:-}" sig wf_re step_re sig="$(_run_signature "$repo" "$rid" "$role")" - [ -z "$sig" ] && return 1 - while IFS=$'\t' read -r wf_re step_re || [ -n "$wf_re" ]; do + [[ -z "$sig" ]] && return 1 + while IFS=$'\t' read -r wf_re step_re || [[ -n "$wf_re" ]]; do wf_re="${wf_re%$'\r'}" step_re="${step_re%$'\r'}" - [ -z "$step_re" ] && continue - if [ "$(benign_match "$rwf" "$sig" "$wf_re" "$step_re")" = "yes" ]; then return 0; fi + [[ -z "$step_re" ]] && continue + if [[ "$(benign_match "$rwf" "$sig" "$wf_re" "$step_re")" = "yes" ]]; then return 0; fi done <<< "$patterns" return 1 } @@ -1520,25 +1520,25 @@ declare -A _RUN_SHA_CACHE=() # release (fail closed — see _run_is_stale). Lookup failures are cached as empty too. _run_reusable_sha() { local agent="$1" repo="$2" id="$3" key="$1:$2:$3" reusable host cachef="" keyhash log shas="" line - { [ -z "$repo" ] || [ "$repo" = '*' ] || [ -z "$id" ]; } && { echo ""; return 1; } + { [[ -z "$repo" ]] || [[ "$repo" = '*' ]] || [[ -z "$id" ]]; } && { echo ""; return 1; } if [[ -v _RUN_SHA_CACHE["$key"] ]]; then - shas="${_RUN_SHA_CACHE[$key]}"; [ -n "$shas" ] && printf '%s\n' "$shas"; [ -n "$shas" ]; return + shas="${_RUN_SHA_CACHE[$key]}"; [[ -n "$shas" ]] && printf '%s\n' "$shas"; [[ -n "$shas" ]]; return fi - if [ -n "${_RUNS_CACHE_DIR:-}" ] && [ -d "$_RUNS_CACHE_DIR" ]; then + if [[ -n "${_RUNS_CACHE_DIR:-}" ]] && [[ -d "$_RUNS_CACHE_DIR" ]]; then # Hash the key into the filename (as _repo_wf_runs_cached does): char-substitution would map # distinct (agent, repo, run) keys to one file and could hand one run another run's SHA. keyhash="$(printf '%s' "$key" | { sha256sum 2>/dev/null || shasum -a 256 2>/dev/null; } | cut -d' ' -f1)" - [ -n "$keyhash" ] || keyhash="${key//[^A-Za-z0-9._-]/_}" + [[ -n "$keyhash" ]] || keyhash="${key//[^A-Za-z0-9._-]/_}" cachef="$_RUNS_CACHE_DIR/sha_${keyhash}" - if [ -f "$cachef" ]; then + if [[ -f "$cachef" ]]; then shas="$(<"$cachef")"; _RUN_SHA_CACHE["$key"]="$shas" - [ -n "$shas" ] && printf '%s\n' "$shas"; [ -n "$shas" ]; return + [[ -n "$shas" ]] && printf '%s\n' "$shas"; [[ -n "$shas" ]]; return fi fi reusable="$(_agent_field "$agent" reusable)" host="$(_agent_field "$agent" host)"; host="${host:-$THIS_REPO}" log="$(gh run view "$id" --repo "$repo" --log 2>/dev/null)" || log="" - if [ -n "$reusable" ]; then + if [[ -n "$reusable" ]]; then # `gh run view --log` lines are "\t\t ". GitHub prints the genuine # "Uses:" line while setting a job up, before any of that job's own output, so only the FIRST # "Uses:" line per job is trusted: a later line a job merely echoes (a forged old SHA) is ignored. @@ -1556,8 +1556,8 @@ _run_reusable_sha() { shas="$(printf '%s' "$shas" | sort -u)" fi _RUN_SHA_CACHE["$key"]="$shas" - [ -n "$cachef" ] && printf '%s' "$shas" > "$cachef" 2>/dev/null || true - [ -n "$shas" ] && printf '%s\n' "$shas"; [ -n "$shas" ] + [[ -n "$cachef" ]] && printf '%s' "$shas" > "$cachef" 2>/dev/null || true + [[ -n "$shas" ]] && printf '%s\n' "$shas"; [[ -n "$shas" ]] } # _run_is_stale — 0 only when the run PROVABLY executed a release @@ -1567,10 +1567,10 @@ _run_reusable_sha() { # stale, so it still counts and blocks — the gate never gets more permissive on a guess. _run_is_stale() { local agent="$1" cand="$2" repo="$3" id="$4" sha shas - { [ -z "$cand" ] || [ "$cand" = "-" ]; } && return 1 + { [[ -z "$cand" ]] || [[ "$cand" = "-" ]]; } && return 1 shas="$(_run_reusable_sha "$agent" "$repo" "$id")" || return 1 while IFS= read -r sha; do - [ -z "$sha" ] && continue + [[ -z "$sha" ]] && continue case "$cand" in "$sha"*) return 1 ;; esac case "$sha" in "$cand"*) return 1 ;; esac done <<< "$shas" @@ -1599,7 +1599,7 @@ _cumulative_health() { # startup_failure runs never executed a job, so no release can be attributed to them (no # "Uses:" log line): they always count, fail closed (#1176). startup=$(( startup + $(jq '[.[]?|select(.conclusion=="startup_failure")]|length' 2>/dev/null <<< "${json:-[]}" || echo 0) )) - if [ -z "$patterns" ] && [ -z "$suspect_patterns" ] && [ "$cand" = "-" ]; then + if [[ -z "$patterns" ]] && [[ -z "$suspect_patterns" ]] && [[ "$cand" = "-" ]]; then # No benign/suspect patterns and no candidate attribution — count all failures with one # jq pass, avoiding a gh run view call per failure. fail=$(( fail + $(jq '[.[]?|select(.conclusion=="failure")]|length' 2>/dev/null <<< "${json:-[]}" || echo 0) )) @@ -1607,11 +1607,11 @@ _cumulative_health() { while IFS=$'\t' read -r rid rwf rrole; do if _run_is_stale "$agent" "$cand" "$repo" "$rid"; then stale=$(( stale + 1 )) - elif [ -n "$patterns" ] && _failure_benign "$repo" "$rid" "$rwf" "$patterns" "$rrole"; then + elif [[ -n "$patterns" ]] && _failure_benign "$repo" "$rid" "$rwf" "$patterns" "$rrole"; then benign=$(( benign + 1 )) else fail=$(( fail + 1 )) - if [ -n "$suspect_patterns" ] && _failure_suspect "$repo" "$rid" "$rwf" "$suspect_patterns" "$rrole"; then + if [[ -n "$suspect_patterns" ]] && _failure_suspect "$repo" "$rid" "$rwf" "$suspect_patterns" "$rrole"; then suspect=$(( suspect + 1 )) fi fi @@ -1649,19 +1649,19 @@ _baseline_daily() { tflag="$(mktemp 2>/dev/null || true)" # unwritable → the ingress cap stays strict (UNRESOLVED) json="$(_CANARY_INGRESS_TRUNC_FLAG="$tflag" _agent_run_json "$agent" "$repo" "$since")" cutday="" - if [ -n "$tflag" ] && [ -s "$tflag" ]; then - while IFS= read -r d; do [ -n "$d" ] && { [ -z "$cutday" ] || [[ "$d" > "$cutday" ]]; } && cutday="$d"; done < "$tflag" + if [[ -n "$tflag" ]] && [[ -s "$tflag" ]]; then + while IFS= read -r d; do [[ -n "$d" ]] && { [[ -z "$cutday" ]] || [[ "$d" > "$cutday" ]]; } && cutday="$d"; done < "$tflag" fi - [ -n "$tflag" ] && rm -f "$tflag" - [ -n "$cutday" ] && any_trunc=1 + [[ -n "$tflag" ]] && rm -f "$tflag" + [[ -n "$cutday" ]] && any_trunc=1 rcount=() - while IFS= read -r d; do [ -n "$d" ] && rcount["$d"]=$(( ${rcount["$d"]:-0} + 1 )); done \ + while IFS= read -r d; do [[ -n "$d" ]] && rcount["$d"]=$(( ${rcount["$d"]:-0} + 1 )); done \ < <(jq -r '.[]?|select(.conclusion=="success" or .conclusion=="failure")|.createdAt[0:10]?' 2>/dev/null <<< "$json" || true) for day in "${days[@]}"; do count="${rcount["$day"]:-0}" - if [ -n "$cutday" ]; then + if [[ -n "$cutday" ]]; then [[ "$day" < "$cutday" ]] && continue - [ "$day" = "$cutday" ] && [ "$count" -eq 0 ] && continue + [[ "$day" = "$cutday" ]] && [[ "$count" -eq 0 ]] && continue fi dknown["$day"]=1 dsum["$day"]=$(( ${dsum["$day"]:-0} + count )) @@ -1669,14 +1669,14 @@ _baseline_daily() { done for day in "${days[@]}"; do # No truncated member → every day is known (zero-filled); otherwise only days some member knows. - if [ "$any_trunc" -eq 1 ] && [ "$#" -gt 0 ] && [ -z "${dknown["$day"]:-}" ]; then continue; fi + if [[ "$any_trunc" -eq 1 ]] && [[ "$#" -gt 0 ]] && [[ -z "${dknown["$day"]:-}" ]]; then continue; fi count="${dsum["$day"]:-0}" total=$(( total + count )) out+="${count} " done # Truncated and nothing countable observed: the traffic is unknown, not zero. Say so with the token the # pure core understands (baseline_sample_decision) rather than a made-up count (#1244 item 16). - [ "$any_trunc" -eq 1 ] && [ "$total" -eq 0 ] && out="unknown " + [[ "$any_trunc" -eq 1 ]] && [[ "$total" -eq 0 ]] && out="unknown " echo "${out% }" } @@ -1687,9 +1687,9 @@ _baseline_daily() { _reusable_differs() { local agent="$1" cand="$2" prior="$3" reusable a b host reusable="$(_agent_field "$agent" reusable)" - [ -z "$reusable" ] || [ -z "$cand" ] || [ -z "$prior" ] && { echo 0; return 0; } + [[ -z "$reusable" ]] || [[ -z "$cand" ]] || [[ -z "$prior" ]] && { echo 0; return 0; } host="$(_agent_field "$agent" host)"; host="${host:-$THIS_REPO}" - if [ "$host" = "$THIS_REPO" ]; then + if [[ "$host" = "$THIS_REPO" ]]; then # This-repo agent: the reusable blob lives in the local checkout. a="$(git rev-parse -q --verify "${cand}:${reusable}" 2>/dev/null || echo "")" b="$(git rev-parse -q --verify "${prior}:${reusable}" 2>/dev/null || echo "")" @@ -1702,8 +1702,8 @@ _reusable_differs() { fi # Fail CLOSED: an unresolvable compare counts as "changed" so the benign-failure # allowlist is disabled and a genuine candidate regression is never masked. - { [ -z "$a" ] || [ -z "$b" ]; } && { echo 1; return 0; } - [ "$a" != "$b" ] && { echo 1; return 0; } + { [[ -z "$a" ]] || [[ -z "$b" ]]; } && { echo 1; return 0; } + [[ "$a" != "$b" ]] && { echo 1; return 0; } echo 0 } @@ -1729,7 +1729,7 @@ declare -A _RUN_DECISION_CACHE=() # the sampler stops reading that member, and is never memoized (the next tick retries). _run_decision_class() { local repo="$1" id="$2" prefix="$3" role="${4:-}" agent="${5:-}" cache_key json cls jrc=0 - { [ -z "$repo" ] || [ "$repo" = '*' ] || [ -z "$id" ]; } && { echo ""; return 0; } + { [[ -z "$repo" ]] || [[ "$repo" = '*' ]] || [[ -z "$id" ]]; } && { echo ""; return 0; } cache_key="${repo}:${id}:${role}" if [[ -v _RUN_DECISION_CACHE["$cache_key"] ]]; then echo "${_RUN_DECISION_CACHE[$cache_key]}"; return 0 @@ -1742,7 +1742,7 @@ _run_decision_class() { _record_unresolved "$agent" "$repo" "jobs of run $id are unreadable while sampling the decision mix (correctness evidence missing)" echo ""; return 1 ;; esac - if [ -n "$role" ]; then + if [[ -n "$role" ]]; then json="$(jq -c --arg role "$role" 'if has("jobs") then .jobs |= map(select(((.name // "")|split(" / ")[0]) == $role)) else error("Missing jobs key") end' <<< "$json" 2>/dev/null || echo '{}')" fi cls="$(decision_class "$prefix" "$json")" @@ -1761,18 +1761,18 @@ _sample_decision_counts() { local repo json rid rrole cls sampled=0 declare -A counts=() local filter='.[]?|select(.conclusion=="success" or .conclusion=="failure")' - [ "$before" != "-" ] && filter="$filter|select(.createdAt < \"$before\")" + [[ "$before" != "-" ]] && filter="$filter|select(.createdAt < \"$before\")" for repo in "$@"; do - [ "$sampled" -ge "$max_k" ] && break - { [ -z "$repo" ] || [ "$repo" = '*' ]; } && continue + [[ "$sampled" -ge "$max_k" ]] && break + { [[ -z "$repo" ]] || [[ "$repo" = '*' ]]; } && continue json="$(_agent_run_json "$agent" "$repo" "$since")" while IFS=$'\t' read -r rid rrole; do - [ -z "$rid" ] && continue - [ "$sampled" -ge "$max_k" ] && break + [[ -z "$rid" ]] && continue + [[ "$sampled" -ge "$max_k" ]] && break # A transient jobs-read failure (status 1) is recorded UNRESOLVED by _run_decision_class; stop # reading this member rather than burn one failing call per remaining run. cls="$(_run_decision_class "$repo" "$rid" "$prefix" "$rrole" "$agent")" || break - [ -z "$cls" ] && continue + [[ -z "$cls" ]] && continue counts["$cls"]=$(( ${counts["$cls"]:-0} + 1 )) sampled=$(( sampled + 1 )) done < <(jq -r "[${filter}]|sort_by(.createdAt)|reverse|.[]|[(.databaseId|tostring),(.role // \"\")]|@tsv" 2>/dev/null <<< "$json") @@ -1782,7 +1782,7 @@ _sample_decision_counts() { local out k out="$(for k in "${!counts[@]}"; do printf '%s\t%s\n' "$k" "${counts[$k]}"; done \ | jq -Rn '[inputs | split("\t") | {(.[0]): (.[1] | tonumber)}] | add // {}' 2>/dev/null)" - [ -n "$out" ] || out='{}' + [[ -n "$out" ]] || out='{}' echo "$out" } @@ -1817,11 +1817,11 @@ _correctness_verdict() { _decision_mix_table() { local agent="$1" cand="$2" pair_transition="${3:-}" local knobs; knobs="$(_jq -c --arg a "$agent" '.agents[$a].gate.correctness // ""')" - [ -z "$knobs" ] || [ "$knobs" = '""' ] && return 0 + [[ -z "$knobs" ]] || [[ "$knobs" = '""' ]] && return 0 local chans frontier="" ch c chans="$(ordered_channels "$agent")" local transition source cut_z prefix - if [ -n "$pair_transition" ]; then + if [[ -n "$pair_transition" ]]; then # The blocked pair is known (#1118): use ITS source tier, not one re-derived on the assumption # that the candidate always sits on `next`. transition="$pair_transition" @@ -1829,17 +1829,17 @@ _decision_mix_table() { local chan_array=(); IFS=, read -r -a chan_array <<< "$chans" for ch in "${chan_array[@]}"; do c="$(channel_commit "$agent" "$ch")" - if [ "$ch" = "next" ] || [ "$c" = "$cand" ]; then :; else frontier="$ch"; break; fi + if [[ "$ch" = "next" ]] || [[ "$c" = "$cand" ]]; then :; else frontier="$ch"; break; fi done - [ -z "$frontier" ] && return 0 + [[ -z "$frontier" ]] && return 0 transition="$(transition_key "$frontier" "$chans")" fi source="${transition%%->*}" cut_z="$(candidate_cut_date "$agent" "$cand")" - [ -z "$cut_z" ] && return 0 + [[ -z "$cut_z" ]] && return 0 prefix="$(jq -r '.decision_step_prefix // "decision: "' <<< "$knobs" 2>/dev/null || echo "decision: ")" local src_repos=() r - while IFS= read -r r; do [ -n "$r" ] && src_repos+=("$r"); done < <(resolve_members "$agent" "$source") + while IFS= read -r r; do [[ -n "$r" ]] && src_repos+=("$r"); done < <(resolve_members "$agent" "$source") local win base_since cand_counts base_counts win="${SOAK_WINDOW_DAYS:-$(_gate_field "$agent" baseline_window_days)}"; win="${win:-14}" base_since="$(_iso_now_minus_days "$win")" @@ -1848,8 +1848,8 @@ _decision_mix_table() { # Guard against an empty sample string — --argjson rejects empty input. # (A `${var:-{}}` inline default is NOT usable: the inner `}` closes the # parameter expansion, appending a stray brace and producing invalid JSON.) - [ -n "$cand_counts" ] || cand_counts='{}' - [ -n "$base_counts" ] || base_counts='{}' + [[ -n "$cand_counts" ]] || cand_counts='{}' + [[ -n "$base_counts" ]] || base_counts='{}' jq -nr --argjson c "$cand_counts" --argjson b "$base_counts" --arg win "$win" ' ([$c[]?]|add // 0) as $ct | ([$b[]?]|add // 0) as $bt | (($c|keys) + ($b|keys) | unique) as $classes @@ -1878,7 +1878,7 @@ _pair_state() { local agent="$1" source="$2" frontier="$3" cand="$4" transition="$5" chans="$6" commits_csv="$7" local cut_z now_epoch cut_z="$(candidate_cut_date "$agent" "$cand")" - if [ -z "$cut_z" ]; then + if [[ -z "$cut_z" ]]; then # Cannot determine the per-candidate window start — fail closed to prevent unbounded history queries. echo "${cand:--} $frontier $transition BLOCKED 0 0 0 0 0 0 0 - - - 0 0 0 0"; return 0 fi @@ -1888,7 +1888,7 @@ _pair_state() { # cannot be attributed (ADR-0007 ingress present, role job unresolvable). A file, because the # run reads below happen in subshells. A caller may pre-arm it (sync-issues evidence). local uflag="${_CANARY_UNRESOLVED_FLAG:-}" uflag_owned=0 - if [ -z "$uflag" ]; then + if [[ -z "$uflag" ]]; then # Per-process path (never shared across concurrent invocations) so _unresolved_evidence in # this same process can reuse the flag instead of re-walking the runs (#1224). uflag="$(_unresolved_flag_path "$agent" "$cand")"; uflag_owned=1 @@ -1904,7 +1904,7 @@ _pair_state() { # Source-tier repos (the tier currently running the candidate). local src_repos=() r - while IFS= read -r r; do [ -n "$r" ] && src_repos+=("$r"); done < <(resolve_members "$agent" "$source") + while IFS= read -r r; do [[ -n "$r" ]] && src_repos+=("$r"); done < <(resolve_members "$agent" "$source") # Sample on the source tier over the per-candidate window. local sample earliest @@ -1913,10 +1913,10 @@ _pair_state() { # Dwell is always measured from the candidate's own cut (tagger date), per #548 spec. local dwell_h=0 local cut_epoch; cut_epoch="$(_epoch "$cut_z")" - if [ "$cut_epoch" -gt 0 ]; then + if [[ "$cut_epoch" -gt 0 ]]; then dwell_h=$(( (now_epoch - cut_epoch) / 3600 )) fi - [ "$dwell_h" -lt 0 ] && dwell_h=0 + [[ "$dwell_h" -lt 0 ]] && dwell_h=0 # Whether the candidate changed the agent's reusable vs the prior channel on the frontier. # At differs=1 the known-benign allowlist narrows to classes marked version_independent @@ -1930,11 +1930,11 @@ _pair_state() { IFS=, read -r -a chan_array <<< "$chans" IFS=, read -r -a commit_array <<< "$commits_csv" for idx in "${!chan_array[@]}"; do - [ "${chan_array[$idx]}" = "$source" ] && src_idx="$idx" - [ "${chan_array[$idx]}" = "$frontier" ] && dst_idx="$idx" + [[ "${chan_array[$idx]}" = "$source" ]] && src_idx="$idx" + [[ "${chan_array[$idx]}" = "$frontier" ]] && dst_idx="$idx" done local prior="${commit_array[$dst_idx]:--}" differs - [ "$prior" = "-" ] && prior="" + [[ "$prior" = "-" ]] && prior="" differs="$(_reusable_differs "$agent" "$cand" "$prior")" # Cumulative health since this candidate's own cut, across every concrete tier EXCEPT a ring @@ -1954,19 +1954,19 @@ _pair_state() { ch3="${chan_array[$idx]}" # Exclude a lower ring only when it PROVABLY runs a different commit. An unresolvable (empty) # tag is unknown, not "different": keep its failures in scope (fail closed). - local lc="${commit_array[$idx]:--}"; [ "$lc" = "-" ] && lc="" - if [ "$idx" -lt "$src_idx" ] && [ -n "$lc" ] && [ "$lc" != "$cand" ]; then + local lc="${commit_array[$idx]:--}"; [[ "$lc" = "-" ]] && lc="" + if [[ "$idx" -lt "$src_idx" ]] && [[ -n "$lc" ]] && [[ "$lc" != "$cand" ]]; then continue fi while IFS= read -r r; do - { [ -z "$r" ] || [ "$r" = '*' ]; } && continue - if [ -n "$lc" ] && [ "$lc" != "$cand" ]; then attr_repos+=("$r"); else raw_repos+=("$r"); fi + { [[ -z "$r" ]] || [[ "$r" = '*' ]]; } && continue + if [[ -n "$lc" ]] && [[ "$lc" != "$cand" ]]; then attr_repos+=("$r"); else raw_repos+=("$r"); fi done < <(resolve_members "$agent" "$ch3") done # A repo that also belongs to a raw tier (e.g. the host, in both next and ring0) stays raw. local -A raw_set=(); local attr_only=() for r in ${raw_repos[@]+"${raw_repos[@]}"}; do raw_set["$r"]=1; done - for r in ${attr_repos[@]+"${attr_repos[@]}"}; do [ -z "${raw_set["$r"]:-}" ] && attr_only+=("$r"); done + for r in ${attr_repos[@]+"${attr_repos[@]}"}; do [[ -z "${raw_set["$r"]:-}" ]] && attr_only+=("$r"); done local cum_fail cum_startup cum_benign cum_suspect cum_stale local a_fail a_startup a_benign a_suspect a_stale read -r cum_fail cum_startup cum_benign cum_suspect cum_stale < <(_cumulative_health "$agent" "$cut_z" "$differs" "-" ${raw_repos[@]+"${raw_repos[@]}"}) @@ -1976,16 +1976,16 @@ _pair_state() { cum_stale=$(( cum_stale + a_stale )) # Target-ring health (#1176): failures from runs still on the previous release are reported, # never counted — the candidate cannot have caused them and may be the fix. - if [ "${cum_stale:-0}" -gt 0 ]; then + if [[ "${cum_stale:-0}" -gt 0 ]]; then echo "::notice::target-ring health ($agent $transition): ${cum_stale} failure(s) in runs still on the previous release — informational, not counted against candidate ${cand:0:12}." >&2 fi # Per-transition knobs (registry-configurable; #548 defaults live in the ring SoT). local dwell_floor waived="false" target=0 dwell_floor="$(_gate_knob "$agent" "$transition" dwell_hours)"; dwell_floor="${dwell_floor:-0}" - if [ "$(_gate_knob "$agent" "$transition" waive_sample)" = "true" ]; then + if [[ "$(_gate_knob "$agent" "$transition" waive_sample)" = "true" ]]; then waived="true" - elif [ -n "$(_gate_knob "$agent" "$transition" sample_min)" ]; then + elif [[ -n "$(_gate_knob "$agent" "$transition" sample_min)" ]]; then target="$(_gate_knob "$agent" "$transition" sample_min)" else local win frac cmin cmax spike_cap daily basis @@ -2003,7 +2003,7 @@ _pair_state() { # A decision that printed nothing must not read as "waived" or as a zero target: hold at the minimum. case "$waived" in true|false) ;; *) waived="false"; basis="unknown" ;; esac case "$target" in ''|*[!0-9]*) target="$cmin" ;; esac - if [ "$basis" = "unknown" ]; then + if [[ "$basis" = "unknown" ]]; then echo "::notice::$agent $transition: the baseline is unknown (a truncated read observed nothing countable on the source tier) — sample sized at the clamp minimum ($target), not waived." >&2 fi fi @@ -2018,11 +2018,11 @@ _pair_state() { # ONLY from an otherwise-PROMOTE verdict — it never masks or bypasses a reliability BLOCK, and # it never rolls back or auto-acts (worst case: a few hours of human latency). local mix_shift="-" - if [ "$state" = "PROMOTE" ] && [ -n "$(_gate_field "$agent" correctness)" ]; then + if [[ "$state" = "PROMOTE" ]] && [[ -n "$(_gate_field "$agent" correctness)" ]]; then local src_csv verdict src_csv="$(IFS=,; echo "${src_repos[*]}")" verdict="$(_correctness_verdict "$agent" "$cand" "$cut_z" "$src_csv")" - if [ "$verdict" = "SHIFT" ]; then + if [[ "$verdict" = "SHIFT" ]]; then state="BLOCKED"; mix_shift="SHIFT" fi fi @@ -2033,16 +2033,16 @@ _pair_state() { # auto-advances it. It is cleared only by a deliberate `promote --confirm` (the # dispatch IS the confirmation; no state store). The overlay fires ONLY from an otherwise- # PROMOTE (reliability-clean) verdict, so `--confirm` can never bypass a BLOCKED gate. - if [ "$state" = "PROMOTE" ] && [ "$(_gate_knob "$agent" "$transition" require_confirmation)" = "true" ]; then + if [[ "$state" = "PROMOTE" ]] && [[ "$(_gate_knob "$agent" "$transition" require_confirmation)" = "true" ]]; then state="AWAITING_CONFIRMATION" fi # Triage: a correctness SHIFT is SUSPECT (correctness variant, distinguished by mix_shift); # any other BLOCK is classified from the failure evidence. local triage="-" - if [ "$state" = "BLOCKED" ] && [ "$mix_shift" = "SHIFT" ]; then + if [[ "$state" = "BLOCKED" ]] && [[ "$mix_shift" = "SHIFT" ]]; then triage="SUSPECT" - elif [ "$state" = "BLOCKED" ]; then + elif [[ "$state" = "BLOCKED" ]]; then triage="$(classify_failure "$differs" "${CANARY_FAILURE_CATEGORY:-unknown}" "$cum_suspect")" fi @@ -2055,28 +2055,28 @@ _pair_state() { # --allow-pre-existing); HOLD stays SUSPECT (increment-2 behaviour — worse/ambiguous/thin cases # keep the human). Byte-identical for a suspect class with no auto_downgrade (empty set → skip). local downgrade="-" dg_cand_rate=0 dg_cand_sample=0 dg_base_rate=0 dg_base_sample=0 - if [ "$triage" = "SUSPECT" ] && [ "$mix_shift" = "-" ]; then + if [[ "$triage" = "SUSPECT" ]] && [[ "$mix_shift" = "-" ]]; then local dg_patterns; dg_patterns="$(_suspect_downgrade_patterns "$agent")" # Guard (Thread 1): only attempt per-class rate comparison when EVERY blocking failure # has been attributed to a suspect class. If any failure had no matching suspect class, # that unrelated failure is an independent regression and must keep the gate blocked. - if [ -n "$dg_patterns" ] && [ "${cum_suspect:-0}" -ge "$cum_fail" ]; then + if [[ -n "$dg_patterns" ]] && [[ "${cum_suspect:-0}" -ge "$cum_fail" ]]; then local dg_win dg_base_since dwf dsr dmin dmargin cm ce cmi bm be bei crate brate knobs decision dg_win="${SOAK_WINDOW_DAYS:-$(_gate_field "$agent" baseline_window_days)}"; dg_win="${dg_win:-14}" dg_base_since="$(_iso_now_minus_days "$dg_win")" - while IFS=$'\t' read -r dwf dsr dmin dmargin || [ -n "$dwf" ]; do + while IFS=$'\t' read -r dwf dsr dmin dmargin || [[ -n "$dwf" ]]; do dwf="${dwf%$'\r'}"; dsr="${dsr%$'\r'}"; dmin="${dmin%$'\r'}"; dmargin="${dmargin%$'\r'}" - [ -z "$dsr" ] && continue + [[ -z "$dsr" ]] && continue read -r cm ce cmi < <(_suspect_class_counts "$agent" "$cut_z" "-" "$dwf" "$dsr" "${src_repos[@]}") read -r bm be bei < <(_suspect_class_counts "$agent" "$dg_base_since" "$cut_z" "$dwf" "$dsr" "${src_repos[@]}") # Guard (Thread 2): if any signature lookup failed, baseline class evidence is # incomplete. A missing run could be a non-match; fail-closed and skip DOWNGRADE. - [ "${cmi:-0}" -gt 0 ] || [ "${bei:-0}" -gt 0 ] && continue - if [ "${ce:-0}" -gt 0 ]; then crate="$(round_div $(( ${cm:-0} * 1000 )) "$ce")"; else crate=0; fi - if [ "${be:-0}" -gt 0 ]; then brate="$(round_div $(( ${bm:-0} * 1000 )) "$be")"; else brate=0; fi + [[ "${cmi:-0}" -gt 0 ]] || [[ "${bei:-0}" -gt 0 ]] && continue + if [[ "${ce:-0}" -gt 0 ]]; then crate="$(round_div $(( ${cm:-0} * 1000 )) "$ce")"; else crate=0; fi + if [[ "${be:-0}" -gt 0 ]]; then brate="$(round_div $(( ${bm:-0} * 1000 )) "$be")"; else brate=0; fi knobs="$(printf '{"min_baseline_sample":%s,"margin_permille":%s}' "${dmin:-0}" "${dmargin:-0}")" decision="$(decide_suspect_downgrade "$crate" "$brate" "$be" "$knobs")" - if [ "$decision" = "DOWNGRADE" ]; then + if [[ "$decision" = "DOWNGRADE" ]]; then triage="PRE_EXISTING"; downgrade="DOWNGRADE" dg_cand_rate="$crate"; dg_cand_sample="$ce"; dg_base_rate="$brate"; dg_base_sample="$be" break @@ -2090,24 +2090,24 @@ _pair_state() { # SOAKING / AWAITING_CONFIRMATION) as BLOCKED with triage UNRESOLVED so sync-issues tracks it. # A gate already BLOCKED by real failures keeps that (more specific) triage. local unresolved="" - if [ -n "$uflag" ] && [ -s "$uflag" ]; then + if [[ -n "$uflag" ]] && [[ -s "$uflag" ]]; then unresolved="$(cut -f1 "$uflag" | sort -u | paste -sd, - | sed 's/,/, /g')" - elif [ -n "$uflag" ] && [ ! -e "$uflag" ]; then + elif [[ -n "$uflag" ]] && [[ ! -e "$uflag" ]]; then unresolved="(unresolved-member evidence could not be persisted)" - elif [ -n "$uflag" ] && ! : >>"$uflag" 2>/dev/null; then + elif [[ -n "$uflag" ]] && ! : >>"$uflag" 2>/dev/null; then # Empty but no longer writable: a failed append whose cleanup also failed (read-only fs) would # look clean here, so treat an unwritable flag as lost evidence. unresolved="(unresolved-member evidence could not be persisted)" fi - if [ "$uflag_owned" -eq 1 ]; then + if [[ "$uflag_owned" -eq 1 ]]; then # Keep a non-empty flag for _unresolved_evidence to reuse (it deletes it); drop an empty one. - [ -n "$unresolved" ] || rm -f "$uflag" + [[ -n "$unresolved" ]] || rm -f "$uflag" unset _CANARY_UNRESOLVED_FLAG fi - if [ -n "$unresolved" ]; then + if [[ -n "$unresolved" ]]; then echo "::error::canary gate for '$agent' [$transition] is BLIND on ring member(s) $unresolved — their runs could not be attributed or completely read (see the UNRESOLVED warnings above for the reason per member). Counted as UNRESOLVED, not passing evidence. For an ADR-0007 collapsed member whose role job is unresolvable, re-sync the member's ingress from the canonical template or correct the agent's ingress_job in the ring registry." >&2 # PRE_EXISTING is advanceable via `promote --allow-pre-existing`, so UNRESOLVED must dominate it. - if [ "$state" != "BLOCKED" ] || [ "$triage" = "PRE_EXISTING" ]; then + if [[ "$state" != "BLOCKED" ]] || [[ "$triage" = "PRE_EXISTING" ]]; then state="BLOCKED"; triage="UNRESOLVED"; mix_shift="-"; downgrade="-" fi fi @@ -2139,7 +2139,7 @@ _frontier_state() { local -a commits=() unknown=() local ch c u commits_csv="" while read -r ch c u; do - [ "$c" = "-" ] && c="" + [[ "$c" = "-" ]] && c="" commits+=("$c") unknown+=("$u") commits_csv+="${c:--}," @@ -2148,7 +2148,7 @@ _frontier_state() { local i prev="" cand dstc transition emitted=0 for i in "${!chan_array[@]}"; do ch="${chan_array[$i]}" - if [ -n "$prev" ]; then + if [[ -n "$prev" ]]; then # The pair verdict is the pure core's (pair_verdict, #1242); this loop only acts on it. A ring # whose tag lookup ERRORED (#1225) is unknown, not absent. cand="${commits[$((i-1))]}" @@ -2167,7 +2167,7 @@ _frontier_state() { # an errored ring's empty commit would read an API outage as "on the candidate", and a total # outage as COMPLETE. Never evaluated (or promoted) by the gate (#1225). echo "::warning::$agent $transition: a ring tag lookup errored — holding the pair BLOCKED (fail closed) until it resolves (#1225)." >&2 - if [ -n "${_CANARY_TAG_GAP_FLAG:-}" ]; then + if [[ -n "${_CANARY_TAG_GAP_FLAG:-}" ]]; then printf '%s\n' "$transition" >> "$_CANARY_TAG_GAP_FLAG" 2>/dev/null || true fi echo "${cand:--} $ch $transition BLOCKED 0 0 0 0 0 0 0 - - - 0 0 0 0" @@ -2176,7 +2176,7 @@ _frontier_state() { fi prev="$ch" done - if [ "$emitted" -eq 0 ]; then + if [[ "$emitted" -eq 0 ]]; then echo "${commits[0]:--} - - COMPLETE 0 0 0 0 0 0 0 - -" fi } @@ -2193,7 +2193,7 @@ cmd_evaluate() { for ch in "${chan_array[@]}"; do local ch_tag c IFS=$'\t' read -r ch_tag c < <(_resolved_channel "$agent" "$ch") - local mark=" "; [ -n "$cand" ] && [ "$c" = "$cand" ] && mark="* " + local mark=" "; [[ -n "$cand" ]] && [[ "$c" = "$cand" ]] && mark="* " printf ' %s%-7s -> %s\n' "$mark" "${ch_tag#"$agent"/}" "${c:0:12}" done echo "----" @@ -2202,37 +2202,37 @@ cmd_evaluate() { local triage mix_shift downgrade dg_cand_rate dg_cand_sample dg_base_rate dg_base_sample _pcand local any=0 while read -r _pcand frontier transition state dwell floor sample target cum_fail cum_startup cum_benign triage mix_shift downgrade dg_cand_rate dg_cand_sample dg_base_rate dg_base_sample; do - [ -z "$frontier" ] && continue - if [ "$frontier" = "-" ]; then + [[ -z "$frontier" ]] && continue + if [[ "$frontier" = "-" ]]; then echo "frontier: none — fully rolled out (all rings on candidate)." any=1; continue fi any=1 gate_summary_line "$transition" "$state" "$dwell" "$floor" "$sample" "$target" "$cum_fail" "$cum_startup" "$cum_benign" echo "decision for next ring '$frontier' [$transition]: $state" - if [ "$state" = "BLOCKED" ]; then - if [ "$triage" = "REGRESSION" ]; then + if [[ "$state" = "BLOCKED" ]]; then + if [[ "$triage" = "REGRESSION" ]]; then echo "::error::triage=REGRESSION — candidate changed the reusable and a run failed since cut. HALT + hold; recommend rollback." - elif [ "$triage" = "SUSPECT" ] && [ "$mix_shift" = "SHIFT" ]; then + elif [[ "$triage" = "SUSPECT" ]] && [[ "$mix_shift" = "SHIFT" ]]; then echo "::warning::triage=SUSPECT (decision-mix shift, #668 Layer 2) — the candidate's decision distribution moved ≥ threshold vs the prior-version baseline. HOLDS the promotion; review the decision-mix table in the blocker issue. Composition drift (e.g. a draft-PR burst) is the usual false positive → promote --override to dismiss; a genuine always/never shift → roll back." - elif [ "$triage" = "SUSPECT" ]; then + elif [[ "$triage" = "SUSPECT" ]]; then echo "::warning::triage=SUSPECT — failure matches a suspect class (possibly candidate-caused). BLOCKS + needs a human; see the blocker issue's discriminating question, then promote --override if unrelated or roll back if a real regression." - elif [ "$downgrade" = "DOWNGRADE" ]; then + elif [[ "$downgrade" = "DOWNGRADE" ]]; then echo "::notice::triage=PRE_EXISTING (auto-downgraded from SUSPECT, #668 increment 6) — the candidate's suspect-class failure rate (${dg_cand_rate}‰ over ${dg_cand_sample} runs) is no worse than the prior version's (${dg_base_rate}‰ over ${dg_base_sample} runs), so the timeout is environmental, not a candidate regression. Report only; the SUSPECT hold auto-cleared (no human needed). Advances with --allow-pre-existing once dwell/sample pass." - elif [ "$triage" = "UNRESOLVED" ]; then + elif [[ "$triage" = "UNRESOLVED" ]]; then echo "::error::triage=UNRESOLVED — at least one ring member's runs could not be attributed or completely read (see the UNRESOLVED warnings above for the reason per member). The gate is blind there, so it holds rather than promote on incomplete evidence. For an ADR-0007 collapsed member ('$(_ingress_workflow)' present, no per-role workflow) whose role job is unresolvable, re-sync its ingress from the canonical template or correct the agent's ingress_job in the ring registry (#1224, #1247)." - elif [ "$triage" = "FLAG_ERROR" ]; then + elif [[ "$triage" = "FLAG_ERROR" ]]; then echo "::error::triage=FLAG_ERROR — could not create the unresolved-member flag file (TMPDIR unwritable/full). The gate fails closed and holds; fix the runner's temp dir." - elif [ "$triage" = "PRE_EXISTING" ]; then + elif [[ "$triage" = "PRE_EXISTING" ]]; then echo "::warning::triage=PRE_EXISTING — failure is pre-existing/environmental. Report only; do NOT rollback. Advances with --allow-pre-existing (or control.allow_pre_existing in the registry) once dwell/sample pass." else echo "::warning::state=BLOCKED (indeterminate) — could not resolve the candidate's cut date or a ring's channel tag (lookup error), so the gate fails closed and holds (not a detected failure; cum_fail=$cum_fail). Clears once the candidate's cut date/tag is resolvable." fi - elif [ "$state" = "AWAITING_CONFIRMATION" ]; then + elif [[ "$state" = "AWAITING_CONFIRMATION" ]]; then echo "::notice::state=AWAITING_CONFIRMATION — reliability PASSED; holding for an opt-in human go/no-go at $transition (#668 Layer 3). Review the canary-confirm issue, then dispatch: promote $agent --confirm (not --override)." fi done < <(_frontier_state "$agent") - if [ "$any" -eq 0 ]; then + if [[ "$any" -eq 0 ]]; then echo "frontier: none — fully rolled out (all rings on candidate)." fi } @@ -2244,12 +2244,12 @@ cmd_evaluate() { cmd_evaluate_all() { local agents rc=0 agent agents="$(_jq -r '.agents | keys[]' 2>/dev/null || true)" - if [ -z "$agents" ]; then + if [[ -z "$agents" ]]; then echo "no agents registered in $CANARY_RINGS — nothing to evaluate."; return 0 fi echo "== canary-rollout evaluate-all: fleet-wide (gate standard: .github#548) ==" while IFS= read -r agent; do - [ -z "$agent" ] && continue + [[ -z "$agent" ]] && continue echo "──────── agent: $agent ────────" cmd_evaluate "$agent" || rc=$? done <<< "$agents" @@ -2268,7 +2268,7 @@ _record_promote_outcome() { cmd_promote() { local agent="$1"; shift local override=false dry=false allow_pre_flag=false confirm=false - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in --override) override=true ;; --confirm) confirm=true ;; @@ -2286,7 +2286,7 @@ cmd_promote() { # never be advanced — not even with --override (the destination ring is unresolved). local _gapflag _prev_gapflag="${_CANARY_TAG_GAP_FLAG:-}" _gapflag="$(mktemp 2>/dev/null || true)" - if [ -z "$_gapflag" ]; then + if [[ -z "$_gapflag" ]]; then # Cannot arm the detector: with no marker an errored ring could still be overridden/promoted. echo "::error::promote $agent: cannot create the tag-gap marker file (temp filesystem unwritable) — failing closed, promoting nothing (#1225)." >&2 return 1 @@ -2294,16 +2294,16 @@ cmd_promote() { export _CANARY_TAG_GAP_FLAG="$_gapflag" mapfile -t _pairs < <(_frontier_state "$agent") local _gap_transitions="" - if [ -n "$_gapflag" ]; then + if [[ -n "$_gapflag" ]]; then _gap_transitions="$(cat "$_gapflag" 2>/dev/null || true)" rm -f "$_gapflag" - if [ -n "$_prev_gapflag" ]; then export _CANARY_TAG_GAP_FLAG="$_prev_gapflag"; else unset _CANARY_TAG_GAP_FLAG; fi + if [[ -n "$_prev_gapflag" ]]; then export _CANARY_TAG_GAP_FLAG="$_prev_gapflag"; else unset _CANARY_TAG_GAP_FLAG; fi fi # allow_pre: advance a BLOCKED pair ONLY when triage=PRE_EXISTING (never REGRESSION). Sourced # from the per-reusable control block or the --allow-pre-existing flag (#1025 P2). Computed once. local allow_pre allow_pre_src="registry control.allow_pre_existing" allow_pre="$(_jq -r --arg a "$agent" '.agents[$a].gate?.control?.allow_pre_existing // false')" - [ "$allow_pre_flag" = true ] && { allow_pre=true; allow_pre_src="--allow-pre-existing flag"; } + [[ "$allow_pre_flag" = true ]] && { allow_pre=true; allow_pre_src="--allow-pre-existing flag"; } # Consistent move (#1076): EVERY agent moves its channel tag via `gh api` on its HOST repo — # never a local `git push`. host defaults to THIS_REPO when the registry entry omits it. local host @@ -2313,23 +2313,23 @@ cmd_promote() { local cand frontier transition state _dwell _floor _sample _target cum_fail _cum_startup _cum_benign triage _mix_shift _dg _dgcr _dgcs _dgbr _dgbs for line in "${_pairs[@]}"; do read -r cand frontier transition state _dwell _floor _sample _target cum_fail _cum_startup _cum_benign triage _mix_shift _dg _dgcr _dgcs _dgbr _dgbs <<< "$line" - { [ -z "$frontier" ] || [ "$frontier" = "-" ]; } && continue + { [[ -z "$frontier" ]] || [[ "$frontier" = "-" ]]; } && continue pending=1 # --override is an operator decision about ONE promotion — the lowest pending pair, i.e. the # newest candidate, exactly what the single-frontier code overrode. It must NOT sweep every # in-flight pair: that could push an AWAITING_CONFIRMATION pair for a different candidate past # its human go/no-go (#1118 keeps that gate). Higher pairs are still decided by their own gates. pair_override=false - if [ "$first_pair" -eq 1 ] && [ "$override" = true ]; then pair_override=true; fi + if [[ "$first_pair" -eq 1 ]] && [[ "$override" = true ]]; then pair_override=true; fi first_pair=0 # An unresolvable candidate ("-") cannot be promoted, even with --override: there is no commit to # move a tag to. It stays held BLOCKED (fail closed) until the source ring's tag resolves. - if [ -n "$_gap_transitions" ] && grep -qxF -- "$transition" <<< "$_gap_transitions"; then + if [[ -n "$_gap_transitions" ]] && grep -qxF -- "$transition" <<< "$_gap_transitions"; then echo "::error::gate=$state for '$frontier' [$transition] — a ring tag lookup errored; not promoting (even with --override). Clears once the tags resolve (#1225)." >&2 _record_promote_outcome blocked "$agent" "$transition" "TAG_LOOKUP" continue fi - if [ "$cand" = "-" ]; then + if [[ "$cand" = "-" ]]; then echo "::error::gate=$state for '$frontier' [$transition] — the source ring's commit is unresolvable; not promoting (even with --override). Clears once the tag resolves." >&2 _record_promote_outcome blocked "$agent" "$transition" "UNRESOLVABLE" continue @@ -2337,20 +2337,20 @@ cmd_promote() { # REGRESSION and SUSPECT both HALT + need a human: neither advances without --override, and # --allow-pre-existing (which only unblocks PRE_EXISTING) never advances them. For a SUSPECT # the human answers the class's discriminating question first (#668 increment 2). - if [ "$state" = "BLOCKED" ] && { [ "$triage" = "REGRESSION" ] || [ "$triage" = "SUSPECT" ]; } && [ "$pair_override" != true ]; then + if [[ "$state" = "BLOCKED" ]] && { [[ "$triage" = "REGRESSION" ]] || [[ "$triage" = "SUSPECT" ]]; } && [[ "$pair_override" != true ]]; then echo "::error::gate=BLOCKED (triage=$triage) for '$frontier' [$transition] — candidate regression suspected; not promoting. Investigate + rollback, do not --override blindly." _record_promote_outcome blocked "$agent" "$transition" "$triage" continue fi local advance=false via_allow_pre=false soak_unmet=false - [ "$state" = "PROMOTE" ] && advance=true - [ "$pair_override" = true ] && advance=true + [[ "$state" = "PROMOTE" ]] && advance=true + [[ "$pair_override" = true ]] && advance=true # allow-pre-existing is NARROW (#1177): it advances ONLY a BLOCKED pair triaged PRE_EXISTING, and # only once that pair's dwell floor AND sample target are met (a waived sample has target 0) — # it removes the environmental block, never the soak. REGRESSION/SUSPECT were refused above. - if [ "$state" = "BLOCKED" ] && [ "$triage" = "PRE_EXISTING" ] && [ "$allow_pre" = true ] && [ "$advance" != true ]; then + if [[ "$state" = "BLOCKED" ]] && [[ "$triage" = "PRE_EXISTING" ]] && [[ "$allow_pre" = true ]] && [[ "$advance" != true ]]; then if [[ "$_dwell" =~ ^[0-9]+$ && "$_floor" =~ ^[0-9]+$ && "$_sample" =~ ^[0-9]+$ && "$_target" =~ ^[0-9]+$ ]] \ - && [ "$_dwell" -ge "$_floor" ] && [ "$_sample" -ge "$_target" ]; then + && [[ "$_dwell" -ge "$_floor" ]] && [[ "$_sample" -ge "$_target" ]]; then advance=true; via_allow_pre=true else soak_unmet=true @@ -2360,17 +2360,17 @@ cmd_promote() { # (reliability is already PROMOTE — the state is only ever set from an otherwise-PROMOTE # verdict). --confirm is NOT --override: it clears ONLY this state and can never advance a # BLOCKED gate, so it cannot bypass reliability. - [ "$state" = "AWAITING_CONFIRMATION" ] && [ "$confirm" = true ] && advance=true - if [ "$advance" != true ]; then - if [ "$state" = "AWAITING_CONFIRMATION" ]; then + [[ "$state" = "AWAITING_CONFIRMATION" ]] && [[ "$confirm" = true ]] && advance=true + if [[ "$advance" != true ]]; then + if [[ "$state" = "AWAITING_CONFIRMATION" ]]; then echo "gate=AWAITING_CONFIRMATION for ring '$frontier' [$transition] — reliability PASSED; holding for an opt-in human go/no-go. Review the canary-confirm issue, then dispatch: promote $agent --confirm (--confirm advances ONLY this reliability-clean state; it is NOT --override)." _record_promote_outcome awaiting-confirmation "$agent" "$transition" - elif [ "$soak_unmet" = true ]; then + elif [[ "$soak_unmet" = true ]]; then echo "gate=$state for ring '$frontier' [$transition] (cum_fail=$cum_fail, triage=$triage) — not promoting: allow-pre-existing is set but dwell/sample not met (dwell=${_dwell}h/${_floor}h sample=${_sample}/${_target}); it advances a PRE_EXISTING block only once the soak is complete." _record_promote_outcome blocked "$agent" "$transition" "$triage" else echo "gate=$state for ring '$frontier' [$transition] (cum_fail=$cum_fail, triage=$triage) — not promoting. (use --override, or --allow-pre-existing for a PRE_EXISTING triage, after investigating)" - if [ "$state" = "SOAKING" ]; then + if [[ "$state" = "SOAKING" ]]; then _record_promote_outcome soaking "$agent" "$transition" else _record_promote_outcome blocked "$agent" "$transition" "$triage" @@ -2378,12 +2378,12 @@ cmd_promote() { fi continue fi - if [ "$state" = "AWAITING_CONFIRMATION" ] && [ "$confirm" = true ]; then + if [[ "$state" = "AWAITING_CONFIRMATION" ]] && [[ "$confirm" = true ]]; then echo "::notice::human confirmation received (--confirm) — advancing $agent/$frontier [$transition] past the confirmation go/no-go (reliability was already PROMOTE)." - elif [ "$via_allow_pre" = true ]; then + elif [[ "$via_allow_pre" = true ]]; then # Make the narrow advance FINDABLE later (#1177): name the agent, transition and triage class. echo "::warning::allow-pre-existing: advancing $agent $transition past a BLOCKED gate (triage=$triage) via $allow_pre_src — environmental block; dwell=${_dwell}h/${_floor}h sample=${_sample}/${_target} met." - elif [ "$state" != "PROMOTE" ]; then + elif [[ "$state" != "PROMOTE" ]]; then echo "::warning::advancing $agent/$frontier despite gate state '$state' (triage=$triage)" fi # Move the RESOLVED frontier tag (major-scoped-channels epic #657, F4): advance the v-scoped @@ -2392,7 +2392,7 @@ cmd_promote() { # reported as promoted_ring. local frontier_tag; frontier_tag="$(_resolved_channel_tag "$agent" "$frontier")" echo "advancing $frontier_tag -> ${cand:0:12} on $host" - if [ "$dry" = true ]; then + if [[ "$dry" = true ]]; then echo "[DRY-RUN] would: gh api PATCH repos/$host/git/refs/tags/$frontier_tag sha=$cand (force)" _record_promote_outcome would-promote "$agent" "$transition" continue @@ -2413,15 +2413,15 @@ cmd_promote() { # (last write wins); the workflow reads CANARY_PROMOTIONS_LOG — one TSV line per move — to # record a deployment for EVERY promotion (a run may now advance several rings, #1118). local deploy_repo="$host" - if [ -n "${GITHUB_OUTPUT:-}" ]; then + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then { echo "promoted_agent=$agent"; echo "promoted_ring=$frontier" echo "promoted_sha=$cand"; echo "promoted_host=$deploy_repo"; } >> "$GITHUB_OUTPUT" fi - if [ -n "${CANARY_PROMOTIONS_LOG:-}" ]; then + if [[ -n "${CANARY_PROMOTIONS_LOG:-}" ]]; then printf '%s\t%s\t%s\t%s\n' "$agent" "$frontier" "$cand" "$deploy_repo" >> "$CANARY_PROMOTIONS_LOG" fi done - if [ "$pending" -eq 0 ]; then + if [[ "$pending" -eq 0 ]]; then echo "nothing to promote — $agent is fully rolled out." _record_promote_outcome current "$agent" - fi @@ -2435,7 +2435,7 @@ cmd_promote() { # distinct: an expected gate-block stays the canary-blocker issue's job; an unexpected tag-write # failure is what this log (and sync-promotion-failures) escalates. No-op when the log is unset. _log_promotion_failure() { - [ -n "${CANARY_PROMOTIONS_FAILED_LOG:-}" ] || return 0 + [[ -n "${CANARY_PROMOTIONS_FAILED_LOG:-}" ]] || return 0 if ! printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" >> "$CANARY_PROMOTIONS_FAILED_LOG"; then echo "::warning::could not append failure record for $1 to ${CANARY_PROMOTIONS_FAILED_LOG} (unwritable path?); tag-write failure may not be escalated." >&2 fi @@ -2451,14 +2451,14 @@ _log_promotion_failure() { cmd_promote_all() { local agents rc=0 agent agents="$(_jq -r '.agents? | keys[]?' 2>/dev/null || true)" - if [ -z "$agents" ]; then + if [[ -z "$agents" ]]; then echo "no agents registered in $CANARY_RINGS — nothing to promote."; return 0 fi echo "== canary-rollout promote-all: fleet-wide (gate standard: .github#548) ==" _PROMOTE_OUTCOMES=() local failed=() while IFS= read -r agent; do - [ -z "$agent" ] && continue + [[ -z "$agent" ]] && continue echo "──────── agent: $agent ────────" cmd_promote "$agent" "$@" || { rc=$?; echo "::warning::promote of $agent returned $rc (continuing fleet)"; failed+=("$agent($rc)"); } done <<< "$agents" @@ -2479,24 +2479,24 @@ cmd_promote_all() { local -A outcome_n=() local blocked=() oc oa ot od while IFS=$'\t' read -r oc oa ot od; do - [ -z "$oc" ] && continue + [[ -z "$oc" ]] && continue outcome_n[$oc]=$(( ${outcome_n[$oc]:-0} + 1 )) - [ "$oc" = blocked ] && blocked+=("$oa $ot${od:+ $od}") - done <<< "$(if [ "${#_PROMOTE_OUTCOMES[@]}" -gt 0 ]; then printf '%s\n' "${_PROMOTE_OUTCOMES[@]}"; fi)" + [[ "$oc" = blocked ]] && blocked+=("$oa $ot${od:+ $od}") + done <<< "$(if [[ "${#_PROMOTE_OUTCOMES[@]}" -gt 0 ]]; then printf '%s\n' "${_PROMOTE_OUTCOMES[@]}"; fi)" local tally="promoted: ${outcome_n[promoted]:-0}" - [ "${outcome_n[would-promote]:-0}" -gt 0 ] && tally+=", would promote (dry-run): ${outcome_n[would-promote]}" + [[ "${outcome_n[would-promote]:-0}" -gt 0 ]] && tally+=", would promote (dry-run): ${outcome_n[would-promote]}" tally+=", soaking: ${outcome_n[soaking]:-0}" - [ "${outcome_n[awaiting-confirmation]:-0}" -gt 0 ] && tally+=", awaiting confirmation: ${outcome_n[awaiting-confirmation]}" + [[ "${outcome_n[awaiting-confirmation]:-0}" -gt 0 ]] && tally+=", awaiting confirmation: ${outcome_n[awaiting-confirmation]}" tally+=", blocked: ${outcome_n[blocked]:-0}" - [ "${#blocked[@]}" -gt 0 ] && tally+=" ($(printf '%s\n' "${blocked[@]}" | paste -sd, - | sed 's/,/, /g'))" + [[ "${#blocked[@]}" -gt 0 ]] && tally+=" ($(printf '%s\n' "${blocked[@]}" | paste -sd, - | sed 's/,/, /g'))" tally+=", current: ${outcome_n[current]:-0}" echo "promote-all outcomes: $tally" - if [ ${#failed[@]} -gt 0 ]; then + if [[ ${#failed[@]} -gt 0 ]]; then echo "::warning title=promote-all: ${#failed[@]} agent(s) failed::${failed[*]} — the sweep still exits 0 by design (#1019); investigate these before trusting a green promote-all." echo "promote-all summary: ${#failed[@]} failed — ${failed[*]}" - elif [ "${outcome_n[blocked]:-0}" -gt 0 ] || [ "${outcome_n[soaking]:-0}" -gt 0 ] || [ "${outcome_n[awaiting-confirmation]:-0}" -gt 0 ]; then + elif [[ "${outcome_n[blocked]:-0}" -gt 0 ]] || [[ "${outcome_n[soaking]:-0}" -gt 0 ]] || [[ "${outcome_n[awaiting-confirmation]:-0}" -gt 0 ]]; then echo "promote-all summary: NOT all agents advanced — $tally" - elif [ "${outcome_n[would-promote]:-0}" -gt 0 ]; then + elif [[ "${outcome_n[would-promote]:-0}" -gt 0 ]]; then echo "promote-all summary: dry run — ${outcome_n[would-promote]} would be promoted; no tag was moved." else echo "promote-all summary: all agents promoted or already current." @@ -2507,17 +2507,17 @@ cmd_promote_all() { cmd_rollback() { local agent="$1" ring="$2"; shift 2 local to="" dry=false - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in --to) - if [ $# -lt 2 ]; then echo "::error::--to requires a value" >&2; return 2; fi + if [[ $# -lt 2 ]]; then echo "::error::--to requires a value" >&2; return 2; fi to="$2"; shift ;; --dry-run) dry=true ;; *) echo "::error::unknown rollback flag: $1" >&2; return 2 ;; esac; shift done - [ -z "$to" ] && { echo "::error::rollback requires --to " >&2; return 2; } + [[ -z "$to" ]] && { echo "::error::rollback requires --to " >&2; return 2; } # #1402: is caller-supplied (workflow_dispatch) and names the tag the App force-moves — # accept ONLY a channel registered for this agent (bare or v-scoped v-). if ! rollback_ring_allowed "$ring" "$(ordered_channels "$agent" 2>/dev/null || true)"; then @@ -2532,9 +2532,9 @@ cmd_rollback() { host="${host:-$THIS_REPO}" local target target="$(_gh_tag_commit "$host" "$agent/$to")" - [ -z "$target" ] && { echo "::error::release tag $agent/$to not found on $host" >&2; return 1; } + [[ -z "$target" ]] && { echo "::error::release tag $agent/$to not found on $host" >&2; return 1; } echo "rolling back $agent/$ring -> $to (${target:0:12}) on $host" - if [ "$dry" = true ]; then + if [[ "$dry" = true ]]; then echo "[DRY-RUN] would: gh api PATCH repos/$host/git/refs/tags/$agent/$ring sha=$target (force)" return 0 fi @@ -2577,11 +2577,11 @@ _gh_issue_create() { _blocker_evidence() { local agent="$1" cand="$2" cut_z repo json r n=0 out="" cut_z="$(candidate_cut_date "$agent" "$cand")" - [ -z "$cut_z" ] && { printf '_(no candidate cut date resolved — cannot list failing runs)_\n'; return 0; } + [[ -z "$cut_z" ]] && { printf '_(no candidate cut date resolved — cannot list failing runs)_\n'; return 0; } local chan_array=() ch all=() seen=" " dedup=() IFS=, read -r -a chan_array <<< "$(ordered_channels "$agent")" for ch in "${chan_array[@]}"; do - while IFS= read -r r; do [ -n "$r" ] && [ "$r" != '*' ] && all+=("$r"); done < <(resolve_members "$agent" "$ch") + while IFS= read -r r; do [[ -n "$r" ]] && [[ "$r" != '*' ]] && all+=("$r"); done < <(resolve_members "$agent" "$ch") done for r in "${all[@]}"; do case "$seen" in *" $r "*) ;; *) dedup+=("$r"); seen+="$r ";; esac; done # Same attribution scope as _pair_state: only a repo that belongs solely to tiers provably NOT on @@ -2590,8 +2590,8 @@ _blocker_evidence() { local -A raw_evidence=(); local ev_c for ch in "${chan_array[@]}"; do ev_c="$(channel_commit "$agent" "$ch" || true)" - if [ -z "$ev_c" ] || [ "$ev_c" = "$cand" ]; then - while IFS= read -r r; do [ -n "$r" ] && raw_evidence["$r"]=1; done < <(resolve_members "$agent" "$ch") + if [[ -z "$ev_c" ]] || [[ "$ev_c" = "$cand" ]]; then + while IFS= read -r r; do [[ -n "$r" ]] && raw_evidence["$r"]=1; done < <(resolve_members "$agent" "$ch") fi done for repo in "${dedup[@]}"; do @@ -2599,17 +2599,17 @@ _blocker_evidence() { local rid rrole sig local concl while IFS=$'\t' read -r rid concl rrole; do - [ -z "$rid" ] && continue + [[ -z "$rid" ]] && continue # Old release: not counted (#1176). Only `failure` runs are attributable — a startup_failure # never ran a job, so it has no "Uses:" line and always counts (cum_startup), like the gate. - if [ "$concl" = "failure" ] && [ -z "${raw_evidence["$repo"]:-}" ] && _run_is_stale "$agent" "$cand" "$repo" "$rid"; then continue; fi - if [ "$n" -ge 8 ]; then out+="- _(…more failing runs; truncated at 8)_"$'\n'; printf '%s' "$out"; return 0; fi + if [[ "$concl" = "failure" ]] && [[ -z "${raw_evidence["$repo"]:-}" ]] && _run_is_stale "$agent" "$cand" "$repo" "$rid"; then continue; fi + if [[ "$n" -ge 8 ]]; then out+="- _(…more failing runs; truncated at 8)_"$'\n'; printf '%s' "$out"; return 0; fi sig="$(_run_signature "$repo" "$rid" "$rrole" | tr '\n' ';' | sed 's/;$//')" out+="- \`$repo\` — run [$rid](https://github.com/$repo/actions/runs/$rid); failed steps: ${sig:-unknown}"$'\n' n=$((n+1)) done < <(jq -r '.[]?|select(.conclusion=="failure" or .conclusion=="startup_failure")|[(.databaseId|tostring),.conclusion,(.role // "")]|@tsv' 2>/dev/null <<< "$json") done - [ -z "$out" ] && out="_(no failing runs in the per-candidate window — cum_fail may be startup_failures or a transient count)_"$'\n' + [[ -z "$out" ]] && out="_(no failing runs in the per-candidate window — cum_fail may be startup_failures or a transient count)_"$'\n' printf '%s' "$out" } @@ -2620,21 +2620,21 @@ _unresolved_evidence() { local agent="$1" cand="$2" flag repo reason out="" flag_is_persistent=0 reuse=0 # Reuse the flag left by _frontier_state (a caller's pre-armed flag first) to avoid re-walking (#1224) flag="${_CANARY_UNRESOLVED_FLAG:-$(_unresolved_flag_path "$agent" "$cand")}" - if [ -s "$flag" ]; then + if [[ -s "$flag" ]]; then flag_is_persistent=1 # A caller-armed flag belongs to the caller; only delete our own per-process one. - [ -z "${_CANARY_UNRESOLVED_FLAG:-}" ] && reuse=1 + [[ -z "${_CANARY_UNRESOLVED_FLAG:-}" ]] && reuse=1 else # Flag not found or empty, create a new one and re-evaluate flag="$(mktemp 2>/dev/null || echo "")" - [ -z "$flag" ] && { printf '_(could not list the unresolved members — see the workflow log ::error:: annotations)_\n'; return 0; } + [[ -z "$flag" ]] && { printf '_(could not list the unresolved members — see the workflow log ::error:: annotations)_\n'; return 0; } _CANARY_UNRESOLVED_FLAG="$flag" _blocker_evidence "$agent" "$cand" >/dev/null 2>&1 || true fi while IFS=$'\t' read -r repo reason; do - [ -n "$repo" ] && out+="- \`$repo\` — $reason"$'\n' + [[ -n "$repo" ]] && out+="- \`$repo\` — $reason"$'\n' done < <(sort -u "$flag") - if [ "$flag_is_persistent" -eq 0 ] || [ "$reuse" -eq 1 ]; then rm -f "$flag"; fi - [ -z "$out" ] && out="_(no unresolved member found in the blocker scope on re-check — the hold may come from the baseline window or an unpersisted evidence flag; it re-evaluates next tick)_"$'\n' + if [[ "$flag_is_persistent" -eq 0 ]] || [[ "$reuse" -eq 1 ]]; then rm -f "$flag"; fi + [[ -z "$out" ]] && out="_(no unresolved member found in the blocker scope on re-check — the hold may come from the baseline window or an unpersisted evidence flag; it re-evaluates next tick)_"$'\n' printf '%s' "$out" } @@ -2655,7 +2655,7 @@ _blocker_body() { # Correctness variant (#668 L2): a decision-mix SHIFT holds as SUSPECT but is NOT a reliability # failure — the candidate exited green yet its decision distribution moved. Distinct note + a # candidate-vs-baseline mix table replace the failing-runs evidence (there are none). - if [ "$triage" = "SUSPECT" ] && [ "$mix_shift" = "SHIFT" ]; then + if [[ "$triage" = "SUSPECT" ]] && [[ "$mix_shift" = "SHIFT" ]]; then note="> ⚠️ **SUSPECT (decision-mix shift, #668 Layer 2)** — reliability PASSED (the candidate exits green), but its decision-class distribution shifted materially vs the prior version on comparable traffic. This HOLDS and needs a human (labelled \`needs-human\`): decide whether the shift is an intended behaviour change (\`promote --override\`) or a correctness regression (roll back). The gate never rolls back on its own." cat < @@ -2678,16 +2678,16 @@ _Whole-fleet status is in the Canary Rollout workflow run's job summary (Actions EOF return 0 fi - if [ "$triage" = "REGRESSION" ]; then + if [[ "$triage" = "REGRESSION" ]]; then note="> ⛔ **REGRESSION** — the candidate changed the reusable and a run failed since its cut. HALT + hold; investigate and roll back rather than \`--override\`. (labelled \`needs-human\`)" - elif [ "$triage" = "SUSPECT" ]; then + elif [[ "$triage" = "SUSPECT" ]]; then local guidance; guidance="$(_suspect_guidance "$agent")" - [ -z "$guidance" ] && guidance="- _(no per-class guidance registered)_" + [[ -z "$guidance" ]] && guidance="- _(no per-class guidance registered)_" note="> ⚠️ **SUSPECT** — the candidate changed the reusable and a run failed with a *possibly-candidate-caused* signature. This still BLOCKS and needs a human (labelled \`needs-human\`), but answer the discriminating question below to confirm fast: if unrelated to the diff, \`promote --override\`; if the candidate is materially responsible, treat it as a real regression and roll back. > > **Discriminating question:** $(printf '%s\n' "$guidance" | sed 's/^/> /')" - elif [ "$downgrade" = "DOWNGRADE" ]; then + elif [[ "$downgrade" = "DOWNGRADE" ]]; then note="> ℹ️ **PRE_EXISTING** — *auto-downgraded from SUSPECT (#668 increment 6).* This failure matched a suspect class that opts into data-driven auto-downgrade, and the candidate's failure rate for that class is **no worse** than the prior version's on comparable traffic — so it is environmental, not a candidate regression. The SUSPECT hold auto-cleared: report only, **no human needed**, and the frontier advances with \`--allow-pre-existing\` once dwell/sample pass. (A materially-worse or thin-baseline case would have stayed SUSPECT.) > > **Suspect-class failure rate (candidate vs prior-version baseline):** @@ -2696,11 +2696,11 @@ $(printf '%s\n' "$guidance" | sed 's/^/> /')" > |---|---|---| > | candidate | \`$dg_cand_rate\` | $dg_cand_sample | > | baseline | \`$dg_base_rate\` | $dg_base_sample |" - elif [ "$triage" = "UNRESOLVED" ]; then + elif [[ "$triage" = "UNRESOLVED" ]]; then note="> ⚠️ **UNRESOLVED (gate blind on a ring member, #1224)** — this agent's runs on at least one ring member could not be attributed or completely read (the reason per member is listed below). That member is **not** counted as passing evidence, so the gate holds instead of promoting on incomplete evidence. This is **not** a detected run failure (cumulative failures: $cum_fail). For an ADR-0007 collapsed repo (its per-role workflow is gone, \`$(_ingress_workflow)\` is present) whose runs could not be attributed to the role job: the member's ingress carries no job for this role, or the registry's \`ingress_job\` does not name that job's key. Re-sync the member's ingress from the canonical \`standards/workflows/agent-ingress.yml\`, or correct \`ingress_job\` in \`standards/canary-rings.json\` (#1247). If the evidence below says job reads were capped, the ingress has more runs in the window than \`CANARY_INGRESS_JOBS_MAX\` allows: raise that knob (default 300) instead. If it names a per-role run list that reached $_LEGACY_RUN_LIMIT runs, that workflow has more failed runs since the candidate cut than one listing returns, so the gate cannot see every failure in the window (#1244): investigate the failure volume; the hold clears when the candidate is recut. This issue auto-closes once every member resolves." - elif [ "$triage" = "FLAG_ERROR" ]; then + elif [[ "$triage" = "FLAG_ERROR" ]]; then note="> ⚠️ **FLAG_ERROR (gate held — runner temp dir unwritable)** — the candidate's cut date resolved fine, but the gate could not create its unresolved-member flag file (\`TMPDIR\` unwritable or full), so it could not record unattributable ring members and fails closed rather than promote on possibly incomplete evidence. This is **not** a detected run failure (cumulative failures: $cum_fail). Fix: free space / make \`TMPDIR\` writable on the runner; this clears on the next tick." - elif [ "$triage" = "PRE_EXISTING" ]; then + elif [[ "$triage" = "PRE_EXISTING" ]]; then note="> ⚠️ **PRE_EXISTING** — the failure is pre-existing/environmental (reusable byte-identical to the prior channel). Report only; the gate will not roll back or advance. Fix-forward, and the armed timer auto-promotes once clean." else # Fail-closed hold (_frontier_state: cut_z empty) — state=BLOCKED but triage="-" and cum_fail=0. @@ -2714,16 +2714,16 @@ $(printf '%s\n' "$guidance" | sed 's/^/> /')" # (before the triage note) and blank out the misleading "0" cumulative-failures cell. local cum_row="**$cum_fail** (startup_failures: $cum_startup)" local evidence_heading="Failing runs in the per-candidate window" - [ "$triage" = "UNRESOLVED" ] && evidence_heading="Unresolved ring members" - if [ "$data_gap" = "2" ]; then + [[ "$triage" = "UNRESOLVED" ]] && evidence_heading="Unresolved ring members" + if [[ "$data_gap" = "2" ]]; then cum_row="_unknown — channel tags unresolved this tick_" note="> ⚠️ **TAG LOOKUP FAILED — FAILING CLOSED.** A channel-tag lookup for this pair errored this tick (a GitHub API failure — see the workflow log — not an absent tag), so the gate cannot tell where the rings are. Rather than report a false \"fully rolled out\", it holds the promotion and keeps this issue open. This clears automatically once the tags resolve again and the gate re-evaluates (#1225)." - elif [ "$data_gap" = "1" ]; then + elif [[ "$data_gap" = "1" ]]; then # With both commits resolvable the triage rests on the reusable diff. With either missing (triage # "-", #1249) nothing was compared, so no verdict is claimed. local gap_verdict="the triage verdict is derived from the reusable diff alone." - if [ "$triage" = "-" ] || [ -z "$triage" ]; then - if [ -z "$cand" ] || [ "$cand" = "-" ]; then + if [[ "$triage" = "-" ]] || [[ -z "$triage" ]]; then + if [[ -z "$cand" ]] || [[ "$cand" = "-" ]]; then gap_verdict="there is **no triage verdict (INDETERMINATE)**: the source ring's commit is unresolvable this tick, so the reusable could not be compared with the prior channel." else gap_verdict="there is **no triage verdict (INDETERMINATE)**: the destination ring has no resolvable commit this tick, so there is no prior channel to compare the reusable with." @@ -2768,12 +2768,12 @@ _confirm_body() { local agent="$1" transition="$2" cand="$3" prior="$4" host="$5" sample="$6" target="$7" local suspect; suspect="$(_suspect_guidance "$agent")" local watch="" - [ -n "$suspect" ] && watch=" + [[ -n "$suspect" ]] && watch=" ### Watch for (suspect classes registered for this agent) $suspect" local diff_link - if [ -n "$prior" ]; then + if [[ -n "$prior" ]]; then diff_link="https://github.com/$host/compare/${prior}...${cand}" else diff_link="(no prior stable release)" @@ -2853,7 +2853,7 @@ _frontier_state_resilient() { local flag gapflag gaps="" flag="$(mktemp 2>/dev/null || echo "")" gapflag="$(mktemp 2>/dev/null || echo "")" - if [ -z "$flag" ] || [ -z "$gapflag" ]; then + if [[ -z "$flag" ]] || [[ -z "$gapflag" ]]; then # Cannot arm a failure detector at all — treat as an unreadable tick rather # than silently falling back to the old undetectable-outage behavior. fetch_failed=1 @@ -2863,17 +2863,17 @@ _frontier_state_resilient() { export _CANARY_FETCH_FAIL_FLAG="$flag" _CANARY_TAG_GAP_FLAG="$gapflag" out="$(_frontier_state "$agent")" || rc=$? unset _CANARY_FETCH_FAIL_FLAG _CANARY_TAG_GAP_FLAG - [ -s "$flag" ] && fetch_failed=1 + [[ -s "$flag" ]] && fetch_failed=1 gaps="$(<"$gapflag")" fi - [ -n "$flag" ] && rm -f "$flag" - [ -n "$gapflag" ] && rm -f "$gapflag" + [[ -n "$flag" ]] && rm -f "$flag" + [[ -n "$gapflag" ]] && rm -f "$gapflag" # Normal path (no fetch failure, state lines produced): transparent pass-through, datagap=0 # appended to EVERY pending-pair line (#1118) — or 2 for a pair held on a tag-lookup error. - if [ "$fetch_failed" -eq 0 ] && [ -n "$out" ] && [ "$rc" -eq 0 ]; then + if [[ "$fetch_failed" -eq 0 ]] && [[ -n "$out" ]] && [[ "$rc" -eq 0 ]]; then local line ltr gap while IFS= read -r line; do - [ -n "$line" ] || continue + [[ -n "$line" ]] || continue local temp="${line#* }" temp="${temp#* }" ltr="${temp%% *}" @@ -2885,8 +2885,8 @@ _frontier_state_resilient() { fi # Non-data-gap _frontier_state failure: the fetch succeeded but _frontier_state still failed — # propagate the error rather than mis-annotating it as a data gap and emitting datagap=1. - if [ "$fetch_failed" -eq 0 ]; then - [ "$rc" -ne 0 ] && return "$rc" + if [[ "$fetch_failed" -eq 0 ]]; then + [[ "$rc" -ne 0 ]] && return "$rc" return 1 fi # Data gap: the run-history fetch failed — reconstruct the tag-only facts for EVERY pending pair @@ -2894,13 +2894,13 @@ _frontier_state_resilient() { local chans; chans="$(ordered_channels "$agent" || true)" local prev="" prev_commit="" prev_unknown=0 ch ch_commit ch_unknown cand dstc transition prior differs triage emitted=0 while read -r ch ch_commit ch_unknown; do # once per ring (uncached across calls) - [ -n "$ch" ] || continue - [ "$ch_commit" = "-" ] && ch_commit="" - if [ -n "$prev" ]; then + [[ -n "$ch" ]] || continue + [[ "$ch_commit" = "-" ]] && ch_commit="" + if [[ -n "$prev" ]]; then cand="$prev_commit" dstc="$ch_commit" # A ring whose tag lookup errored (#1225) is unknown: hold the pair BLOCKED, tag-gap. - if [ "$prev_unknown" = 1 ] || [ "$ch_unknown" = 1 ]; then + if [[ "$prev_unknown" = 1 ]] || [[ "$ch_unknown" = 1 ]]; then echo "${cand:--} $ch ${prev}->${ch} BLOCKED 0 0 0 0 0 0 0 - - - 0 0 0 0 2" emitted=1 prev="$ch"; prev_commit="$ch_commit"; prev_unknown="$ch_unknown" @@ -2909,14 +2909,14 @@ _frontier_state_resilient() { # Same pure pair verdict as _frontier_state (#1242): a pending pair, including an unresolvable # (empty) src commit, stays tracked as BLOCKED rather than vanishing (fail closed); anything # other than ON_CANDIDATE is held. - if [ "$(pair_verdict "${cand:--}" "$prev_unknown" "${dstc:--}" "$ch_unknown")" != "ON_CANDIDATE" ]; then + if [[ "$(pair_verdict "${cand:--}" "$prev_unknown" "${dstc:--}" "$ch_unknown")" != "ON_CANDIDATE" ]]; then transition="${prev}->${ch}" prior="$dstc" # A comparison needs BOTH commits. With an unresolvable (empty) candidate, or a destination ring # that has no commit yet (empty prior), nothing can be compared: differs is unknown, not # "unchanged" (#1249). _reusable_differs answers 0 when either side is empty, which read as # PRE_EXISTING — a "byte-identical" claim nothing supports. - if [ -n "$cand" ] && [ -n "$prior" ]; then differs="$(_reusable_differs "$agent" "$cand" "$prior")"; else differs="unknown"; fi + if [[ -n "$cand" ]] && [[ -n "$prior" ]]; then differs="$(_reusable_differs "$agent" "$cand" "$prior")"; else differs="unknown"; fi # classify_failure with an unknown category + no suspect signal: differs=1 → REGRESSION # (fail closed — a changed reusable with UNREADABLE health is a suspected regression that # needs a human), differs=0 → PRE_EXISTING (a byte-identical reusable cannot be a @@ -2928,10 +2928,10 @@ _frontier_state_resilient() { fi fi prev="$ch"; prev_commit="$ch_commit"; prev_unknown="$ch_unknown" - done < <(if [ -n "$chans" ]; then _ring_commits "$agent" "$chans"; fi) + done < <(if [[ -n "$chans" ]]; then _ring_commits "$agent" "$chans"; fi) # Total inability: cannot resolve even one pending pair → hard error. Fail closed (never a silent # green); the caller surfaces ::error:: and ends non-zero. - [ "$emitted" -eq 0 ] && return 1 + [[ "$emitted" -eq 0 ]] && return 1 return 0 } @@ -2957,11 +2957,11 @@ _confirm_issues_for_agent() { # totally undeterminable — a green no-op would mask a regression (#820). Reads ISSUE_REPO # (default THIS_REPO). cmd_sync_issues() { - local dry=false; [ "${1:-}" = "--dry-run" ] && dry=true + local dry=false; [[ "${1:-}" = "--dry-run" ]] && dry=true local agents; agents="$(_jq -r '.agents? | keys[]?' 2>/dev/null || true)" - [ -z "$agents" ] && { echo "no agents registered in $CANARY_RINGS — nothing to sync."; return 0; } + [[ -z "$agents" ]] && { echo "no agents registered in $CANARY_RINGS — nothing to sync."; return 0; } echo "== canary-rollout sync-issues: repo=$ISSUE_REPO dry=$dry (gate standard: .github#548) ==" - if [ "$dry" != true ]; then + if [[ "$dry" != true ]]; then gh label create canary-blocker --repo "$ISSUE_REPO" --color ededed --description "canary-rollout automation" >/dev/null 2>&1 || true # Route blockers so they get ACTIONED, not left sitting: `dev-lead` (dev-lead-intent # treats a `dev-lead`-labelled issue as an actionable "issue" intent and picks it up — @@ -2977,7 +2977,7 @@ cmd_sync_issues() { fi local rows="" agent hard_fail=0 while IFS= read -r agent; do - [ -z "$agent" ] && continue + [[ -z "$agent" ]] && continue # Resilient read (#820), now per PENDING pair (#1118): the wrapper fails closed to a # BLOCKED+datagap line per pending pair on a run-history fetch outage, and returns non-zero # (no output) only on a TOTAL inability to determine state. A non-zero here means we could not @@ -2985,7 +2985,7 @@ cmd_sync_issues() { # mask a regression). local pairs_out pairs_rc=0 pairs_out="$(_frontier_state_resilient "$agent")" || pairs_rc=$? - if [ "$pairs_rc" -ne 0 ]; then + if [[ "$pairs_rc" -ne 0 ]]; then echo "::error::sync-issues: cannot determine canary state for '$agent' (run-history AND tag resolution unavailable) — failing closed rather than reporting a false all-clear." >&2 hard_fail=1 rows+="| \`$agent\` | UNKNOWN | \`-\` | ? | ? | ⚠️ data unavailable (fail-closed) |"$'\n' @@ -3001,25 +3001,25 @@ cmd_sync_issues() { local bl_mix_shift="-" bl_downgrade="-" bl_dgcr=0 bl_dgcs=0 bl_dgbr=0 bl_dgbs=0 bl_datagap=0 local have_awaiting=0 cf_cand="" cf_transition="" cf_sample=0 cf_target=0 tag_gap=0 while read -r cand frontier transition state _d _f _s _t cum_fail cum_startup _cb triage mix_shift downgrade dg_cand_rate dg_cand_sample dg_base_rate dg_base_sample datagap; do - { [ -z "$frontier" ] || [ "$frontier" = "-" ]; } && continue - [ "${datagap:-0}" = "2" ] && tag_gap=1 + { [[ -z "$frontier" ]] || [[ "$frontier" = "-" ]]; } && continue + [[ "${datagap:-0}" = "2" ]] && tag_gap=1 # Blocker source priority: a substantive REGRESSION/SUSPECT pair outranks a tag-lookup-error pair # (datagap=2), which outranks an ordinary BLOCKED pair — so an active regression's escalation # (needs-human) is never replaced by the outage's `-` triage, while the outage still beats # unrelated run evidence. The outage itself is surfaced via tag_gap (hard_fail + dashboard). local _rank=1 _bl_rank=1 - { [ "$triage" = "REGRESSION" ] || [ "$triage" = "SUSPECT" ]; } && _rank=3 - [ "${datagap:-0}" = "2" ] && _rank=2 - { [ "$bl_triage" = "REGRESSION" ] || [ "$bl_triage" = "SUSPECT" ]; } && _bl_rank=3 - [ "$bl_datagap" = "2" ] && _bl_rank=2 - if [ "$state" = "BLOCKED" ] && { [ "$have_blocked" -eq 0 ] || [ "$_rank" -gt "$_bl_rank" ]; }; then + { [[ "$triage" = "REGRESSION" ]] || [[ "$triage" = "SUSPECT" ]]; } && _rank=3 + [[ "${datagap:-0}" = "2" ]] && _rank=2 + { [[ "$bl_triage" = "REGRESSION" ]] || [[ "$bl_triage" = "SUSPECT" ]]; } && _bl_rank=3 + [[ "$bl_datagap" = "2" ]] && _bl_rank=2 + if [[ "$state" = "BLOCKED" ]] && { [[ "$have_blocked" -eq 0 ]] || [[ "$_rank" -gt "$_bl_rank" ]]; }; then have_blocked=1 bl_cand="$cand"; bl_transition="$transition"; bl_triage="$triage" bl_cum_fail="$cum_fail"; bl_cum_startup="$cum_startup"; bl_mix_shift="$mix_shift" bl_downgrade="$downgrade"; bl_dgcr="$dg_cand_rate"; bl_dgcs="$dg_cand_sample" bl_dgbr="$dg_base_rate"; bl_dgbs="$dg_base_sample"; bl_datagap="${datagap:-0}" fi - if [ "$state" = "AWAITING_CONFIRMATION" ] && [ "$have_awaiting" -eq 0 ]; then + if [[ "$state" = "AWAITING_CONFIRMATION" ]] && [[ "$have_awaiting" -eq 0 ]]; then have_awaiting=1 cf_cand="$cand"; cf_transition="$transition"; cf_sample="$_s"; cf_target="$_t" fi @@ -3027,7 +3027,7 @@ cmd_sync_issues() { # A ring's tag lookup ERRORED this tick (#1225): the pairs touching it are held BLOCKED, but the # agent's true state is unknown — fail closed (non-zero, like a total run-history outage) and # never close an issue on the strength of it (handled below). - if [ "$tag_gap" -eq 1 ]; then + if [[ "$tag_gap" -eq 1 ]]; then echo "::error::sync-issues: a channel-tag lookup for '$agent' errored (API failure, not an absent tag) — holding the affected pair(s) BLOCKED and failing closed rather than reporting a false all-clear." >&2 hard_fail=1 fi @@ -3036,50 +3036,50 @@ cmd_sync_issues() { local bl_link="—" num_state num istate num_state="$(_issue_find canary-blocker "" || true)" num="${num_state%%$'\t'*}"; istate="${num_state##*$'\t'}" - if [ "$have_blocked" -eq 1 ]; then + if [[ "$have_blocked" -eq 1 ]]; then local evidence body title mix_table="" - if [ "$bl_datagap" = "2" ]; then + if [[ "$bl_datagap" = "2" ]]; then evidence="_(⚠️ a channel-tag lookup failed this tick, so this pair's rings could not be resolved and no failing runs can be attributed. The gate FAILS CLOSED: the promotion is held and this issue stays open until the tags resolve again.)_" - elif [ "$bl_datagap" = "1" ]; then + elif [[ "$bl_datagap" = "1" ]]; then evidence="_(⚠️ run-history fetch failed this tick — the failing runs could not be listed. The gate FAILS CLOSED: the promotion is held and this issue stays open until run history is readable again and the gate can re-evaluate.)_" - elif [ "$bl_triage" = "UNRESOLVED" ]; then + elif [[ "$bl_triage" = "UNRESOLVED" ]]; then evidence="$(_unresolved_evidence "$agent" "$bl_cand" || true)" - elif [ "$bl_triage" = "FLAG_ERROR" ]; then + elif [[ "$bl_triage" = "FLAG_ERROR" ]]; then evidence="_(the gate could not create its unresolved-member flag file in \`TMPDIR\`, so no run evidence was gathered this tick. It FAILS CLOSED and holds this pair until the temp dir is writable.)_" else - if [ "$bl_cand" = "-" ]; then + if [[ "$bl_cand" = "-" ]]; then evidence="_(the source ring's commit is unresolvable this tick, so there is no candidate whose failing runs can be listed. The gate FAILS CLOSED and holds this pair until the tag resolves.)_" else evidence="$(_blocker_evidence "$agent" "$bl_cand" || true)" fi fi - [ "$bl_mix_shift" = "SHIFT" ] && mix_table="$(_decision_mix_table "$agent" "$bl_cand" "$bl_transition" || true)" + [[ "$bl_mix_shift" = "SHIFT" ]] && mix_table="$(_decision_mix_table "$agent" "$bl_cand" "$bl_transition" || true)" body="$(_blocker_body "$agent" "$bl_transition" "$bl_cand" "$bl_cum_fail" "$bl_cum_startup" "$bl_triage" "$host" "$evidence" "$bl_mix_shift" "$mix_table" "$bl_downgrade" "$bl_dgcr" "$bl_dgcs" "$bl_dgbr" "$bl_dgbs" "$bl_datagap")" - if [ "$bl_mix_shift" = "SHIFT" ]; then + if [[ "$bl_mix_shift" = "SHIFT" ]]; then title="Canary blocker: $agent $bl_transition (decision-mix shift, SUSPECT)" - elif [ "$bl_datagap" = "2" ]; then + elif [[ "$bl_datagap" = "2" ]]; then title="Canary blocker: $agent $bl_transition (tag lookup failed — fail-closed)" - elif [ "$bl_datagap" = "1" ]; then + elif [[ "$bl_datagap" = "1" ]]; then title="Canary blocker: $agent $bl_transition ($(triage_label "$bl_triage"), partial run-history — fail-closed)" else title="Canary blocker: $agent $bl_transition (cum_fail=$bl_cum_fail, $bl_triage)" fi - if [ -z "$num" ]; then - if [ "$dry" = true ]; then echo " [DRY] would OPEN blocker issue for $agent ($bl_triage)"; bl_link="(new)"; else + if [[ -z "$num" ]]; then + if [[ "$dry" = true ]]; then echo " [DRY] would OPEN blocker issue for $agent ($bl_triage)"; bl_link="(new)"; else num="$(_gh_issue_create "$title" "$body" "canary-blocker" || true)" - if [ -n "$num" ]; then + if [[ -n "$num" ]]; then gh issue edit "$num" --repo "$ISSUE_REPO" --add-label dev-lead >/dev/null 2>&1 || true - { [ "$bl_triage" = "REGRESSION" ] || [ "$bl_triage" = "SUSPECT" ]; } && gh issue edit "$num" --repo "$ISSUE_REPO" --add-label needs-human >/dev/null 2>&1 || true + { [[ "$bl_triage" = "REGRESSION" ]] || [[ "$bl_triage" = "SUSPECT" ]]; } && gh issue edit "$num" --repo "$ISSUE_REPO" --add-label needs-human >/dev/null 2>&1 || true echo " opened blocker issue #$num for $agent"; bl_link="#$num" else echo "::warning::could not open blocker issue for $agent (Issues:write on the App?)"; fi fi else - if [ "$dry" = true ]; then echo " [DRY] would UPDATE blocker issue #$num for $agent"; bl_link="#$num"; else - [ "$istate" = "OPEN" ] || gh issue reopen "$num" --repo "$ISSUE_REPO" >/dev/null 2>&1 || true + if [[ "$dry" = true ]]; then echo " [DRY] would UPDATE blocker issue #$num for $agent"; bl_link="#$num"; else + [[ "$istate" = "OPEN" ]] || gh issue reopen "$num" --repo "$ISSUE_REPO" >/dev/null 2>&1 || true gh issue edit "$num" --repo "$ISSUE_REPO" --title "$title" --body "$body" >/dev/null 2>&1 \ || echo "::warning::could not update blocker issue #$num for $agent" gh issue edit "$num" --repo "$ISSUE_REPO" --add-label dev-lead >/dev/null 2>&1 || true - if [ "$bl_triage" = "REGRESSION" ] || [ "$bl_triage" = "SUSPECT" ]; then + if [[ "$bl_triage" = "REGRESSION" ]] || [[ "$bl_triage" = "SUSPECT" ]]; then gh issue edit "$num" --repo "$ISSUE_REPO" --add-label needs-human >/dev/null 2>&1 || true else gh issue edit "$num" --repo "$ISSUE_REPO" --remove-label needs-human >/dev/null 2>&1 || true @@ -3090,8 +3090,8 @@ cmd_sync_issues() { else # No BLOCKED pair — close a stale open blocker issue (the gate cleared). Never on a tag gap # (#1225): an unresolved ring is not a cleared gate. - if [ -n "$num" ] && [ "$istate" = "OPEN" ] && [ "$tag_gap" -eq 0 ]; then - if [ "$dry" = true ]; then echo " [DRY] would CLOSE cleared blocker issue #$num for $agent"; else + if [[ -n "$num" ]] && [[ "$istate" = "OPEN" ]] && [[ "$tag_gap" -eq 0 ]]; then + if [[ "$dry" = true ]]; then echo " [DRY] would CLOSE cleared blocker issue #$num for $agent"; else gh issue close "$num" --repo "$ISSUE_REPO" \ --comment "✅ Gate cleared — \`$agent\` is no longer BLOCKED. Closed automatically by canary-rollout." >/dev/null 2>&1 || true echo " closed cleared blocker issue #$num for $agent" @@ -3106,7 +3106,7 @@ cmd_sync_issues() { # and closes only when stable advances or the ring1 candidate itself changes. Labelled # needs-human — a human confirms; dev-lead does not action it. local cf_link="—" cnum_state cnum cistate keep_num="" - if [ "$have_awaiting" -eq 1 ]; then + if [[ "$have_awaiting" -eq 1 ]]; then local cf_dst prior cbody ctitle cf_dst="${cf_transition##*->}" prior="$(channel_commit "$agent" "$cf_dst" || true)" @@ -3114,17 +3114,17 @@ cmd_sync_issues() { ctitle="Canary confirm: $agent $cf_transition — human go/no-go before stable" cnum_state="$(_issue_find canary-confirm "" || true)" cnum="${cnum_state%%$'\t'*}"; cistate="${cnum_state##*$'\t'}" - if [ -z "$cnum" ]; then - if [ "$dry" = true ]; then echo " [DRY] would OPEN confirm issue for $agent"; cf_link="(new confirm)"; else + if [[ -z "$cnum" ]]; then + if [[ "$dry" = true ]]; then echo " [DRY] would OPEN confirm issue for $agent"; cf_link="(new confirm)"; else cnum="$(_gh_issue_create "$ctitle" "$cbody" "canary-confirm" || true)" - if [ -n "$cnum" ]; then + if [[ -n "$cnum" ]]; then gh issue edit "$cnum" --repo "$ISSUE_REPO" --add-label needs-human >/dev/null 2>&1 || true echo " opened confirm issue #$cnum for $agent"; cf_link="#$cnum (confirm)" else echo "::warning::could not open confirm issue for $agent (Issues:write on the App?)"; fi fi else - if [ "$dry" = true ]; then echo " [DRY] would UPDATE confirm issue #$cnum for $agent"; cf_link="#$cnum (confirm)"; else - [ "$cistate" = "OPEN" ] || gh issue reopen "$cnum" --repo "$ISSUE_REPO" >/dev/null 2>&1 || true + if [[ "$dry" = true ]]; then echo " [DRY] would UPDATE confirm issue #$cnum for $agent"; cf_link="#$cnum (confirm)"; else + [[ "$cistate" = "OPEN" ]] || gh issue reopen "$cnum" --repo "$ISSUE_REPO" >/dev/null 2>&1 || true gh issue edit "$cnum" --repo "$ISSUE_REPO" --title "$ctitle" --body "$cbody" >/dev/null 2>&1 \ || echo "::warning::could not update confirm issue #$cnum for $agent" gh issue edit "$cnum" --repo "$ISSUE_REPO" --add-label needs-human >/dev/null 2>&1 || true @@ -3138,30 +3138,30 @@ cmd_sync_issues() { # Skipped on a tag gap (#1225): with a ring unresolved we cannot tell stale from still-pending. local ci_num ci_state while IFS=$'\t' read -r ci_num ci_state; do - [ -z "$ci_num" ] && continue - [ "$tag_gap" -eq 1 ] && continue - [ -n "$keep_num" ] && [ "$ci_num" = "$keep_num" ] && continue - [ "$ci_state" = "OPEN" ] || continue - if [ "$dry" = true ]; then echo " [DRY] would CLOSE cleared confirm issue #$ci_num for $agent"; else + [[ -z "$ci_num" ]] && continue + [[ "$tag_gap" -eq 1 ]] && continue + [[ -n "$keep_num" ]] && [[ "$ci_num" = "$keep_num" ]] && continue + [[ "$ci_state" = "OPEN" ]] || continue + if [[ "$dry" = true ]]; then echo " [DRY] would CLOSE cleared confirm issue #$ci_num for $agent"; else gh issue close "$ci_num" --repo "$ISSUE_REPO" \ --comment "✅ No longer awaiting confirmation for this candidate — \`$agent\` go/no-go is stale. Closed automatically by canary-rollout." >/dev/null 2>&1 || true echo " closed cleared confirm issue #$ci_num for $agent" fi - [ "$cf_link" = "—" ] && cf_link="#$ci_num (confirm closed)" + [[ "$cf_link" = "—" ]] && cf_link="#$ci_num (confirm closed)" done < <(_confirm_issues_for_agent "$agent") # Pass 2: one dashboard row per pending pair (a COMPLETE agent gets a single COMPLETE row). local rendered=0 disp_tr link while read -r cand frontier transition state _d _f _s _t cum_fail cum_startup _cb triage mix_shift downgrade dg_cand_rate dg_cand_sample dg_base_rate dg_base_sample datagap; do - [ -z "$state" ] && continue - disp_tr="$transition"; [ "$frontier" = "-" ] && disp_tr="-" + [[ -z "$state" ]] && continue + disp_tr="$transition"; [[ "$frontier" = "-" ]] && disp_tr="-" link="—" - [ "$state" = "BLOCKED" ] && [ "$transition" = "$bl_transition" ] && link="$bl_link" - [ "$state" = "AWAITING_CONFIRMATION" ] && [ "$transition" = "$cf_transition" ] && link="$cf_link" + [[ "$state" = "BLOCKED" ]] && [[ "$transition" = "$bl_transition" ]] && link="$bl_link" + [[ "$state" = "AWAITING_CONFIRMATION" ]] && [[ "$transition" = "$cf_transition" ]] && link="$cf_link" rows+="| \`$agent\` | $state | \`$disp_tr\` | ${cum_fail:-0} | ${triage:--} | $link |"$'\n' rendered=1 done <<< "$pairs_out" - [ "$rendered" -eq 0 ] && rows+="| \`$agent\` | COMPLETE | \`-\` | 0 | - | — |"$'\n' + [[ "$rendered" -eq 0 ]] && rows+="| \`$agent\` | COMPLETE | \`-\` | 0 | - | — |"$'\n' done <<< "$agents" # Render the fleet-status table into the run's job summary (a read-only snapshot, not a @@ -3169,7 +3169,7 @@ cmd_sync_issues() { local ts dmd ts="$(date -u +%Y-%m-%dT%H:%MZ 2>/dev/null || echo unknown)" dmd="$(_dashboard_md "${rows%$'\n'}" "$ts")" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then if printf '\n%s\n' "$dmd" >> "$GITHUB_STEP_SUMMARY"; then echo " wrote fleet-status table to the job summary" else @@ -3181,7 +3181,7 @@ cmd_sync_issues() { fi # Fail closed (#820): if any agent's state was totally undeterminable this tick, end non-zero # AFTER rendering the dashboard — a green no-op would mask a possible regression. - [ "$hard_fail" -eq 1 ] && return 1 + [[ "$hard_fail" -eq 1 ]] && return 1 return 0 } @@ -3198,7 +3198,7 @@ cmd_sync_issues() { # empty when the path is unset or the file is missing/empty. _promo_log_agents() { local f="$1" - [ -n "$f" ] && [ -s "$f" ] || return 0 + [[ -n "$f" ]] && [[ -s "$f" ]] || return 0 cut -f1 "$f" 2>/dev/null | awk 'NF && !seen[$0]++' } @@ -3206,14 +3206,14 @@ _promo_log_agents() { # (the most recent failure this run), empty if none. _promo_fail_latest() { local agent="$1" f="${CANARY_PROMOTIONS_FAILED_LOG:-}" - [ -n "$f" ] && [ -s "$f" ] || return 0 + [[ -n "$f" ]] && [[ -s "$f" ]] || return 0 awk -F'\t' -v a="$agent" '$1==a{ r=$2"\t"$3"\t"$4"\t"$5 } END{ if (r!="") print r }' "$f" } # _promo_fail_body _promo_fail_body() { local agent="$1" count="$2" threshold="$3" ring="$4" cand="$5" host="$6" reason="$7" escalated="$8" note - if [ "$escalated" = 1 ]; then + if [[ "$escalated" = 1 ]]; then note="> ⛔ **ESCALATED (needs-human).** This tag write has failed on **$count consecutive** scheduled runs (threshold $threshold) — it is not self-healing. Unlike a gate block (expected, timer-cleared), a failing WRITE means the move itself is rejected: check the release-manager App's ruleset bypass + token scopes for \`$ring\` on \`$host\`, then re-run \`promote $agent\`. This issue auto-closes on the next successful promotion." else note="> ℹ️ **Tracking a failed tag write.** The promotion move for \`$agent\` was rejected this run (not a gate block — the gate would hold *before* the write). Consecutive failures: **$count** of $threshold before escalation to \`needs-human\`. Auto-closes on the next successful promotion." @@ -3243,19 +3243,19 @@ EOF # and CANARY_PROMOTIONS_LOG (successes); an agent present in neither was not attempted this run # and is left untouched. Reads ISSUE_REPO (default THIS_REPO). cmd_sync_promotion_failures() { - local dry=false; [ "${1:-}" = "--dry-run" ] && dry=true + local dry=false; [[ "${1:-}" = "--dry-run" ]] && dry=true local threshold="${CANARY_PROMOTION_FAILURE_ESCALATE_AFTER:-2}" case "$threshold" in ''|*[!0-9]*) threshold=2 ;; esac - [ "$threshold" -lt 1 ] && threshold=1 + [[ "$threshold" -lt 1 ]] && threshold=1 echo "== canary-rollout sync-promotion-failures: repo=$ISSUE_REPO dry=$dry threshold=$threshold ==" local failed_agents ok_agents attempted failed_agents="$(_promo_log_agents "${CANARY_PROMOTIONS_FAILED_LOG:-}")" ok_agents="$(_promo_log_agents "${CANARY_PROMOTIONS_LOG:-}")" attempted="$(printf '%s\n%s\n' "$failed_agents" "$ok_agents" | awk 'NF && !seen[$0]++')" - if [ -z "$attempted" ]; then + if [[ -z "$attempted" ]]; then echo " no promotion attempts recorded this run — nothing to reconcile."; return 0 fi - if [ "$dry" != true ]; then + if [[ "$dry" != true ]]; then gh label create canary-promotion-failure --repo "$ISSUE_REPO" --color b60205 --description "canary-rollout: a promotion tag WRITE is failing (not a gate block)" >/dev/null 2>&1 || true gh label create dev-lead --repo "$ISSUE_REPO" --color 5319e7 --description "Route to the dev-lead agent for action" >/dev/null 2>&1 || true gh label create needs-human --repo "$ISSUE_REPO" --color d93f0b --description "Requires human judgement (canary regression)" >/dev/null 2>&1 || true @@ -3268,25 +3268,25 @@ cmd_sync_promotion_failures() { # A successful write cancels a failure only for the SAME ring: one run can now advance several # rings (#1118), so a ring0 success must not hide a ring1 failure for the same agent. local -A ok_ring=() - if [ -n "${CANARY_PROMOTIONS_LOG:-}" ] && [ -s "$CANARY_PROMOTIONS_LOG" ]; then + if [[ -n "${CANARY_PROMOTIONS_LOG:-}" ]] && [[ -s "$CANARY_PROMOTIONS_LOG" ]]; then local ok_a ok_r while IFS=$'\t' read -r ok_a ok_r _; do - [ -n "$ok_a" ] && ok_ring["$ok_a|$ok_r"]=1 + [[ -n "$ok_a" ]] && ok_ring["$ok_a|$ok_r"]=1 done < "$CANARY_PROMOTIONS_LOG" fi local -A promo_failures=() unresolved=() local _pf_agent _pf_num _pf_state _pf_count while IFS=$'\t' read -r _pf_agent _pf_num _pf_state _pf_count; do - [ -n "$_pf_agent" ] && promo_failures["$_pf_agent"]="${_pf_num}"$'\t'"${_pf_state}"$'\t'"${_pf_count}" + [[ -n "$_pf_agent" ]] && promo_failures["$_pf_agent"]="${_pf_num}"$'\t'"${_pf_state}"$'\t'"${_pf_count}" done < <(printf '%s\n' "$issues_json" | jq -r '(sort_by(.number) // []) | .[]? | select(.body != null) | (try (.body | capture("")) catch null) as $c | select($c != null) | ([.body | match("canary-promo-fail-count:([0-9]+)") | .captures[0].string] | first // "0") as $count | "\($c.agent)\t\(.number)\t\(.state | ascii_upcase)\t\($count)"' 2>/dev/null) # Parse the failure log once before the per-agent loop to avoid spawning awk on every iteration. local -A fail_ring=() fail_cand=() fail_host=() fail_reason=() - if [ -n "${CANARY_PROMOTIONS_FAILED_LOG:-}" ] && [ -s "$CANARY_PROMOTIONS_FAILED_LOG" ]; then + if [[ -n "${CANARY_PROMOTIONS_FAILED_LOG:-}" ]] && [[ -s "$CANARY_PROMOTIONS_FAILED_LOG" ]]; then local lf_agent lf_ring lf_cand lf_host lf_reason while IFS=$'\t' read -r lf_agent lf_ring lf_cand lf_host lf_reason; do - if [ -n "$lf_agent" ]; then + if [[ -n "$lf_agent" ]]; then # Superseded by a successful write of the same ring this run (a retry) → not a failure. - [ -n "${ok_ring["$lf_agent|$lf_ring"]:-}" ] && continue + [[ -n "${ok_ring["$lf_agent|$lf_ring"]:-}" ]] && continue unresolved["$lf_agent"]=1 fail_ring["$lf_agent"]="$lf_ring" fail_cand["$lf_agent"]="$lf_cand" @@ -3297,18 +3297,18 @@ cmd_sync_promotion_failures() { fi local agent while IFS= read -r agent; do - [ -z "$agent" ] && continue + [[ -z "$agent" ]] && continue local outcome="ok" - [ -n "${unresolved["$agent"]:-}" ] && outcome="failed" + [[ -n "${unresolved["$agent"]:-}" ]] && outcome="failed" local ns num istate prior ns="${promo_failures["$agent"]:-}" num="$(printf '%s' "$ns" | cut -f1)"; istate="$(printf '%s' "$ns" | cut -f2)"; prior="$(printf '%s' "$ns" | cut -f3)" - [ -z "$prior" ] && prior=0 + [[ -z "$prior" ]] && prior=0 # A CLOSED tracking issue means the streak already ended; its stale count must not seed the # next streak or one transient failure would escalate on its first occurrence after recovery. - [ "$istate" = "CLOSED" ] && prior=0 + [[ "$istate" = "CLOSED" ]] && prior=0 local count; count="$(promotion_failure_next_count "$prior" "$outcome")" - if [ "$outcome" = "failed" ]; then + if [[ "$outcome" = "failed" ]]; then local ring cand host reason escalate body title ring="${fail_ring["$agent"]:-?}" cand="${fail_cand["$agent"]:-}" @@ -3316,32 +3316,32 @@ cmd_sync_promotion_failures() { reason="${fail_reason["$agent"]:-tag write rejected}" escalate="$(promotion_failure_should_escalate "$count" "$threshold")" body="$(_promo_fail_body "$agent" "$count" "$threshold" "$ring" "$cand" "$host" "$reason" "$escalate")" - if [ "$escalate" = 1 ]; then + if [[ "$escalate" = 1 ]]; then title="Canary promotion FAILING: $agent (${count}× consecutive tag-write rejection)" else title="Canary promotion tag-write failed: $agent (${count}× consecutive)" fi - if [ -z "$num" ]; then - if [ "$dry" = true ]; then echo " [DRY] would OPEN promotion-failure issue for $agent (count=$count, escalate=$escalate)"; else + if [[ -z "$num" ]]; then + if [[ "$dry" = true ]]; then echo " [DRY] would OPEN promotion-failure issue for $agent (count=$count, escalate=$escalate)"; else num="$(_gh_issue_create "$title" "$body" "canary-promotion-failure" || true)" - if [ -n "$num" ]; then - [ "$escalate" = 1 ] && gh issue edit "$num" --repo "$ISSUE_REPO" --add-label dev-lead --add-label needs-human >/dev/null 2>&1 || true + if [[ -n "$num" ]]; then + [[ "$escalate" = 1 ]] && gh issue edit "$num" --repo "$ISSUE_REPO" --add-label dev-lead --add-label needs-human >/dev/null 2>&1 || true echo " opened promotion-failure issue #$num for $agent (count=$count)" else echo "::warning::could not open promotion-failure issue for $agent (Issues:write on the App?)"; fi fi else - if [ "$dry" = true ]; then echo " [DRY] would UPDATE promotion-failure issue #$num for $agent (count=$count, escalate=$escalate)"; else - [ "$istate" = "OPEN" ] || gh issue reopen "$num" --repo "$ISSUE_REPO" >/dev/null 2>&1 || true + if [[ "$dry" = true ]]; then echo " [DRY] would UPDATE promotion-failure issue #$num for $agent (count=$count, escalate=$escalate)"; else + [[ "$istate" = "OPEN" ]] || gh issue reopen "$num" --repo "$ISSUE_REPO" >/dev/null 2>&1 || true gh issue edit "$num" --repo "$ISSUE_REPO" --title "$title" --body "$body" >/dev/null 2>&1 \ || echo "::warning::could not update promotion-failure issue #$num for $agent" - [ "$escalate" = 1 ] && gh issue edit "$num" --repo "$ISSUE_REPO" --add-label dev-lead --add-label needs-human >/dev/null 2>&1 || true + [[ "$escalate" = 1 ]] && gh issue edit "$num" --repo "$ISSUE_REPO" --add-label dev-lead --add-label needs-human >/dev/null 2>&1 || true echo " updated promotion-failure issue #$num for $agent (count=$count)" fi fi else # A successful write this run resets the streak → close any open tracking issue. - if [ -n "$num" ] && [ "$istate" = "OPEN" ]; then - if [ "$dry" = true ]; then echo " [DRY] would CLOSE recovered promotion-failure issue #$num for $agent"; else + if [[ -n "$num" ]] && [[ "$istate" = "OPEN" ]]; then + if [[ "$dry" = true ]]; then echo " [DRY] would CLOSE recovered promotion-failure issue #$num for $agent"; else gh issue close "$num" --repo "$ISSUE_REPO" \ --comment "✅ Promotion succeeded — \`$agent\` tag write recovered. Closed automatically by canary-rollout." >/dev/null 2>&1 || true echo " closed recovered promotion-failure issue #$num for $agent" @@ -3403,8 +3403,8 @@ _watched_paths() { else "none" end' 2>/dev/null || echo none)" extra="$(_jq -r --arg a "$agent" \ '(.agents[$a].agent_ref_paths // .agent_ref_paths // []) | .[]?' 2>/dev/null || true)" - [ -z "$extra" ] && [ "$declared" = "none" ] && extra="$_DEFAULT_AGENT_REF_PATHS" - { [ -n "$reusable" ] && printf '%s\n' "$reusable"; printf '%s\n' "$extra"; } \ + [[ -z "$extra" ]] && [[ "$declared" = "none" ]] && extra="$_DEFAULT_AGENT_REF_PATHS" + { [[ -n "$reusable" ]] && printf '%s\n' "$reusable"; printf '%s\n' "$extra"; } \ | awk 'NF && !seen[$0]++' } @@ -3424,12 +3424,12 @@ _watched_paths() { # "unknown", not "unchanged"). _gh_changed_files() { local repo="$1" base="$2" head="$3" json truncated files - { [ -z "$base" ] || [ -z "$head" ]; } && { echo ""; return 0; } + { [[ -z "$base" ]] || [[ -z "$head" ]]; } && { echo ""; return 0; } json="$(gh api "repos/$repo/compare/$base...$head" 2>/dev/null)" || { echo ""; return 0; } - [ -z "$json" ] && { echo ""; return 0; } + [[ -z "$json" ]] && { echo ""; return 0; } truncated="$(jq -r '.truncated // false' <<< "$json" 2>/dev/null || echo false)" - if [ "$truncated" != "true" ]; then + if [[ "$truncated" != "true" ]]; then jq -r '.files[]?.filename // empty' <<< "$json" 2>/dev/null || echo "" return 0 fi @@ -3440,9 +3440,9 @@ _gh_changed_files() { files="$(jq -r '.files[]?.filename // empty' <<< "$json" 2>/dev/null || echo "")" local sha commit_json while IFS= read -r sha; do - [ -z "$sha" ] && continue + [[ -z "$sha" ]] && continue commit_json="$(gh api "repos/$repo/commits/$sha" 2>/dev/null)" || continue - [ -z "$commit_json" ] && continue + [[ -z "$commit_json" ]] && continue files+=$'\n'"$(jq -r '.files[]?.filename // empty' <<< "$commit_json" 2>/dev/null || echo "")" done < <(jq -r '.commits[]?.sha // empty' <<< "$json" 2>/dev/null) @@ -3455,7 +3455,7 @@ _gh_changed_files() { _host_release_versions() { local agent="$1" host host="$(_agent_field "$agent" host)" - [ -z "$host" ] && return 0 + [[ -z "$host" ]] && return 0 gh api "repos/$host/git/matching-refs/tags/$agent/v" --jq '.[]?.ref' 2>/dev/null \ | sed -n "s#^refs/tags/${agent}/v##p" || true } @@ -3518,10 +3518,10 @@ _autocut_commit_signals() { local path page json acc="[]" truncated=0 path_done local since; since="$(_commit_date "$host" "$next_commit")" while IFS= read -r path; do - [ -z "$path" ] && continue + [[ -z "$path" ]] && continue page=1; path_done=0 - while [ "$page" -le "$CANARY_MAX_COMMIT_PAGES" ]; do - local since_args=(); [ -n "$since" ] && since_args=(-f "since=$since") + while [[ "$page" -le "$CANARY_MAX_COMMIT_PAGES" ]]; do + local since_args=(); [[ -n "$since" ]] && since_args=(-f "since=$since") json="$(gh api --method GET "repos/$host/commits" -f path="$path" -f sha="$mainsha" "${since_args[@]}" -F per_page=100 -F page="$page" 2>/dev/null)" || return 1 # Parse the page and extract found, count, and pre-boundary messages in a single jq invocation local parsed found count pre rest @@ -3537,17 +3537,17 @@ _autocut_commit_signals() { commit_page_done "$found" "${count:-0}" 100 || { path_done=1; break; } page=$((page + 1)) done - [ "$path_done" -eq 1 ] || truncated=1 + [[ "$path_done" -eq 1 ]] || truncated=1 done <<< "$(_autocut_signal_paths "$agent")" - [ "$truncated" -eq 1 ] && return 3 - [ "$acc" = "[]" ] && return 2 + [[ "$truncated" -eq 1 ]] && return 3 + [[ "$acc" = "[]" ]] && return 2 local out out="$(printf '%s\n' "$acc" | jq -r ' { b: any(.[]?; (. // "") | test("^[\\w-]+(\\([^)]*\\))?!:") or test("(^|\\n)BREAKING[ -]CHANGE:")), f: any(.[]?; (. // "") | test("^feat(\\([^)]*\\))?:")) } | "\(if .b then 1 else 0 end) \(if .f then 1 else 0 end)" ' 2>/dev/null)" || return 1 - [ -z "$out" ] && return 1 + [[ -z "$out" ]] && return 1 printf '%s\n' "$out" } @@ -3560,9 +3560,9 @@ _autocut_commit_signals() { # on any fetch/parse error so the caller stays fail-safe to patch (never auto-major on missing data). _autocut_range_signals() { local host="$1" base="$2" head="$3" json out - { [ -z "$base" ] || [ -z "$head" ]; } && return 1 + { [[ -z "$base" ]] || [[ -z "$head" ]]; } && return 1 json="$(gh api "repos/$host/compare/$base...$head" 2>/dev/null)" || return 1 - [ -z "$json" ] && return 1 + [[ -z "$json" ]] && return 1 out="$(jq -r ' if (.commits | type) != "array" then error("no commits array") else . end | (.commits | map(.commit.message // "")) as $msgs @@ -3572,7 +3572,7 @@ _autocut_range_signals() { } | "\(if .b then 1 else 0 end) \(if .f then 1 else 0 end)" ' <<< "$json" 2>/dev/null)" || return 1 - [ -z "$out" ] && return 1 + [[ -z "$out" ]] && return 1 printf '%s\n' "$out" } @@ -3581,7 +3581,7 @@ _autocut_range_signals() { _gh_file_content() { local raw raw="$(gh api "repos/$1/contents/$2?ref=$3" --jq '.content // empty' 2>/dev/null)" || return 1 - [ -z "$raw" ] && return 1 + [[ -z "$raw" ]] && return 1 printf '%s' "$raw" | tr -d '\n' | base64 -d 2>/dev/null || return 1 } @@ -3608,22 +3608,22 @@ _autocut_iface_break() { _autocut_detect_bump() { local agent="$1" host="$2" reusable="$3" next_commit="$4" mainsha="$5" local override; override="$(_autocut_bump_override "$agent")" - if [ -n "$override" ]; then + if [[ -n "$override" ]]; then echo "::notice::autocut $agent: bump=$override (registry override .agents[$agent].autocut.bump)" >&2 echo "$override"; return 0 fi local breaking=0 feat=0 driver="" sigs iface rc=0 sigs="$(_autocut_commit_signals "$agent" "$host" "$next_commit" "$mainsha")" || rc=$? - if [ "$rc" -eq 0 ]; then + if [[ "$rc" -eq 0 ]]; then read -r breaking feat <<< "$sigs" - elif [ "$rc" -eq 3 ]; then + elif [[ "$rc" -eq 3 ]]; then # Unresolvable range (#1023 defect 1b): the range could not be fully enumerated, so a # breaking change may hide beyond the cap. FAIL SAFE TO MAJOR, loudly — never silently to # patch, the more dangerous direction (a breaking change shipped as a patch breaks pinned # consumers with no signal). A false major is at worst a spurious fresh v-line + this warning. echo "::warning::autocut $agent: commit range ${next_commit:0:12}..${mainsha:0:12} on $host could not be fully enumerated within $CANARY_MAX_COMMIT_PAGES pages — cannot rule out a breaking change; failing safe to bump=major (not patch). Investigate the range." >&2 breaking=1; feat=0; driver="unresolvable commit range (fail-safe major)" - elif [ "$rc" -eq 2 ] && sigs="$(_autocut_range_signals "$host" "$next_commit" "$mainsha")"; then + elif [[ "$rc" -eq 2 ]] && sigs="$(_autocut_range_signals "$host" "$next_commit" "$mainsha")"; then # Reusable-path-scoped signals unavailable (a script-only change touches no reusable commit, # so the boundary scan finds nothing — rc=2): fall back to the compare-range commit messages # so a script-only feat/breaking still bumps correctly (#1019). A fetch error (rc=1) falls @@ -3633,14 +3633,14 @@ _autocut_detect_bump() { echo "::notice::autocut $agent: commit-signal fetch failed — bump=patch (fail-safe)" >&2 echo "patch"; return 0 fi - [ "$breaking" = 1 ] && [ -z "$driver" ] && driver="conventional-commit breaking change" + [[ "$breaking" = 1 ]] && [[ -z "$driver" ]] && driver="conventional-commit breaking change" # An interface break escalates to major; a fetch error here is non-fatal (keep commit signals) # and never invents a major from missing data. if iface="$(_autocut_iface_break "$host" "$reusable" "$next_commit" "$mainsha")"; then - if [ "$iface" = 1 ]; then breaking=1; driver="workflow_call interface break"; fi + if [[ "$iface" = 1 ]]; then breaking=1; driver="workflow_call interface break"; fi fi local bump; bump="$(decide_bump "$breaking" "$feat" "")" - if [ -z "$driver" ]; then + if [[ -z "$driver" ]]; then case "$bump" in minor) driver="feat commit" ;; *) driver="no breaking/feat signal" ;; @@ -3657,7 +3657,7 @@ _next_release_version() { versions="$(_host_release_versions "$agent")" # shellcheck disable=SC2086 highest="$(max_semver $versions)" - [ -z "$highest" ] && highest="0.0.0" + [[ -z "$highest" ]] && highest="0.0.0" bump_version "$highest" "$bump" } @@ -3670,24 +3670,24 @@ _autocut_agent() { local host reusable defbranch mainsha next_commit main_blob next_blob bump newver host="$(_agent_field "$agent" host)" reusable="$(_agent_field "$agent" reusable)" - if [ -z "$host" ] || [ -z "$reusable" ]; then + if [[ -z "$host" ]] || [[ -z "$reusable" ]]; then echo "::warning::autocut $agent: missing host/reusable in registry — skipping"; return 0 fi - defbranch="$(_gh_default_branch "$host")"; [ -z "$defbranch" ] && defbranch="main" + defbranch="$(_gh_default_branch "$host")"; [[ -z "$defbranch" ]] && defbranch="main" mainsha="$(_gh_head_sha "$host" "$defbranch")" - if [ -z "$mainsha" ]; then + if [[ -z "$mainsha" ]]; then echo "::warning::autocut $agent: could not resolve $host $defbranch HEAD — skipping"; return 0 fi main_blob="$(_gh_blob_sha "$host" "$reusable" "$mainsha")" - if [ -z "$main_blob" ]; then + if [[ -z "$main_blob" ]]; then echo "::warning::autocut $agent: reusable '$reusable' not found at $host@${mainsha:0:12} — skipping"; return 0 fi local next_resolved_tag IFS=$'\t' read -r next_resolved_tag next_commit < <(_resolved_channel "$agent" next) next_blob="" - [ -n "$next_commit" ] && next_blob="$(_gh_blob_sha "$host" "$reusable" "$next_commit")" + [[ -n "$next_commit" ]] && next_blob="$(_gh_blob_sha "$host" "$reusable" "$next_commit")" # Fast no-op: main HEAD already IS the current candidate — nothing merged since the last cut. - if [ "$mainsha" = "$next_commit" ]; then + if [[ "$mainsha" = "$next_commit" ]]; then echo "autocut $agent: reusable unchanged on $host (next candidate up to date) — no cut." return 0 fi @@ -3698,22 +3698,22 @@ _autocut_agent() { # fast path, kept byte-identical for a reusable change) OR any other registry-derived watched # path (via the compare API). local reusable_changed=0 - if [ -z "$next_commit" ] || [ -z "$next_blob" ]; then + if [[ -z "$next_commit" ]] || [[ -z "$next_blob" ]]; then # Cannot compare the reusable blob (no candidate yet, or reusable absent at the candidate): # fail OPEN so a first cut / an unresolved candidate still seeds the pipeline (prior behaviour). reusable_changed=1 - elif [ "$main_blob" != "$next_blob" ]; then + elif [[ "$main_blob" != "$next_blob" ]]; then reusable_changed=1 fi local watched_changed=0 watched_hitlist="" - if [ "$reusable_changed" -eq 0 ] && [ -n "$next_commit" ]; then + if [[ "$reusable_changed" -eq 0 ]] && [[ -n "$next_commit" ]]; then local watched changed_files watched="$(_watched_paths "$agent")" changed_files="$(_gh_changed_files "$host" "$next_commit" "$mainsha")" watched_hitlist="$(watched_hits "$changed_files" "$watched")" - [ -n "$watched_hitlist" ] && watched_changed=1 + [[ -n "$watched_hitlist" ]] && watched_changed=1 fi - if [ "$reusable_changed" -eq 0 ] && [ "$watched_changed" -eq 0 ]; then + if [[ "$reusable_changed" -eq 0 ]] && [[ "$watched_changed" -eq 0 ]]; then echo "autocut $agent: no agent_ref-consumed path changed on $host (reusable + scripts/prompts/personas up to date) — no cut." return 0 fi @@ -3725,14 +3725,14 @@ _autocut_agent() { # major's `v-next` — falling back to the legacy bare `/next` on today's bare-tier # fleet where no v-line exists yet, so the move stays byte-identical until F5 migrates tags. local next_tag newmajor; newmajor="$(major_component "$newver")" - if [ "$bump" = major ]; then + if [[ "$bump" = major ]]; then next_tag="$(channel_tag "$agent" next "$newmajor")" else next_tag="$next_resolved_tag" fi echo "autocut $agent: reusable changed on $host ($defbranch ${mainsha:0:12}) vs next ${next_commit:0:12} — cutting v$newver (bump=$bump), moving $next_tag." local relver="$agent/v$newver" - if [ "$dry" = true ]; then + if [[ "$dry" = true ]]; then echo "[DRY-RUN] would: cut $relver at ${mainsha:0:12} on $host + move $next_tag (gh-api, App token)" return 0 fi @@ -3744,8 +3744,8 @@ _autocut_agent() { # next move entirely rather than advancing next to an untagged commit. local existing_sha existing_sha="$(_gh_tag_commit "$host" "$relver")" - if [ -n "$existing_sha" ]; then - if [ "$existing_sha" != "$mainsha" ]; then + if [[ -n "$existing_sha" ]]; then + if [[ "$existing_sha" != "$mainsha" ]]; then echo "::warning::autocut $agent: release $relver on $host points to ${existing_sha:0:12}, not ${mainsha:0:12} — skipping next move to preserve invariant." return 0 fi @@ -3764,17 +3764,17 @@ _autocut_agent() { # BEFORE promote-all so a freshly cut candidate begins soaking the same tick (dwell=0 < floor # → it SOAKS, does not promote). cmd_autocut() { - local dry=false; [ "${1:-}" = "--dry-run" ] && dry=true - if [ "${CANARY_AUTO_CUT:-}" != "true" ]; then + local dry=false; [[ "${1:-}" = "--dry-run" ]] && dry=true + if [[ "${CANARY_AUTO_CUT:-}" != "true" ]]; then echo "== canary-rollout autocut: DISABLED (CANARY_AUTO_CUT != 'true') — no-op. ==" return 0 fi local agents agent agents="$(_jq -r '.agents? | keys[]?' 2>/dev/null || true)" - [ -z "$agents" ] && { echo "no agents registered in $CANARY_RINGS — nothing to autocut."; return 0; } + [[ -z "$agents" ]] && { echo "no agents registered in $CANARY_RINGS — nothing to autocut."; return 0; } echo "== canary-rollout autocut: fleet-wide dry=$dry (gate standard: .github#548) ==" while IFS= read -r agent; do - [ -z "$agent" ] && continue + [[ -z "$agent" ]] && continue echo "──────── agent: $agent ────────" _autocut_agent "$agent" "$dry" || echo "::warning::autocut of $agent failed (continuing fleet)" done <<< "$agents" @@ -3828,7 +3828,7 @@ _gh_list_reusables() { json="$(gh api "repos/$repo/contents/.github/workflows" 2>/dev/null)" || return 1 # An empty (but exit-0) body is not a readable listing — treat it as an enumeration # failure so the caller skips the host rather than false-flagging every reusable gone. - [ -n "$json" ] || return 1 + [[ -n "$json" ]] || return 1 jq -e 'type=="array"' >/dev/null 2>&1 <<< "$json" || return 1 # The array type is validated above, so iterate with `.[]` (not `.[]?`): the optional # operator would silently swallow an iteration error on a malformed element instead of @@ -3918,9 +3918,9 @@ _channel_tag_major_gaps() { _agent_has_channel_major "$agent" "$major" || return 0 channels="$(ordered_channels "$agent")" for tier in ${channels//,/ }; do - [ -z "$tier" ] && continue - [ -n "$(_channel_tag_commit "$agent" "$tier")" ] || continue # no bare tier tag → nothing to pair - [ -z "$(_channel_tag_commit "$agent" "v${major}-${tier}")" ] && printf '%s\n' "$tier" + [[ -z "$tier" ]] && continue + [[ -n "$(_channel_tag_commit "$agent" "$tier")" ]] || continue # no bare tier tag → nothing to pair + [[ -z "$(_channel_tag_commit "$agent" "v${major}-${tier}")" ]] && printf '%s\n' "$tier" done } @@ -3929,7 +3929,7 @@ _channel_tag_major_gaps() { # prints a scaffold .agents[] block per unregistered reusable. cmd_drift() { local emit_stub=false - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in --emit-stub) emit_stub=true ;; *) echo "::error::unknown drift flag: $1" >&2; return 2 ;; @@ -3938,11 +3938,11 @@ cmd_drift() { echo "== canary-rollout drift: registry vs host reusables (read-only; gate standard: .github#548) ==" local hosts host rows="" stubs="" u_total=0 m_total=0 hosts="$(_registered_hosts)" - if [ -z "$hosts" ]; then + if [[ -z "$hosts" ]]; then echo "no host repos resolved from $CANARY_RINGS — nothing to audit."; return 0 fi while IFS= read -r host; do - [ -z "$host" ] && continue + [[ -z "$host" ]] && continue echo "──────── host: $host ────────" local present registered unmanaged_r unregistered missing p agents registered="$(_registered_reusables_for_host "$host")" @@ -3958,23 +3958,23 @@ cmd_drift() { fi unmanaged_r="$(_unmanaged_reusables_for_host "$host")" local reg_arr=() pres_arr=() um_arr=() reg_str="" pres_str="" um_str="" - if [ -n "$registered" ]; then mapfile -t reg_arr <<< "$registered"; fi - if [ -n "$present" ]; then mapfile -t pres_arr <<< "$present"; fi - if [ -n "$unmanaged_r" ]; then mapfile -t um_arr <<< "$unmanaged_r"; fi - [ "${#reg_arr[@]}" -gt 0 ] && reg_str=" ${reg_arr[*]}" - [ "${#pres_arr[@]}" -gt 0 ] && pres_str=" ${pres_arr[*]}" - [ "${#um_arr[@]}" -gt 0 ] && um_str=" ${um_arr[*]}" + if [[ -n "$registered" ]]; then mapfile -t reg_arr <<< "$registered"; fi + if [[ -n "$present" ]]; then mapfile -t pres_arr <<< "$present"; fi + if [[ -n "$unmanaged_r" ]]; then mapfile -t um_arr <<< "$unmanaged_r"; fi + [[ "${#reg_arr[@]}" -gt 0 ]] && reg_str=" ${reg_arr[*]}" + [[ "${#pres_arr[@]}" -gt 0 ]] && pres_str=" ${pres_arr[*]}" + [[ "${#um_arr[@]}" -gt 0 ]] && um_str=" ${um_arr[*]}" echo " registered reusables (${#reg_arr[@]}):$reg_str" echo " present reusables (${#pres_arr[@]}):$pres_str" - [ "${#um_arr[@]}" -gt 0 ] && echo " unmanaged (intentional, out of ring gate) (${#um_arr[@]}):$um_str" + [[ "${#um_arr[@]}" -gt 0 ]] && echo " unmanaged (intentional, out of ring gate) (${#um_arr[@]}):$um_str" # unregistered = present on the host, minus registered agents AND intentionally-unmanaged (#651). unregistered="$(set_difference "$(set_difference "$present" "$registered")" "$unmanaged_r")" while IFS= read -r p; do - [ -z "$p" ] && continue + [[ -z "$p" ]] && continue echo "::warning::DRIFT[unregistered] $host: $p present on host but absent from canary-rings.json (no cut/soak/gate/dashboard until registered)" rows+="| \`$host\` | unregistered | \`$p\` | not in \`.agents{}\` — register it or delete the file |"$'\n' u_total=$((u_total + 1)) - if [ "$emit_stub" = true ]; then stubs+="$(_drift_scaffold "$host" "$p")"$'\n'; fi + if [[ "$emit_stub" = true ]]; then stubs+="$(_drift_scaffold "$host" "$p")"$'\n'; fi done <<< "$unregistered" # missing-file = registered in the registry but the file is gone from the host. A registered reusable # that keeps a legacy non-`-reusable.yml` name (the grandfathered pr-review.yml engine, #1166) is @@ -3982,7 +3982,7 @@ cmd_drift() { # correctly not flagged as missing. missing="$(set_difference "$registered" "$present")" while IFS= read -r p; do - [ -z "$p" ] && continue + [[ -z "$p" ]] && continue agents="$(_agents_for_reusable "$host" "$p")" echo "::warning::DRIFT[missing-file] $host: registry agent '${agents:-?}' -> $p not found on host (deleted/renamed reusable; stale registry entry)" rows+="| \`$host\` | missing-file | \`$p\` | registry agent \`${agents:-?}\` points at a file that no longer exists |"$'\n' @@ -3993,10 +3993,10 @@ cmd_drift() { echo "----" local total=$((u_total + m_total)) echo "drift summary: $u_total unregistered, $m_total missing-file ($total total findings)" - if [ "$total" -eq 0 ]; then + if [[ "$total" -eq 0 ]]; then echo "no reusable drift detected — the registry and host reusables are in sync." fi - if [ "$emit_stub" = true ] && [ -n "$stubs" ]; then + if [[ "$emit_stub" = true ]] && [[ -n "$stubs" ]]; then echo "---- scaffold .agents[] stubs for unregistered reusables (--emit-stub) ----" echo "Paste into standards/canary-rings.json under .agents, then set run_workflow + review ring members:" printf '%s' "$stubs" @@ -4006,12 +4006,12 @@ cmd_drift() { # back to stdout when GITHUB_STEP_SUMMARY is unset (local/manual runs). local ts dmd ts="$(date -u +%Y-%m-%dT%H:%MZ 2>/dev/null || echo unknown)" - if [ "$total" -eq 0 ]; then + if [[ "$total" -eq 0 ]]; then dmd="$(printf '# Canary Rollout — reusable drift\n\nLast updated: `%s` · gate standard: .github#548.\n\n_No reusable drift — the registry and host `*-reusable.yml` are in sync._\n' "$ts")" else dmd="$(printf '# Canary Rollout — reusable drift\n\nLast updated: `%s` · gate standard: .github#548 · findings: **%s** (%s unregistered, %s missing-file).\n\n| host | class | reusable | note |\n|---|---|---|---|\n%s\n> `unregistered` ships with ZERO staged rollout until added to `.agents{}`; `missing-file` is a stale registry entry pointing at a deleted reusable. Report-only — no auto-registration.\n' "$ts" "$total" "$u_total" "$m_total" "${rows%$'\n'}")" fi - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then if printf '\n%s\n' "$dmd" >> "$GITHUB_STEP_SUMMARY"; then echo " wrote reusable-drift table to the job summary" else @@ -4030,36 +4030,36 @@ cmd_drift() { local agents a unshipped_total=0 ship_rows="" agents="$(_jq -r '.agents? | keys[]?' 2>/dev/null || true)" while IFS= read -r a; do - [ -z "$a" ] && continue + [[ -z "$a" ]] && continue local a_host a_defbranch a_main a_next hits hitcsv h changed_files watched a_host="$(_agent_field "$a" host)"; a_host="${a_host:-$THIS_REPO}" a_next="$(channel_commit "$a" next)" - a_defbranch="$(_gh_default_branch "$a_host")"; [ -z "$a_defbranch" ] && a_defbranch="main" + a_defbranch="$(_gh_default_branch "$a_host")"; [[ -z "$a_defbranch" ]] && a_defbranch="main" a_main="$(_gh_head_sha "$a_host" "$a_defbranch")" - if [ -z "$a_next" ] || [ -z "$a_main" ]; then + if [[ -z "$a_next" ]] || [[ -z "$a_main" ]]; then echo " $a: could not resolve next/main HEAD on $a_host — skipping" continue fi - if [ "$a_next" = "$a_main" ]; then + if [[ "$a_next" = "$a_main" ]]; then echo " $a: shipped — next (${a_next:0:12}) == $a_host main HEAD" continue fi watched="$(_watched_paths "$a")" changed_files="$(_gh_changed_files "$a_host" "$a_next" "$a_main")" hits="$(watched_hits "$changed_files" "$watched")" - if [ -z "$hits" ]; then + if [[ -z "$hits" ]]; then echo " $a: shipped — next differs from main but no agent_ref-consumed path changed" continue fi hitcsv="" - while IFS= read -r h; do [ -z "$h" ] && continue; hitcsv="${hitcsv:+$hitcsv, }$h"; done <<< "$hits" + while IFS= read -r h; do [[ -z "$h" ]] && continue; hitcsv="${hitcsv:+$hitcsv, }$h"; done <<< "$hits" echo "::warning::DRIFT[unshipped] $a: $a_host main (${a_main:0:12}) has agent_ref changes not on next (${a_next:0:12}): $hitcsv" ship_rows+="| \`$a\` | \`$a_host\` | \`$hitcsv\` | merged to main but not on the \`next\` channel (autocut should cut) |"$'\n' unshipped_total=$((unshipped_total + 1)) done <<< "$agents" echo "ship-drift summary: $unshipped_total agent(s) with merged-but-unshipped agent_ref changes" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ "$unshipped_total" -gt 0 ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && [[ "$unshipped_total" -gt 0 ]]; then local smd smd="$(printf '# Canary Rollout — ship-drift (merged but not shipped)\n\nLast updated: `%s` · %s agent(s) whose `next` channel lags host main across agent_ref-consumed paths.\n\n| agent | host | changed paths | note |\n|---|---|---|---|\n%s\n> autocut (#1069) should cut a new candidate on the next tick; a persistent row means autocut is disabled or blocked.\n' "$ts" "$unshipped_total" "${ship_rows%$'\n'}")" printf '\n%s\n' "$smd" >> "$GITHUB_STEP_SUMMARY" \ @@ -4078,12 +4078,12 @@ cmd_drift() { echo "== channel-tag drift: bare tier tag without its v- counterpart (#1065) ==" local ct_agent ct_major ct_gaps ct_tier ct_total=0 ct_rows="" while IFS= read -r ct_agent; do - [ -z "$ct_agent" ] && continue + [[ -z "$ct_agent" ]] && continue ct_major="$(_agent_current_channel_major "$ct_agent")" ct_gaps="$(_channel_tag_major_gaps "$ct_agent")" - [ -z "$ct_gaps" ] && continue + [[ -z "$ct_gaps" ]] && continue for ct_tier in $ct_gaps; do - [ -z "$ct_tier" ] && continue + [[ -z "$ct_tier" ]] && continue echo "::warning::DRIFT[channel-tag] $ct_agent: has /$ct_tier but NO v${ct_major}-$ct_tier (a stub deploy to this tier would pin a nonexistent @$ct_agent/v${ct_major}-$ct_tier)" ct_rows+="| \`$ct_agent\` | \`$ct_tier\` | \`$ct_agent/v${ct_major}-$ct_tier\` | bare tier tag exists but its v-scoped counterpart is missing — a stub deploy pins a nonexistent ref (maintainer backfill) |"$'\n' ct_total=$((ct_total + 1)) @@ -4091,7 +4091,7 @@ cmd_drift() { done <<< "$agents" echo "channel-tag drift summary: $ct_total tier(s) missing a v- counterpart" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ "$ct_total" -gt 0 ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && [[ "$ct_total" -gt 0 ]]; then local ctmd ctmd="$(printf '# Canary Rollout — channel-tag drift (missing v-)\n\nLast updated: `%s` · %s tier(s) with a bare channel tag lacking its v-scoped counterpart.\n\n| agent | tier | missing ref | note |\n|---|---|---|---|\n%s\n> A ring-promotion bootstrap gap (#1065). A stub deploy keyed on the channel major pins `/v-`; if it does not exist the fleet fails at startup. A maintainer backfills the tag at its bare counterpart'"'"'s commit.\n' "$ts" "$ct_total" "${ct_rows%$'\n'}")" printf '\n%s\n' "$ctmd" >> "$GITHUB_STEP_SUMMARY" \ @@ -4115,7 +4115,7 @@ cmd_drift() { infra_repos="$(_jq -r '(.org_infra_repos // []) | .[]')" reserved="$(_reserved_tag_namespaces)" registered_agents="$(_jq -r '.agents? | keys[]?' 2>/dev/null || true)" - if [ -z "$registered_agents" ]; then + if [[ -z "$registered_agents" ]]; then # An unreadable/empty registry would flag EVERY channel-tagged agent as unregistered (false # positives) — skip the sweep and mark it incomplete rather than report noise. echo "::warning::registry completeness: .agents{} is empty or unreadable — skipping the sweep" @@ -4124,22 +4124,22 @@ cmd_drift() { infra_repos="" fi while IFS= read -r ir; do - [ -z "$ir" ] && continue + [[ -z "$ir" ]] && continue local tag_agents ta attempt attempt=0 # Bounded retries for the tag enumeration: transient API failures should not silently miss # completeness gaps. After exhaustion, skip the repo but track that completeness was incomplete. - while [ "$attempt" -lt "$max_attempts" ]; do + while [[ "$attempt" -lt "$max_attempts" ]]; do if tag_agents="$(_gh_list_channel_tag_agents "$ir")"; then break fi attempt=$((attempt + 1)) - if [ "$attempt" -lt "$max_attempts" ]; then + if [[ "$attempt" -lt "$max_attempts" ]]; then echo "::notice::completeness $ir: tag enumeration failed (attempt $attempt/$max_attempts), retrying..." sleep 2 fi done - if [ "$attempt" -eq "$max_attempts" ]; then + if [[ "$attempt" -eq "$max_attempts" ]]; then # Could not read the repo's tags after retries — skip it rather than mistake an outage for "no channel # tags" (which would silently miss a real completeness gap). Read-only + best-effort, but track incomplete. echo "::warning::completeness $ir: could not enumerate channel tags after $max_attempts attempts (API error / no access) — skipping repo this cycle" @@ -4147,7 +4147,7 @@ cmd_drift() { continue fi while IFS= read -r ta; do - [ -z "$ta" ] && continue + [[ -z "$ta" ]] && continue grep -qxF "$ta" <<< "$registered_agents" && continue # registered → managed, fine grep -qxF "$ta" <<< "$reserved" && continue # reserved non-agent namespace grep -qxF "$ta" <<< "$rc_seen" && continue # already reported (tags on both repos) @@ -4157,23 +4157,23 @@ cmd_drift() { rc_total=$((rc_total + 1)) done <<< "$tag_agents" done <<< "$infra_repos" - if [ "$rc_incomplete" -eq 1 ]; then + if [[ "$rc_incomplete" -eq 1 ]]; then echo "registry-completeness summary: $rc_total agent(s) missing from registry; completeness check was INCOMPLETE (some repos could not be enumerated)" else echo "registry-completeness summary: $rc_total channel-tag agent(s) missing from the registry" fi - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - if [ "$rc_total" -gt 0 ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + if [[ "$rc_total" -gt 0 ]]; then local rcmd rcmd="$(printf '# Canary Rollout — registry completeness (channel-tagged but unregistered)\n\nLast updated: `%s` · %s channel-tag agent(s) with `/v*-` tags on an infra repo but no `.agents{}` entry.\n\n| agent | host | note |\n|---|---|---|\n%s\n> Registry self-consistency is not completeness (#1106). A channel-tagged agent absent from the registry ships with ZERO staged rollout — no cut/soak/gate/dashboard — exactly how pr-review ran an 82-day-old build unnoticed. Register it, or add a genuine non-agent namespace to `reserved_tag_namespaces`.\n' "$ts" "$rc_total" "${rc_rows%$'\n'}")" printf '\n%s\n' "$rcmd" >> "$GITHUB_STEP_SUMMARY" \ || echo "::warning::could not write the registry-completeness job summary" fi - if [ "$rc_registry_empty" -eq 1 ]; then + if [[ "$rc_registry_empty" -eq 1 ]]; then printf '\n> ⚠️ **Note:** Completeness sweep was skipped — the registry `.agents{}` is empty or unreadable, so no channel-tag agent could be checked. Fix the registry and re-run the check.\n' >> "$GITHUB_STEP_SUMMARY" \ || echo "::warning::could not write the completeness-skipped notice" - elif [ "$rc_incomplete" -eq 1 ]; then + elif [[ "$rc_incomplete" -eq 1 ]]; then printf '\n> ⚠️ **Note:** Completeness check was incomplete — one or more infra repos could not be enumerated after %s attempts (API errors or access issues). Some deployed agents may not have been detected. Investigate and re-run the check.\n' "$max_attempts" >> "$GITHUB_STEP_SUMMARY" \ || echo "::warning::could not write the completeness-incomplete notice" fi @@ -4189,7 +4189,7 @@ main() { return 1 fi done - if [ -n "${SOAK_WINDOW_DAYS:-}" ] && ! [[ "${SOAK_WINDOW_DAYS}" =~ ^[1-9][0-9]*$ ]]; then + if [[ -n "${SOAK_WINDOW_DAYS:-}" ]] && ! [[ "${SOAK_WINDOW_DAYS}" =~ ^[1-9][0-9]*$ ]]; then echo "::error::SOAK_WINDOW_DAYS, when set, must be a positive integer" >&2 return 2 fi @@ -4199,27 +4199,27 @@ main() { # the two are in different shells and never clobber each other. # The timing report runs on exit either way; only a directory main created is removed. local _own_cache=0 - if [ -z "${_RUNS_CACHE_DIR:-}" ]; then + if [[ -z "${_RUNS_CACHE_DIR:-}" ]]; then _RUNS_CACHE_DIR="$(mktemp -d 2>/dev/null || true)" - if [ -n "$_RUNS_CACHE_DIR" ]; then + if [[ -n "$_RUNS_CACHE_DIR" ]]; then export _RUNS_CACHE_DIR _own_cache=1 fi fi _CANARY_OWN_CACHE="$_own_cache" - trap '_timing_report; rm -f "${_TIMING_LOG:-}" "${_TIMING_CALLS:-}"; [ "${_CANARY_OWN_CACHE:-0}" = 1 ] && rm -rf "${_RUNS_CACHE_DIR:-}"' EXIT + trap '_timing_report; rm -f "${_TIMING_LOG:-}" "${_TIMING_CALLS:-}"; [[ "${_CANARY_OWN_CACHE:-0}" = 1 ]] && rm -rf "${_RUNS_CACHE_DIR:-}"' EXIT # Load the registry lookups ONCE, here in the parent shell, so the fan-out's subshells inherit them # instead of re-parsing the registry per call (#1259). Failure just leaves the jq fallback in place. _registry_preload || true local sub="${1:-}"; shift || true _TIMING_SUB="$sub"; _timing_start || true case "$sub" in - evaluate) [ $# -ge 1 ] || { echo "usage: evaluate " >&2; return 2; }; cmd_evaluate "$@" ;; + evaluate) [[ $# -ge 1 ]] || { echo "usage: evaluate " >&2; return 2; }; cmd_evaluate "$@" ;; evaluate-all) cmd_evaluate_all ;; - promote) [ $# -ge 1 ] || { echo "usage: promote [--override] [--confirm] [--allow-pre-existing] [--dry-run]" >&2; return 2; }; cmd_promote "$@" ;; + promote) [[ $# -ge 1 ]] || { echo "usage: promote [--override] [--confirm] [--allow-pre-existing] [--dry-run]" >&2; return 2; }; cmd_promote "$@" ;; promote-all) cmd_promote_all "$@" ;; - rollback) [ $# -ge 2 ] || { echo "usage: rollback --to " >&2; return 2; }; cmd_rollback "$@" ;; - resolve) [ $# -ge 2 ] || { echo "usage: resolve " >&2; return 2; }; resolve_members "$@" ;; + rollback) [[ $# -ge 2 ]] || { echo "usage: rollback --to " >&2; return 2; }; cmd_rollback "$@" ;; + resolve) [[ $# -ge 2 ]] || { echo "usage: resolve " >&2; return 2; }; resolve_members "$@" ;; sync-issues) cmd_sync_issues "$@" ;; # upsert blocker issues + dashboard for held promotions sync-promotion-failures) cmd_sync_promotion_failures "$@" ;; # escalate failing tag writes into durable issues (#1023) autocut) cmd_autocut "$@" ;; # cut a new candidate when a reusable changes on main (#1069) @@ -4230,9 +4230,9 @@ main() { # Test hook: CANARY_PRELOAD_REGISTRY=1 preloads the registry lookups at source time, so the whole suite can # be re-run against the memoized path (it must behave identically; see tests/canary_rollout.bats). -[ "${CANARY_PRELOAD_REGISTRY:-}" = "1" ] && { _registry_preload || true; } +[[ "${CANARY_PRELOAD_REGISTRY:-}" = "1" ]] && { _registry_preload || true; } # Source-guard: tests source this file to exercise resolve_members etc. without running. -if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then +if [[ "${BASH_SOURCE[0]:-$0}" = "$0" ]]; then main "$@" fi diff --git a/scripts/check-cron-timing.sh b/scripts/check-cron-timing.sh index 1b8b8afff..c7cc2eef3 100755 --- a/scripts/check-cron-timing.sh +++ b/scripts/check-cron-timing.sh @@ -25,7 +25,7 @@ for wf in "${WORKFLOW_DIR}"/*.yml "${WORKFLOW_DIR}"/*.yaml; do # Pull every quoted cron expression out of the file. A cron line looks like: # - cron: '0 7 * * *' # comment while IFS= read -r expr; do - [ -n "$expr" ] || continue + [[ -n "$expr" ]] || continue if cron_minute_is_zero "$expr"; then { printf 'FAIL: %s — cron "%s" fires at minute 0 (top of the hour).\n' \ @@ -39,7 +39,7 @@ for wf in "${WORKFLOW_DIR}"/*.yml "${WORKFLOW_DIR}"/*.yaml; do | sed -E "s/cron:[[:space:]]*['\"]//; s/['\"]$//") done -if [ "$violations" -gt 0 ]; then +if [[ "$violations" -gt 0 ]]; then printf '\n%d scheduled workflow(s) fire on the top of the hour. See %s.\n' \ "$violations" "$STANDARD" >&2 exit 1 diff --git a/scripts/check-duplicate-decls.sh b/scripts/check-duplicate-decls.sh index fdd3658d3..27cd3ec3d 100644 --- a/scripts/check-duplicate-decls.sh +++ b/scripts/check-duplicate-decls.sh @@ -42,7 +42,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SCAN_DIR="${1:-$REPO_ROOT}" -if [ ! -d "$SCAN_DIR" ]; then +if [[ ! -d "$SCAN_DIR" ]]; then echo "::error::check-duplicate-decls: scan directory does not exist: ${SCAN_DIR}" >&2 exit 2 fi @@ -163,12 +163,12 @@ report="" _record() { # _record ( is "SYMBOLCOUNT" lines) local file="$1" kind="$2" findings="$3" sym count IFS - [ -n "$findings" ] || return 0 + [[ -n "$findings" ]] || return 0 fail=1 report="${report}- \`${file}\` — duplicated top-level ${kind}: " while IFS="$(printf '\t')" read -r sym count; do - [ -n "$sym" ] || continue + [[ -n "$sym" ]] || continue report="${report} - \`${sym}\` (declared ${count} times) " done </dev/null 2>&1; then echo "::error::check-duplicate-decls: python3 is required to validate JSON files but was not found" >&2 exit 2 @@ -244,7 +244,7 @@ PY } fi -if [ "$fail" -ne 0 ]; then +if [[ "$fail" -ne 0 ]]; then echo "::error::Duplicate top-level declarations found — the #1485 corruption class. This merge is blocked." printf '%s\n' "$report" echo "This is the signature of a botched automated conflict resolution or a" diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index d521e800a..7ee53de40 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -183,7 +183,7 @@ warn() { echo "::warning::$*" >&2; } # and not in dry-run (issue #1036, AC4). Everything gated behind this is a no-op # in the read-only default. mutations_enabled() { - [ "$APPLY" = "true" ] && [ "$DRY_RUN" != "true" ] + [[ "$APPLY" = "true" ]] && [[ "$DRY_RUN" != "true" ]] } # mark_repo_audited : record that 's checks ran this execution. @@ -193,7 +193,7 @@ mark_repo_audited() { # repo_was_audited : return 0 if 's checks ran this execution (AC2). repo_was_audited() { - [ -f "$AUDITED_REPOS_FILE" ] || return 1 + [[ -f "$AUDITED_REPOS_FILE" ]] || return 1 grep -qxF "$1" "$AUDITED_REPOS_FILE" } @@ -213,7 +213,7 @@ mark_repo_inconclusive() { # repo_has_inconclusive_checks : return 0 if had a failed collection check. repo_has_inconclusive_checks() { - [ -f "$INCONCLUSIVE_REPOS_FILE" ] || return 1 + [[ -f "$INCONCLUSIVE_REPOS_FILE" ]] || return 1 grep -qxF "$1" "$INCONCLUSIVE_REPOS_FILE" } @@ -259,7 +259,7 @@ gh_api() { echo "$output" return 0 fi - if [ "$i" -lt "$retries" ]; then + if [[ "$i" -lt "$retries" ]]; then sleep $((i * 2)) else info "gh api $1 failed after $retries retries" >&2 @@ -404,18 +404,18 @@ list_workflow_files() { for attempt in 1 2 3; do rc=0 out=$(gh api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>"$errfile") || rc=$? - [ "$rc" -eq 0 ] && break + [[ "$rc" -eq 0 ]] && break kind=$(agent_ingress_gh_error_kind "$(cat "$errfile")") - [ "$kind" = "missing" ] && break - [ "$attempt" -lt 3 ] && sleep $((attempt * 2)) + [[ "$kind" = "missing" ]] && break + [[ "$attempt" -lt 3 ]] && sleep $((attempt * 2)) done - if [ "$rc" -eq 0 ]; then + if [[ "$rc" -eq 0 ]]; then rm -f "$errfile" printf '%s\n' "$out" return 0 fi rm -f "$errfile" - [ "$kind" = "missing" ] && return 2 + [[ "$kind" = "missing" ]] && return 2 return 1 } @@ -430,7 +430,7 @@ check_required_workflows() { local ingress_b64="" ingress_decoded="" ingress_read=false ingress_unreadable=false wf for wf in "${REQUIRED_WORKFLOWS[@]}"; do if ! gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.name' > /dev/null 2>&1; then - if [ "$ingress_read" = false ]; then + if [[ "$ingress_read" = false ]]; then ingress_read=true # Failing read + readable listing WITHOUT the exact ingress filename = # genuinely absent. Listing unreadable (incl. a missing workflows dir), or @@ -443,18 +443,18 @@ check_required_workflows() { # rc 2 = the workflows directory itself is absent (404): genuinely # nothing there, so report missing workflows. Any other failure, or the # ingress being listed while its read failed, is inconclusive. - if [ "$list_rc" -eq 1 ] || { [ "$list_rc" -eq 0 ] && grep -qxF "$AGENT_INGRESS_WORKFLOW" <<< "$listing"; }; then + if [[ "$list_rc" -eq 1 ]] || { [[ "$list_rc" -eq 0 ]] && grep -qxF "$AGENT_INGRESS_WORKFLOW" <<< "$listing"; }; then ingress_unreadable=true info "could not read $AGENT_INGRESS_WORKFLOW in $repo (inconclusive) — not reporting missing workflows" fi fi - [ -n "$ingress_b64" ] && ingress_decoded=$(echo "$ingress_b64" | base64 -d 2>/dev/null || echo "") + [[ -n "$ingress_b64" ]] && ingress_decoded=$(echo "$ingress_b64" | base64 -d 2>/dev/null || echo "") fi - if [ "$ingress_unreadable" = true ]; then + if [[ "$ingress_unreadable" = true ]]; then mark_repo_inconclusive "$repo" continue fi - if [ "$wf" != "feature-ideation.yml" ] && [ -n "$ingress_decoded" ] \ + if [[ "$wf" != "feature-ideation.yml" ]] && [[ -n "$ingress_decoded" ]] \ && agent_ingress_has_role_job "$(agent_ingress_role_for_workflow "$wf")" <<< "$ingress_decoded"; then continue fi @@ -472,9 +472,9 @@ check_required_workflows() { # is present and correctly pinned; it only needs its one required per-repo edit. local fi_content fi_decoded fi_content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/feature-ideation.yml" --jq '.content' 2>/dev/null || echo "") - if [ -n "$fi_content" ]; then + if [[ -n "$fi_content" ]]; then fi_decoded=$(echo "$fi_content" | base64 -d 2>/dev/null || echo "") - if [ -n "$fi_decoded" ] && feature_ideation_should_flag_placeholder "$repo" "$fi_decoded"; then + if [[ -n "$fi_decoded" ]] && feature_ideation_should_flag_placeholder "$repo" "$fi_decoded"; then add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "feature-ideation-placeholder-context" "warning" \ "\`feature-ideation.yml\` is present but its \`project_context\` is still the seed template placeholder (\`TODO:\`/\`Example:\`) — replace it with a real per-repo project description so weekly ideation runs on real context" \ "standards/ci-standards.md#required-workflows" @@ -530,11 +530,11 @@ check_action_pinning() { local content content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && continue + [[ -z "$content" ]] && continue local decoded decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$decoded" ] && continue + [[ -z "$decoded" ]] && continue # Find uses: directives that are NOT SHA-pinned # SHA-pinned: uses: owner/action@<40+ hex chars> @@ -544,7 +544,7 @@ check_action_pinning() { local unpinned unpinned=$(echo "$decoded" | grep -E '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]+[^#]*@' | grep -vE '@[0-9a-f]{40}' | grep -vE '(docker://|\.\/)' | grep -vE "uses:[[:space:]]+$ORG/\\.github(-private)?/\\.github/workflows/" || true) - if [ -n "$unpinned" ]; then + if [[ -n "$unpinned" ]]; then local count count=$(echo "$unpinned" | wc -l | tr -d ' ') local examples @@ -596,9 +596,9 @@ check_reusable_workflows_disabled() { local content decoded content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && continue + [[ -z "$content" ]] && continue decoded=$(printf '%s' "$content" | base64 -d 2>/dev/null) || continue - [ -z "$decoded" ] && continue + [[ -z "$decoded" ]] && continue # Extract the top-level trigger keys from the `on:` declaration. # Handles block form, inline scalar (`on: workflow_call`), and @@ -624,7 +624,7 @@ check_reusable_workflows_disabled() { # Pure reusable = workflow_call present AND no other trigger key. echo "$triggers" | grep -qx "workflow_call" || continue - if [ "$(echo "$triggers" | grep -vx "workflow_call" | grep -c .)" -ne 0 ]; then + if [[ "$(echo "$triggers" | grep -vx "workflow_call" | grep -c .)" -ne 0 ]]; then continue # hybrid (workflow_call + real trigger) — exempt, must stay active fi @@ -643,9 +643,9 @@ check_reusable_workflows_disabled() { # Pure reusable — check its enabled/disabled state. local state state=$(gh_api "repos/$ORG/$repo/actions/workflows/$wf" --jq '.state' 2>/dev/null || echo "") - [ -z "$state" ] && continue # not registered (e.g. absent from default branch) + [[ -z "$state" ]] && continue # not registered (e.g. absent from default branch) - if [ "$state" != "disabled_manually" ]; then + if [[ "$state" != "disabled_manually" ]]; then add_finding "$repo" "reusable-workflows" "reusable-not-disabled-manually-$wf" "warning" \ "Pure reusable workflow \`$wf\` (workflow_call-only) is in state \`$state\` (expected \`disabled_manually\`); it must be intentionally disabled in the Actions UI. Disable with: \`gh workflow disable $wf -R $ORG/$repo\`" \ "standards/ci-standards.md#pure-reusable-workflows-must-be-disabled" @@ -662,7 +662,7 @@ check_dependabot_config() { local content content=$(gh_api "repos/$ORG/$repo/contents/.github/dependabot.yml" --jq '.content' 2>/dev/null || echo "") - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then add_finding "$repo" "dependabot" "missing-config" "error" \ "Missing \`.github/dependabot.yml\` configuration file" \ "standards/dependabot-policy.md" @@ -691,7 +691,7 @@ check_dependabot_config() { block=$(echo "$decoded" | awk "/package-ecosystem:.*(\"$eco\"|'$eco')/{found=1} found{print; if(/package-ecosystem:/ && NR>1 && !/(\"$eco\"|'$eco')/) exit}" | head -15) local limit limit=$(echo "$block" | grep 'open-pull-requests-limit:' | head -1 | grep -oE '[0-9]+' || echo "") - if [ -n "$limit" ] && [ "$limit" != "0" ]; then + if [[ -n "$limit" ]] && [[ "$limit" != "0" ]]; then add_finding "$repo" "dependabot" "wrong-limit-$eco" "warning" \ "Dependabot \`$eco\` ecosystem has \`open-pull-requests-limit: $limit\` (should be \`0\` for security-only policy)" \ "standards/dependabot-policy.md#policy" @@ -730,14 +730,14 @@ check_repo_settings() { has_issues: .has_issues }' 2>/dev/null || echo "{}") - [ "$settings" = "{}" ] && return + [[ "$settings" = "{}" ]] && return # Boolean settings checks for entry in "${REQUIRED_SETTINGS_BOOL[@]}"; do IFS=':' read -r key expected severity detail <<< "$entry" local actual actual=$(printf '%s' "$settings" | jq -r --arg key "$key" '.[$key] | if . == null then "null" else tostring end') - if [ "$actual" != "$expected" ]; then + if [[ "$actual" != "$expected" ]]; then add_finding "$repo" "settings" "$key" "$severity" \ "$detail (current: \`$actual\`, expected: \`$expected\`)" \ "standards/github-settings.md#repository-settings--standard-defaults" @@ -747,7 +747,7 @@ check_repo_settings() { # Default branch local default_branch default_branch=$(printf '%s' "$settings" | jq -r '.default_branch') - if [ "$default_branch" != "main" ]; then + if [[ "$default_branch" != "main" ]]; then add_finding "$repo" "settings" "default-branch" "error" \ "Default branch is \`$default_branch\`, should be \`main\`" \ "standards/github-settings.md#general" @@ -786,7 +786,7 @@ check_labels() { # already guards against, mirrored here. local persona_out persona_rc=0 persona_out=$(persona_opt_out_label_configs) || persona_rc=$? - if [ "$persona_rc" -ne 0 ]; then + if [[ "$persona_rc" -ne 0 ]]; then add_finding "$repo" "labels" "persona-opt-out-derivation-failed" "error" \ "Could not derive the \`:hands-off\` persona opt-out label family from the persona manifests (\`$PERSONA_MANIFEST_REPO\`). Failing closed: an unreadable or incomplete manifest list is NOT treated as \"no persona labels required\" — run \`scripts/apply-repo-settings.sh $repo\` once the manifests are readable to provision the family." \ "standards/github-settings.md#derived-family--persona-opt-out-labels-idhands-off" @@ -794,7 +794,7 @@ check_labels() { # Still check whatever WAS derived: a partial derivation emits its best guess, # and even on the happy path this is the per-persona missing-label check. while IFS='|' read -r label color description; do - [ -z "$label" ] && continue + [[ -z "$label" ]] && continue _check_or_create_label "$repo" "$label" "$color" "$description" "$existing_labels" done <<< "$persona_out" } @@ -842,7 +842,7 @@ check_rulesets() { # Default branch drives which rulesets are "targeting main" below. local default_branch default_branch=$(echo "$repo_json" | jq -r '.default_branch // "main"' 2>/dev/null || echo "main") - if [ -z "$default_branch" ] || [ "$default_branch" = "null" ]; then + if [[ -z "$default_branch" ]] || [[ "$default_branch" = "null" ]]; then default_branch="main" fi @@ -852,7 +852,7 @@ check_rulesets() { # reconstitute the per-page arrays into one flat array with jq -s '[.[]]'. local rulesets_json rulesets_json=$(gh_api --paginate "repos/$ORG/$repo/rulesets" 2>/dev/null | jq -s '[.[][]]' 2>/dev/null || echo "[]") - [ -z "$rulesets_json" ] && rulesets_json="[]" + [[ -z "$rulesets_json" ]] && rulesets_json="[]" local names names=$(echo "$rulesets_json" | jq -r '.[].name' 2>/dev/null || echo "") @@ -968,7 +968,7 @@ _ruleset_contents_one() { # --- Codified source of truth (fail closed if missing/unparseable) -------- local codified_file="$RULESETS_SRC_DIR/$name.json" - if [ ! -f "$codified_file" ]; then + if [[ ! -f "$codified_file" ]]; then add_finding "$repo" "rulesets" "ruleset-contents-source-missing-$name" "error" \ "Cannot audit \`$name\` ruleset contents: codified source of truth \`standards/rulesets/$name.json\` was not found. Treating as a finding rather than a pass (fail closed) — an audit that cannot read the standard must never read as \"no drift\"." \ "$std_ref" @@ -976,7 +976,7 @@ _ruleset_contents_one() { fi local exp_params exp_params=$(jq -c --arg t "$rtype" '[.rules[]? | select(.type==$t) | .parameters][0] // empty' "$codified_file" 2>/dev/null || echo "") - if [ -z "$exp_params" ]; then + if [[ -z "$exp_params" ]]; then add_finding "$repo" "rulesets" "ruleset-contents-source-invalid-$name" "error" \ "Cannot audit \`$name\` ruleset contents: codified source \`standards/rulesets/$name.json\` has no \`$rtype\` rule parameters to compare against (unparseable or malformed). Fail closed." \ "$std_ref" @@ -987,12 +987,12 @@ _ruleset_contents_one() { # Absence-by-name is already reported by check_rulesets; stay silent here. local rs_id rs_id=$(echo "$rulesets_json" | jq -r --arg n "$name" '.[]? | select(.name==$n) | .id' 2>/dev/null || echo "") - [ -z "$rs_id" ] && return 0 + [[ -z "$rs_id" ]] && return 0 # --- Fetch the full live ruleset (fail closed on error/invalid JSON) ------ local rs rs=$(gh_api "repos/$ORG/$repo/rulesets/$rs_id" 2>/dev/null || echo "") - if [ -z "$rs" ] || ! echo "$rs" | jq empty >/dev/null 2>&1; then + if [[ -z "$rs" ]] || ! echo "$rs" | jq empty >/dev/null 2>&1; then add_finding "$repo" "rulesets" "ruleset-contents-unfetchable-$name" "error" \ "Could not fetch or parse the \`$name\` ruleset (id $rs_id) to audit its contents against \`standards/rulesets/$name.json\`. Treating as drift, not a pass (fail closed): an error must never be conflated with \"no drift\"." \ "$std_ref" @@ -1008,9 +1008,9 @@ _ruleset_contents_one() { local req_types req_type has_req_type req_types=$(jq -r '.rules[]? | select((has("parameters") | not) or .parameters == null) | .type // empty' "$codified_file" 2>/dev/null || echo "") while IFS= read -r req_type; do - [ -z "$req_type" ] && continue + [[ -z "$req_type" ]] && continue has_req_type=$(echo "$rs" | jq --arg t "$req_type" 'any(.rules[]?; .type==$t)' 2>/dev/null || echo "false") - if [ "$has_req_type" != "true" ]; then + if [[ "$has_req_type" != "true" ]]; then add_finding "$repo" "rulesets" "ruleset-drift-$name-missing-rule-$req_type" "error" \ "Ruleset \`$name\` is missing required \`$req_type\` rule codified in \`standards/rulesets/$name.json\`; run \`scripts/apply-rulesets.sh --repo $ORG/$repo\` to converge." \ "$std_ref" @@ -1020,7 +1020,7 @@ _ruleset_contents_one() { # A ruleset that exists but has lost the codified rule type entirely is drift. local has_rule has_rule=$(echo "$rs" | jq --arg t "$rtype" 'any(.rules[]?; .type==$t)' 2>/dev/null || echo "false") - if [ "$has_rule" != "true" ]; then + if [[ "$has_rule" != "true" ]]; then add_finding "$repo" "rulesets" "ruleset-drift-$name-missing-rule" "error" \ "Ruleset \`$name\` exists but no longer carries a \`$rtype\` rule, so every codified parameter is unenforced. The codified standard (\`standards/rulesets/$name.json\`) requires it; run \`scripts/apply-rulesets.sh --repo $ORG/$repo\` to converge." \ "$std_ref" @@ -1029,7 +1029,7 @@ _ruleset_contents_one() { local act_params act_params=$(echo "$rs" | jq -c --arg t "$rtype" '[.rules[]? | select(.type==$t) | .parameters][0] // {}' 2>/dev/null || echo "") - [ -z "$act_params" ] && act_params="{}" + [[ -z "$act_params" ]] && act_params="{}" # --- Diff codified vs live and emit one finding per drifted parameter ------ local drift @@ -1041,10 +1041,10 @@ _ruleset_contents_one() { fi local param expected actual slug - while IFS=$'\t' read -r param expected actual || [ -n "$param" ]; do - [ -z "$param" ] && continue + while IFS=$'\t' read -r param expected actual || [[ -n "$param" ]]; do + [[ -z "$param" ]] && continue slug=$(printf '%s' "$param" | tr '[:upper:]' '[:lower:]' | tr ' /' '--' | tr -cd 'a-z0-9_-') - [ -z "$slug" ] && slug="param" + [[ -z "$slug" ]] && slug="param" add_finding "$repo" "rulesets" "ruleset-drift-$name-$slug" "error" \ "Ruleset \`$name\` parameter \`$param\` has drifted from the codified standard: expected \`$expected\`, actual \`$actual\`. The codified JSON (\`standards/rulesets/$name.json\`) is the source of truth (#575/#580); run \`scripts/apply-rulesets.sh --repo $ORG/$repo\` to converge." \ "$std_ref" @@ -1091,7 +1091,7 @@ check_declared_required_checks() { err=$(mktemp) raw=$(gh api -H "Accept: application/vnd.github.raw+json" \ "repos/$ORG/$repo/contents/$RC_DECLARED_PATH" 2>"$err") && rc=0 || rc=$? - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then if grep -q 'HTTP 404' "$err"; then rm -f "$err"; return 0; fi rm -f "$err" # Keep any open declared-required-check-missing-* finding open: its check @@ -1120,15 +1120,15 @@ check_declared_required_checks() { # The applier keeps a codified entry over a same-context declaration, so only # audit the declarations it actually merges. local codified - if ! codified=$(jq -c . "$RULESETS_SRC_DIR/code-quality.json" 2>/dev/null) || [ -z "$codified" ] \ - || ! declared=$(rc_drop_codified "$codified" "$declared" 2>/dev/null) || [ -z "$declared" ]; then + if ! codified=$(jq -c . "$RULESETS_SRC_DIR/code-quality.json" 2>/dev/null) || [[ -z "$codified" ]] \ + || ! declared=$(rc_drop_codified "$codified" "$declared" 2>/dev/null) || [[ -z "$declared" ]]; then mark_repo_inconclusive "$repo" add_finding "$repo" "rulesets" "declared-required-checks-source-invalid" "error" \ "Cannot compare declared required checks: the codified source \`standards/rulesets/code-quality.json\` is missing or unparseable. Fail closed." \ "$std_ref" return 0 fi - [ "$declared" = "[]" ] && return 0 + [[ "$declared" = "[]" ]] && return 0 # Absence of code-quality is reported by check_rulesets (missing-code-quality). # The declared checks cannot be verified without it, so keep any open @@ -1137,7 +1137,7 @@ check_declared_required_checks() { # issue auto-closure fleet-wide. local rs_id rs_id=$(echo "$rulesets_json" | jq -r '.[]? | select(.name=="code-quality") | .id' 2>/dev/null || echo "") - if [ -z "$rs_id" ]; then + if [[ -z "$rs_id" ]]; then mark_repo_inconclusive "$repo" return 0 fi @@ -1146,16 +1146,16 @@ check_declared_required_checks() { rs=$(gh_api "repos/$ORG/$repo/rulesets/$rs_id" 2>/dev/null || echo "") # An unfetchable code-quality ruleset is already a finding from # check_ruleset_contents; do not report it twice. - if [ -z "$rs" ] || ! echo "$rs" | jq empty >/dev/null 2>&1; then + if [[ -z "$rs" ]] || ! echo "$rs" | jq empty >/dev/null 2>&1; then mark_repo_inconclusive "$repo" return 0 fi local context want have slug while IFS=$'\t' read -r context want have; do - [ -z "$context" ] && continue + [[ -z "$context" ]] && continue slug=$(printf '%s' "$context" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//') - if [ "$have" = "missing" ]; then + if [[ "$have" = "missing" ]]; then have="it is not required at all" else have="it is pinned to app \`$have\`" @@ -1172,7 +1172,7 @@ check_code_scanning_rule() { local floor floor=$(cs_floor_tools "$RULESETS_SRC_DIR/code-quality.json") - if [ -z "$floor" ]; then + if [[ -z "$floor" ]]; then add_finding "$repo" "rulesets" "code-scanning-rule-source-invalid" "error" \ "Cannot audit code-scanning merge protection: the codified source \`standards/rulesets/code-quality.json\` has no \`code_scanning\` rule to compare against (missing, unparseable or malformed). Fail closed." \ "$CODE_SCANNING_STANDARD_REF" @@ -1183,7 +1183,7 @@ check_code_scanning_rule() { # paginated array of pages must be flattened (jq -s 'add'). local rules="" api_ok=0 rules=$(gh api --paginate "repos/$ORG/$repo/rules/branches/$default_branch" 2>/dev/null | jq -cs 'add // []' 2>/dev/null) || api_ok=$? - if [ "$api_ok" -ne 0 ] || ! jq -e 'type == "array"' >/dev/null 2>&1 <<< "$rules"; then + if [[ "$api_ok" -ne 0 ]] || ! jq -e 'type == "array"' >/dev/null 2>&1 <<< "$rules"; then add_finding "$repo" "rulesets" "code-scanning-rule-unreadable" "error" \ "Could not read the rules that apply to \`$default_branch\` (\`GET repos/$ORG/$repo/rules/branches/$default_branch\`), so code-scanning merge protection could not be checked. Treating as a finding, not a pass (fail closed)." \ "$CODE_SCANNING_STANDARD_REF" @@ -1202,9 +1202,9 @@ check_code_scanning_rule() { local remedy="Run \`scripts/apply-rulesets.sh --repo $ORG/$repo code-quality\` (it adds the rule to \`code-quality\` and never lowers a stricter threshold); a repo that sets its own rulesets adds it to one of them at this floor or stricter." local tool field want have - while IFS=$'\t' read -r tool field want have || [ -n "$tool" ]; do - [ -z "$tool" ] && continue - if [ "$field" = "missing" ]; then + while IFS=$'\t' read -r tool field want have || [[ -n "$tool" ]]; do + [[ -z "$tool" ]] && continue + if [[ "$field" = "missing" ]]; then add_finding "$repo" "rulesets" "code-scanning-rule-missing" "error" \ "No \`code_scanning\` rule for \`$tool\` applies to \`$default_branch\`, so a pull request can merge with open code-scanning alerts (GitHub's default blocks only \`high_or_higher\` security alerts, and only where a rule asks it to). The standard requires \`$tool\` with \`security_alerts_threshold\` \`medium_or_higher\` or stricter and \`alerts_threshold\` \`errors\` or stricter. $remedy" \ "$CODE_SCANNING_STANDARD_REF" @@ -1246,13 +1246,13 @@ check_ruleset_bypass_actors() { local ids ids=$(echo "$rulesets_json" | jq -r '.[].id' 2>/dev/null || echo "") - [ -z "$ids" ] && return 0 + [[ -z "$ids" ]] && return 0 local rs_id for rs_id in $ids; do local rs rs=$(gh_api "repos/$ORG/$repo/rulesets/$rs_id" 2>/dev/null || echo "") - [ -z "$rs" ] && continue + [[ -z "$rs" ]] && continue # Does this ruleset target the default branch? Match the GitHub aliases # (~DEFAULT_BRANCH, ~ALL) or an explicit refs/heads/ include. @@ -1268,7 +1268,7 @@ check_ruleset_bypass_actors() { and (($exc | index("~DEFAULT_BRANCH")) == null) and (($exc | index($db)) == null) ' 2>/dev/null || echo "false") - [ "$targets_default" != "true" ] && continue + [[ "$targets_default" != "true" ]] && continue local rs_name rs_name=$(echo "$rs" | jq -r '.name' 2>/dev/null || echo "ruleset-$rs_id") @@ -1276,7 +1276,7 @@ check_ruleset_bypass_actors() { # churn across runs. Ruleset names (pr-quality, code-quality, …) are stable. local slug slug=$(printf '%s' "$rs_name" | tr '[:upper:] /' '[:lower:]--' | tr -cd 'a-z0-9_-') - [ -z "$slug" ] && slug="$rs_id" + [[ -z "$slug" ]] && slug="$rs_id" # --- Bypass actor check (single jq invocation) ------------------------ local oa_always oa_any repo_admin dep_always dep_any @@ -1290,15 +1290,15 @@ check_ruleset_bypass_actors() { ([.bypass_actors[]? | select(.actor_type=="Integration" and .actor_id==$id)] | length > 0) ] | map(tostring) | join(" ") ' <<< "$rs" 2>/dev/null || echo "false false false false false") - [ -z "$bypass_info" ] && bypass_info="false false false false false" + [[ -z "$bypass_info" ]] && bypass_info="false false false false false" read -r oa_always oa_any repo_admin dep_always dep_any <<< "$bypass_info" - if [ "$oa_always" != "true" ]; then - if [ "$oa_any" = "true" ]; then + if [[ "$oa_always" != "true" ]]; then + if [[ "$oa_any" = "true" ]]; then add_finding "$repo" "rulesets" "ruleset-bypass-orgadmin-mode-$slug" "error" \ "Ruleset \`$rs_name\` (targets \`$default_branch\`) grants \`OrganizationAdmin\` bypass but not with \`bypass_mode: always\`. The standard requires \`always\` for emergency admin override on every ruleset targeting the default branch." \ "$std_ref" - elif [ "$repo_admin" = "true" ]; then + elif [[ "$repo_admin" = "true" ]]; then add_finding "$repo" "rulesets" "ruleset-bypass-orgadmin-$slug" "error" \ "Ruleset \`$rs_name\` (targets \`$default_branch\`) grants bypass to the **Repository admin** role (RepositoryRole id 5), not the **OrganizationAdmin** role required by the standard. Repository admin is repo-scoped and does not satisfy the org-wide emergency-override requirement. Add \`OrganizationAdmin\` with \`bypass_mode: always\`." \ "$std_ref" @@ -1310,8 +1310,8 @@ check_ruleset_bypass_actors() { fi # --- dependabot-automerge-petry app ----------------------------------- - if [ "$dep_always" != "true" ]; then - if [ "$dep_any" = "true" ]; then + if [[ "$dep_always" != "true" ]]; then + if [[ "$dep_any" = "true" ]]; then add_finding "$repo" "rulesets" "ruleset-bypass-dependabot-mode-$slug" "error" \ "Ruleset \`$rs_name\` (targets \`$default_branch\`) grants the \`dependabot-automerge-petry\` app (id $DEPENDABOT_APP_ACTOR_ID) bypass but with the wrong mode. \`pull_request\` mode only applies when the bypass actor opens the PR; Dependabot opens its own PRs, so its merge API calls are rejected. Set \`bypass_mode: always\`." \ "$std_ref" @@ -1350,7 +1350,7 @@ check_legacy_rulesets() { local ids ids=$(echo "$rulesets_json" | jq -r '.[].id' 2>/dev/null || echo "") - [ -z "$ids" ] && return 0 + [[ -z "$ids" ]] && return 0 # jq snippet: does a ruleset (full JSON on stdin) target the default branch? # Kept identical to check_ruleset_bypass_actors so the two agree on scope. @@ -1375,9 +1375,9 @@ check_legacy_rulesets() { local rs_id rs targets rs_name for rs_id in $ids; do rs=$(gh_api "repos/$ORG/$repo/rulesets/$rs_id" 2>/dev/null || echo "") - [ -z "$rs" ] && continue + [[ -z "$rs" ]] && continue targets=$(echo "$rs" | jq --arg db "refs/heads/$default_branch" "$targets_jq" 2>/dev/null || echo "false") - [ "$targets" != "true" ] && continue + [[ "$targets" != "true" ]] && continue rs_name=$(echo "$rs" | jq -r '.name' 2>/dev/null || echo "ruleset-$rs_id") rs_cache["$rs_id"]="$rs" @@ -1386,7 +1386,7 @@ check_legacy_rulesets() { if [[ " ${SANCTIONED_RULESETS[*]} " == *" $rs_name "* ]]; then local ctx ctx=$(echo "$rs" | jq -r '[.rules[]? | select(.type=="required_status_checks") | .parameters.required_status_checks[]?.context] | .[]' 2>/dev/null || echo "") - [ -n "$ctx" ] && sanctioned_ctx+="$ctx"$'\n' + [[ -n "$ctx" ]] && sanctioned_ctx+="$ctx"$'\n' fi done @@ -1400,20 +1400,20 @@ check_legacy_rulesets() { rs="${rs_cache[$rs_id]}" local slug slug=$(printf '%s' "$rs_name" | tr '[:upper:] /' '[:lower:]--' | tr -cd 'a-z0-9_-') - [ -z "$slug" ] && slug="$rs_id" + [[ -z "$slug" ]] && slug="$rs_id" local legacy_ctx uncovered="" legacy_ctx=$(echo "$rs" | jq -r '[.rules[]? | select(.type=="required_status_checks") | .parameters.required_status_checks[]?.context] | .[]' 2>/dev/null || echo "") local c while IFS= read -r c; do - [ -z "$c" ] && continue + [[ -z "$c" ]] && continue if ! grep -qxF "$c" <<< "$sanctioned_ctx"; then uncovered+="\`$c\` " fi done <<< "$legacy_ctx" local migrate_note - if [ -n "$uncovered" ]; then + if [[ -n "$uncovered" ]]; then migrate_note="**Migrate before deleting** — these required checks are not yet required by \`pr-quality\` or \`code-quality\`, so deleting this ruleset would drop them as merge gates: ${uncovered}Declare them in \`$RC_DECLARED_PATH\` (each with its \`integration_id\`) so \`scripts/apply-rulesets.sh\` merges them into \`code-quality\`, then delete this ruleset." else migrate_note="Every required check it carries is already required by a sanctioned ruleset, so it is **safe to delete** (e.g. \`gh api -X DELETE repos/$ORG/$repo/rulesets/$rs_id\`)." @@ -1446,30 +1446,30 @@ check_codeowners() { local raw="" rc=1 i for i in 1 2 3; do raw=$(gh api "repos/$ORG/$repo/contents/$path" 2>&1) && rc=0 || rc=$? - [ "$rc" -eq 0 ] && break + [[ "$rc" -eq 0 ]] && break # 404 is deterministic — the file is not at this path; stop retrying it. response_is_http_404 "$raw" && break - [ "$i" -lt 3 ] && sleep $((i * 2)) + [[ "$i" -lt 3 ]] && sleep $((i * 2)) done - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then # Non-404 failure (auth/scope/network/5xx/rate-limit) → inconclusive path. response_is_http_404 "$raw" || any_unreadable=true continue fi local content decoded content=$(printf '%s' "$raw" | jq -r '.content? // empty' 2>/dev/null || echo "") - [ -n "$content" ] || continue + [[ -n "$content" ]] || continue # The contents API returns the body as base64. Reject anything that does not # cleanly decode so an error body can never leak in as owner lines (#370). decoded=$(printf '%s' "$content" | base64 -d 2>/dev/null) || continue - [ -n "$decoded" ] || continue + [[ -n "$decoded" ]] || continue found=true codeowners_content="$decoded" break done - if [ "$found" = false ]; then - if [ "$any_unreadable" = true ]; then + if [[ "$found" = false ]]; then + if [[ "$any_unreadable" = true ]]; then # Could not determine presence — do NOT file a `missing` finding (#437). add_finding "$repo" "settings" "codeowners-unreadable" "warning" \ "Could not read CODEOWNERS (transient/auth error, not a 404) — verify GH_TOKEN scope and repo access. CODEOWNERS presence was not evaluated this run; NOT filed as missing to avoid a false finding." \ @@ -1498,7 +1498,7 @@ check_codeowners() { | sed -E 's/[[:space:]]+#.*$//' \ | grep -v '^[[:space:]]*$' || true) - if [ -z "$owner_lines" ]; then + if [[ -z "$owner_lines" ]]; then add_finding "$repo" "settings" "codeowners-empty" "error" \ "CODEOWNERS file has no owner lines (only comments/blank)" \ "standards/codeowners-standard.md" @@ -1510,16 +1510,16 @@ check_codeowners() { # `\ ` escaped spaces inside the path pattern first). local bad_first_owner="" while IFS= read -r line; do - [ -z "$line" ] && continue + [[ -z "$line" ]] && continue # Collapse `\ ` escaped spaces in the pattern so awk $2 is the first owner token local first_owner first_owner=$(printf '%s\n' "$line" | sed 's/\\ /\\x20/g' | awk '{print $2}') - if [ "$first_owner" != "@${ORG}/org-leads" ]; then + if [[ "$first_owner" != "@${ORG}/org-leads" ]]; then bad_first_owner="$line" break fi done <<< "$owner_lines" - if [ -n "$bad_first_owner" ]; then + if [[ -n "$bad_first_owner" ]]; then add_finding "$repo" "settings" "codeowners-org-leads-not-first" "error" \ "CODEOWNERS owner lines must list \`@${ORG}/org-leads\` as the FIRST owner. Offending line: \`$bad_first_owner\`" \ "standards/codeowners-standard.md" @@ -1539,7 +1539,7 @@ check_codeowners() { } }' \ | sort -u || true) - if [ -n "$individual_owners" ]; then + if [[ -n "$individual_owners" ]]; then local joined joined=$(echo "$individual_owners" | tr '\n' ' ') add_finding "$repo" "settings" "codeowners-individual-users" "error" \ @@ -1559,7 +1559,7 @@ check_codeowners() { local bad_catchall="" bad_catchall=$(printf '%s\n' "$owner_lines" \ | awk '$1 == "*" && NF > 2 { print; exit }') - if [ -n "$bad_catchall" ]; then + if [[ -n "$bad_catchall" ]]; then add_finding "$repo" "settings" "codeowners-catchall-extra-owners" "error" \ "Default \`*\` catch-all line in CODEOWNERS must list ONLY \`@${ORG}/org-leads\` (\`* @${ORG}/org-leads\`); assign additional teams on path-specific rules instead. Offending line: \`$bad_catchall\`" \ "standards/codeowners-standard.md" @@ -1617,14 +1617,14 @@ classify_sonar_s7637_exemption() { | grep -E '^[[:space:]]*sonar\.issue\.ignore\.multicriteria\.[A-Za-z0-9_]+\.ruleKey[[:space:]]*=[[:space:]]*githubactions:S7637[[:space:]]*$' \ | sed -E 's/^[[:space:]]*sonar\.issue\.ignore\.multicriteria\.([A-Za-z0-9_]+)\.ruleKey.*/\1/' || true) - if [ -z "$keys" ]; then + if [[ -z "$keys" ]]; then echo "missing" return 0 fi local key resourcekey base broad=0 while IFS= read -r key; do - [ -z "$key" ] && continue + [[ -z "$key" ]] && continue resourcekey=$(printf '%s\n' "$content" \ | grep -E "^[[:space:]]*sonar\\.issue\\.ignore\\.multicriteria\\.${key}\\.resourceKey[[:space:]]*=" \ | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]*$//' | head -n1 || true) @@ -1633,12 +1633,12 @@ classify_sonar_s7637_exemption() { # is a bare **. Such a pattern also matches ci.yml and would drop S7637 on # the third-party actions there. base="${resourcekey##*/}" - if [ "$resourcekey" = "**" ] || case "$base" in *'*'* | *'?'*) true ;; *) false ;; esac; then + if [[ "$resourcekey" = "**" ]] || case "$base" in *'*'* | *'?'*) true ;; *) false ;; esac; then broad=1 fi done <<< "$keys" - if [ "$broad" -eq 1 ]; then + if [[ "$broad" -eq 1 ]]; then echo "too-broad" else echo "present" @@ -1671,14 +1671,14 @@ classify_inline_s7637_marker() { uses_lines=$(printf '%s\n' "$content" \ | grep -E '^[[:space:]]*uses:[[:space:]]*combsmith/\.github(-private)?/\.github/workflows/[^@[:space:]]+@' || true) - if [ -z "$uses_lines" ]; then + if [[ -z "$uses_lines" ]]; then echo "n/a" return 0 fi local line ref channel_seen=0 missing=0 while IFS= read -r line; do - [ -z "$line" ] && continue + [[ -z "$line" ]] && continue # The pinned ref is the token after the first '@', up to whitespace/comment. ref=$(printf '%s\n' "$line" | sed -E 's/^[^@]*@([^[:space:]#]+).*/\1/') # A 40-char hex SHA pin already satisfies S7637 — no inline marker needed. @@ -1691,9 +1691,9 @@ classify_inline_s7637_marker() { fi done <<< "$uses_lines" - if [ "$channel_seen" -eq 0 ]; then + if [[ "$channel_seen" -eq 0 ]]; then echo "n/a" - elif [ "$missing" -eq 1 ]; then + elif [[ "$missing" -eq 1 ]]; then echo "missing" else echo "present" @@ -1718,9 +1718,9 @@ check_sonar_s7637_exemption() { for wf in $workflows; do case "$wf" in *.yml | *.yaml) ;; *) continue ;; esac wf_content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") - [ -z "$wf_content" ] && continue + [[ -z "$wf_content" ]] && continue wf_decoded=$(echo "$wf_content" | base64 -d 2>/dev/null || echo "") - [ -z "$wf_decoded" ] && continue + [[ -z "$wf_decoded" ]] && continue inline_verdict=$(classify_inline_s7637_marker "$wf_decoded") case "$inline_verdict" in present) inline_seen=1 ;; @@ -1729,7 +1729,7 @@ check_sonar_s7637_exemption() { done # Every channel-pinned first-party stub carries the inline marker → exempt. - if [ "$inline_seen" -eq 1 ] && [ "$inline_missing" -eq 0 ]; then + if [[ "$inline_seen" -eq 1 ]] && [[ "$inline_missing" -eq 0 ]]; then return 0 fi @@ -1738,10 +1738,10 @@ check_sonar_s7637_exemption() { # sonar-project.properties carries the narrow per-stub exemption. local content decoded content=$(gh_api "repos/$ORG/$repo/contents/sonar-project.properties" --jq '.content' 2>/dev/null || echo "") - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then # No properties file. Flag only when a stub needs but lacks the inline marker # (a missing properties file itself is reported by check_sonarcloud). - if [ "$inline_missing" -eq 1 ]; then + if [[ "$inline_missing" -eq 1 ]]; then add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "sonar-s7637-exemption-missing" "warning" \ "First-party caller stub(s) carry a channel-pinned reusable ref (\`@/stable\`, \`@v1\`/\`@v2\`) without the inline \`# NOSONAR(githubactions:S7637)\` marker, and there is no legacy \`sonar-project.properties\` exemption. SonarCloud will flag them as unpinned actions even though the org exempts them from SHA-pinning. Add the inline marker to each channel-pinned first-party \`uses:\` line (canonical), or the legacy per-stub \`sonar.issue.ignore\` entry." \ "standards/ci-standards.md#sonarcloud-exemption-first-party-reusable-ref-s7637" @@ -1749,7 +1749,7 @@ check_sonar_s7637_exemption() { return 0 fi decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$decoded" ] && return 0 + [[ -z "$decoded" ]] && return 0 local verdict verdict=$(classify_sonar_s7637_exemption "$decoded") @@ -1758,7 +1758,7 @@ check_sonar_s7637_exemption() { missing) # Only a real gap when a channel-pinned first-party stub actually lacks the # inline marker; with no such stub there is nothing for S7637 to fire on. - if [ "$inline_missing" -eq 1 ]; then + if [[ "$inline_missing" -eq 1 ]]; then add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "sonar-s7637-exemption-missing" "warning" \ "No \`githubactions:S7637\` exemption found. SonarCloud will flag first-party reusable-ref caller stubs (\`@/stable\`, \`@v1\`/\`@v2\`) as unpinned actions even though the org exempts them from SHA-pinning. Add the inline \`# NOSONAR(githubactions:S7637)\` marker to each channel-pinned first-party \`uses:\` line (canonical), or the legacy per-stub \`sonar.issue.ignore\` exemption in \`sonar-project.properties\`." \ "standards/ci-standards.md#sonarcloud-exemption-first-party-reusable-ref-s7637" @@ -1825,7 +1825,7 @@ check_codeql_default_setup() { local api_ok=0 raw_response=$(gh api "repos/$ORG/$repo/code-scanning/default-setup" 2>/dev/null) || api_ok=$? - if [ "$api_ok" -ne 0 ]; then + if [[ "$api_ok" -ne 0 ]]; then # Distinguish a 403 permission error from other failures (404, 500, …). # The gh api error body contains `"status": "403"` (a JSON string) for # "Resource not accessible by personal access token" responses. @@ -1845,7 +1845,7 @@ check_codeql_default_setup() { else local state state=$(echo "$raw_response" | jq -r '.state // ""') - if [ "$state" != "configured" ]; then + if [[ "$state" != "configured" ]]; then add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "codeql-default-setup-not-configured" "error" \ "CodeQL default setup is in state \`$state\` (expected \`configured\`). Run \`apply-repo-settings.sh $repo\` or \`gh api -X PATCH repos/$ORG/$repo/code-scanning/default-setup -F state=configured -F query_suite=default\`." \ "$CODEQL_STANDARD_REF" @@ -1931,11 +1931,11 @@ check_workflow_permissions() { local content content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && continue + [[ -z "$content" ]] && continue local decoded decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$decoded" ] && continue + [[ -z "$decoded" ]] && continue # Skip reusable workflows (workflow_call-only triggers). # Their permissions are controlled entirely by the caller workflow, so @@ -1954,7 +1954,7 @@ check_workflow_permissions() { job_count=$(echo "$decoded" | grep -cE '^ [a-zA-Z_-]+:$' || echo "0") local has_job_perms has_job_perms=$(echo "$decoded" | grep -cE '^ permissions:' || echo "0") - if [ "$job_count" -gt 1 ] || [ "$has_job_perms" -eq 0 ]; then + if [[ "$job_count" -gt 1 ]] || [[ "$has_job_perms" -eq 0 ]]; then add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "missing-permissions-$wf" "warning" \ "Workflow \`$wf\` missing top-level \`permissions:\` declaration (least-privilege policy)" \ "standards/ci-standards.md#permissions-policy" @@ -1979,11 +1979,11 @@ check_ci_concurrency() { local content content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && return # missing ci.yml is caught by check_required_workflows + [[ -z "$content" ]] && return # missing ci.yml is caught by check_required_workflows local decoded decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$decoded" ] && return + [[ -z "$decoded" ]] && return # Only flag workflows that have a concurrency block but are missing github.sha. # Workflows with no concurrency block at all are not flagged here (they may be @@ -2026,11 +2026,11 @@ stub_pin_acceptable() { local esc_reusable; esc_reusable=$(escape_ere "$reusable") local -a legacy_arr=() - [ -n "$legacy_csv" ] && IFS=',' read -r -a legacy_arr <<< "$legacy_csv" + [[ -n "$legacy_csv" ]] && IFS=',' read -r -a legacy_arr <<< "$legacy_csv" local alt="" r for r in "$canonical" "${legacy_arr[@]}"; do - [ -z "$r" ] && continue + [[ -z "$r" ]] && continue alt+="${alt:+|}$(escape_ere "$r")" done @@ -2060,12 +2060,12 @@ ring_major_form_acceptable() { pinned_ref=$(printf '%s\n' "$decoded" \ | sed -nE "s#^[[:space:]]*uses:[[:space:]]*combsmith/\\.github/\\.github/workflows/${esc_reusable}\\.yml@([^[:space:]]+).*#\\1#p" \ | head -n1) - [ -n "$pinned_ref" ] || return 1 + [[ -n "$pinned_ref" ]] || return 1 local pinned_major; pinned_major=$(ring_pinned_major "$pinned_ref") - [ -n "$pinned_major" ] || return 1 + [[ -n "$pinned_major" ]] || return 1 - [ "$pinned_ref" = "$(ring_canonical_ref "$chan" "$repo" "$pinned_major")" ] + [[ "$pinned_ref" = "$(ring_canonical_ref "$chan" "$repo" "$pinned_major")" ]] } # ring_tier_for_repo(), ring_canonical_ref() and ring_legacy_csv() are provided by @@ -2076,7 +2076,7 @@ check_centralized_workflow_stubs() { # The .github repo is the source of truth and is allowed to reference its # own reusables by @main; skip the stub check for it. - [ "$repo" = ".github" ] && return + [[ "$repo" = ".github" ]] && return # workflow-filename:reusable-basename:canonical-pin:legacy-accepted-csv # canonical-pin — the org-standard ref a stub should pin. The sentinel @@ -2108,12 +2108,12 @@ check_centralized_workflow_stubs() { # If the listing fails (no workflows dir), there's nothing to check. local workflow_list workflow_list=$(gh_api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null || echo "") - [ -z "$workflow_list" ] && return + [[ -z "$workflow_list" ]] && return local entry wf reusable canonical legacy chan is_ring for entry in "${centralized[@]}"; do IFS=':' read -r wf reusable canonical legacy <<< "$entry" - [ -z "$canonical" ] && { echo "::error::centralized entry '$entry' missing canonical pin — expected format 'wf:reusable:canonical:legacy-csv'" >&2; exit 1; } + [[ -z "$canonical" ]] && { echo "::error::centralized entry '$entry' missing canonical pin — expected format 'wf:reusable:canonical:legacy-csv'" >&2; exit 1; } is_ring=0 chan="" @@ -2125,7 +2125,7 @@ check_centralized_workflow_stubs() { # #482). Higher tiers are also acceptable so a repo pinned ahead of its tier # (e.g. a ring1 repo still on /stable, or .github-private's /next promoted to # /stable) is never flagged — only @main / inline / off-channel pins are. - if [ "$canonical" = "RING" ]; then + if [[ "$canonical" = "RING" ]]; then is_ring=1 chan="${reusable%-reusable}" canonical="$(ring_canonical_ref "$chan" "$repo")" @@ -2141,11 +2141,11 @@ check_centralized_workflow_stubs() { local content content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && continue + [[ -z "$content" ]] && continue local decoded decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$decoded" ] && continue + [[ -z "$decoded" ]] && continue # Compliance rule (major-scoped-channels epic #657 F5, #861): # RING reusables MUST pin the major-scoped `/v-` form whose @@ -2234,14 +2234,14 @@ check_dev_lead_stub() { # .github holds the template (exercised by the reusable's own CI) and # .github-private runs the workflow inline rather than as a caller stub. - [ "$repo" = ".github" ] && return - [ "$repo" = ".github-private" ] && return + [[ "$repo" = ".github" ]] && return + [[ "$repo" = ".github-private" ]] && return local content decoded content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/dev-lead.yml" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && return # repo hasn't adopted dev-lead — nothing to check + [[ -z "$content" ]] && return # repo hasn't adopted dev-lead — nothing to check decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$decoded" ] && return + [[ -z "$decoded" ]] && return # 1) Canonical pin (non-comment `uses:` line) — a moving dev-lead channel tag # (self-host channel model). The production default is `stable`; the staged @@ -2285,7 +2285,7 @@ check_dev_lead_stub() { # When uses is a v-form (e.g. v3-stable), agent_ref must match it. # When uses is bare (e.g. stable), agent_ref must also be bare. uses_channel=$(printf '%s\n' "$decoded" | sed -nE 's#^[[:space:]]*uses:[[:space:]]*combsmith/\.github-private/\.github/workflows/dev-lead-reusable\.yml@dev-lead/(v[0-9]+-(stable|next|ring[0-9]+))([[:space:]]|$).*#\1#p') - if [ -n "$uses_channel" ]; then + if [[ -n "$uses_channel" ]]; then if ! printf '%s\n' "$decoded" | grep -qE "^[[:space:]]*agent_ref:[[:space:]]*dev-lead/$uses_channel([[:space:]]|$)"; then add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "dev-lead-stub-agent-ref" "error" \ "The \`dev-lead.yml\` caller stub must pass \`with: agent_ref: dev-lead/$uses_channel\` to match the pinned channel \`$uses_channel\`. Re-sync from \`standards/workflows/dev-lead.yml\`." \ @@ -2339,8 +2339,8 @@ check_stub_surface_drift() { # .github is the source of truth for the templates; .github-private runs these # workflows inline rather than as caller stubs. - [ "$repo" = ".github" ] && return 0 - [ "$repo" = ".github-private" ] && return 0 + [[ "$repo" = ".github" ]] && return 0 + [[ "$repo" = ".github-private" ]] && return 0 # Enrolled shims ("workflow.yml:comma-separated-surfaces") come from # STUB_SURFACE_SHIMS in lib/stub-surface.sh, shared with the deploy sweep. @@ -2350,16 +2350,16 @@ check_stub_surface_drift() { IFS=':' read -r wf surfaces <<< "$entry" template="$STANDARDS_WF_DIR/$wf" - if [ ! -f "$template" ]; then + if [[ ! -f "$template" ]]; then warn "No canonical template at $template — skipping surface-drift check for $wf" continue fi canonical="$(< "$template")" content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/$wf" --jq '.content' 2>/dev/null || echo "") - [ -z "$content" ] && continue # repo hasn't adopted this stub — nothing to check + [[ -z "$content" ]] && continue # repo hasn't adopted this stub — nothing to check deployed=$(echo "$content" | base64 -d 2>/dev/null || echo "") - [ -z "$deployed" ] && continue + [[ -z "$deployed" ]] && continue IFS=',' read -r -a surface_list <<< "$surfaces" for surface in "${surface_list[@]}"; do @@ -2368,7 +2368,7 @@ check_stub_surface_drift() { # run (it can cancel lanes — #402); a trimmed trigger or altered permission # breaks functionality, so those are errors. severity="error" - [ "$surface" = "concurrency" ] && severity="warning" + [[ "$surface" = "concurrency" ]] && severity="warning" add_finding "$repo" "$CI_WORKFLOWS_CATEGORY" "stub-surface-drift-$wf-$surface" "$severity" \ "The \`$wf\` caller stub's \`$surface:\` surface has drifted from the canonical \`standards/workflows/$wf\`. These stubs are thin callers — the \`on:\` triggers, \`permissions:\` grants, and \`concurrency:\` block are owned centrally and are not repo-adjustable (only the documented \`with:\` inputs and the tier channel pin may differ per repo). Re-sync \`$surface:\` from \`standards/workflows/$wf\`." \ "standards/ci-standards.md#centralization-tiers" @@ -2453,7 +2453,7 @@ check_centralized_check_names() { # The .github repo owns the reusables; its own ruleset is allowed to # reference whatever check names it likes. - [ "$repo" = ".github" ] && return + [[ "$repo" = ".github" ]] && return # Map from stale name → current canonical name. Used for the rename # remediation message. The remediation here is "rename in the @@ -2501,7 +2501,7 @@ check_centralized_check_names() { --jq '.contexts[]' 2>/dev/null || echo "") contexts+="$classic_contexts" - [ -z "$(echo "$contexts" | tr -d '[:space:]')" ] && return + [[ -z "$(echo "$contexts" | tr -d '[:space:]')" ]] && return # Check 1: stale pre-centralization names that have a safe rename local entry old new @@ -2524,7 +2524,7 @@ check_centralized_check_names() { # "review-claude / claude" all match). local context match_type while IFS= read -r context; do - [ -z "$context" ] && continue + [[ -z "$context" ]] && continue match_type="" case "$context" in "claude") match_type="claude" ;; @@ -2537,7 +2537,7 @@ check_centralized_check_names() { # Stable check id per match type so findings don't churn across # audit runs from variations in caller-job-id prefixes. local check_id - if [ "$match_type" = "claude-issue" ]; then + if [[ "$match_type" = "claude-issue" ]]; then check_id="required-claude-issue-check-broken" else check_id="required-claude-check-broken" @@ -2559,7 +2559,7 @@ check_claude_md() { local content content=$(gh_api "repos/$ORG/$repo/contents/CLAUDE.md" --jq '.content' 2>/dev/null || echo "") - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then add_finding "$repo" "standards" "missing-claude-md" "error" \ "Missing \`CLAUDE.md\` — every repo must have a CLAUDE.md that references AGENTS.md" \ "AGENTS.md" @@ -2586,7 +2586,7 @@ check_claude_md() { # set. Pure. agents_md_lint_scope_for_repo() { local repo="$1" - if [ "$repo" = ".github" ]; then + if [[ "$repo" = ".github" ]]; then printf 'canonical' else printf 'downstream' @@ -2604,7 +2604,7 @@ agents_md_lint_scope_for_repo() { # ships structural validation informational-only (docs/initiatives/agents-md-validation.md). record_agents_md_structural_findings() { local repo="$1" decoded="$2" - [ -n "$decoded" ] || return 0 + [[ -n "$decoded" ]] || return 0 local scope tmp scope="$(agents_md_lint_scope_for_repo "$repo")" @@ -2620,7 +2620,7 @@ record_agents_md_structural_findings() { amdl_lint "$tmp" "$AMDL_DEFAULT_RULES" "$scope" 2>/dev/null \ | awk -v repo="$repo" -F'\t' 'BEGIN {OFS=FS} $2 != "" {print repo, $0}' \ >> "$STRUCTURAL_FINDINGS_FILE" || lint_rc="${PIPESTATUS[0]}" - if [ "$lint_rc" -ne 0 ]; then + if [[ "$lint_rc" -ne 0 ]]; then printf '%s\tamdl_lint exited %s\n' "$repo" "$lint_rc" >> "$STRUCTURAL_LINT_ERROR_FILE" fi rm -f "$tmp" @@ -2635,7 +2635,7 @@ check_agents_md() { local content content=$(gh_api "repos/$ORG/$repo/contents/AGENTS.md" --jq '.content' 2>/dev/null || echo "") - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then add_finding "$repo" "standards" "missing-agents-md" "error" \ "Missing \`AGENTS.md\` — every repo must have an AGENTS.md that references the org-level standards" \ "AGENTS.md" @@ -2646,7 +2646,7 @@ check_agents_md() { decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") # For repos other than .github, AGENTS.md should reference the org-level .github/AGENTS.md - if [ "$repo" != ".github" ]; then + if [[ "$repo" != ".github" ]]; then # Accept two forms of reference: # 1. Any path containing .github/AGENTS.md (relative link text or path reference) # 2. GitHub blob URL format: /combsmith/.github/blob//AGENTS.md (in href) @@ -2682,7 +2682,7 @@ check_copilot_setup_steps() { content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/copilot-setup-steps.yml" \ --jq '.content' 2>/dev/null || echo "") - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then add_finding "$repo" "standards" "missing-copilot-setup-steps" "warning" \ "Missing \`.github/workflows/copilot-setup-steps.yml\` — every repo should pre-install tools and dependencies for the Copilot cloud agent. Copy the template from the org standards and uncomment the stack block(s) for this repo." \ "standards/ci-standards.md" @@ -2767,13 +2767,13 @@ check_copilot_instructions() { # The .github repo holds the canonical template — exempt from the # per-repo requirement. - [ "$repo" = ".github" ] && return + [[ "$repo" = ".github" ]] && return local content content=$(gh_api "repos/$ORG/$repo/contents/.github/copilot-instructions.md" \ --jq '.content' 2>/dev/null || echo "") - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then add_finding "$repo" "standards" "missing-copilot-instructions" "error" \ "Missing \`.github/copilot-instructions.md\`. Every repo must have its own Copilot instructions file — Copilot instruction files are repository-scoped and do not propagate from the \`combsmith/.github\` repo. Copy the canonical template from \`standards/copilot-instructions-standard.md\` in \`combsmith/.github\`, then tailor it with this repo's specific tech stack, project structure, local dev commands, required environment variables, and testing configuration." \ "standards/copilot-instructions-standard.md" @@ -2819,19 +2819,19 @@ check_check_suite_prefs() { local prefs="" rc=1 i for i in 1 2 3; do prefs=$(gh api "repos/$ORG/$repo/check-suites/preferences" 2>&1) && rc=0 || rc=$? - if [ "$rc" -eq 0 ]; then + if [[ "$rc" -eq 0 ]]; then break fi # 404 is deterministic and benign — stop retrying and report nothing. if grep -q 'HTTP 404' <<< "$prefs"; then return 0 fi - if [ "$i" -lt 3 ]; then + if [[ "$i" -lt 3 ]]; then sleep $((i * 2)) fi done - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then add_finding "$repo" "settings" "check-suite-prefs-unreadable" "warning" \ "Could not read check-suite preferences — verify GH_TOKEN has repo scope and the repo is accessible. Check-suite auto-trigger compliance was not evaluated." \ "standards/github-settings.md" @@ -2844,12 +2844,12 @@ check_check_suite_prefs() { '.preferences.auto_trigger_checks // [] | map(select(.app_id == $id)) | first | .setting // "missing"') # "missing" means the app has never run in this repo — no orphaned suite possible - [ "$setting" = "missing" ] && continue - [ "$setting" = "false" ] && continue + [[ "$setting" = "missing" ]] && continue + [[ "$setting" = "false" ]] && continue local app_label="app_id=$app_id" - [ "$app_id" = "1236702" ] && app_label="Claude (1236702)" - [ "$app_id" = "347564" ] && app_label="CodeRabbit (347564)" + [[ "$app_id" = "1236702" ]] && app_label="Claude (1236702)" + [[ "$app_id" = "347564" ]] && app_label="CodeRabbit (347564)" add_finding "$repo" "settings" "check-suite-auto-trigger-${app_id}" "error" \ "$app_label auto-trigger is enabled: GitHub creates a queued check suite on every push that is never completed, permanently blocking auto-merge. Run: \`bash scripts/apply-repo-settings.sh $repo\`" \ @@ -2913,7 +2913,7 @@ ensure_required_labels() { swap_rulesets_finding_to_hands_off() { local repo="$1" issue="$2" - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then info "[dry-run] would swap \`dev-lead\` -> \`dev-lead:hands-off\` on $ORG/$repo#$issue (ruleset finding)" return 0 fi @@ -2926,7 +2926,7 @@ swap_rulesets_finding_to_hands_off() { local swap_ok=true gh issue edit "$issue" --repo "$ORG/$repo" --add-label "$DEV_LEAD_HANDS_OFF_LABEL" 2>/dev/null || swap_ok=false gh issue edit "$issue" --repo "$ORG/$repo" --remove-label "$DEV_LEAD_LABEL" 2>/dev/null || swap_ok=false - if [ "$swap_ok" != "true" ]; then + if [[ "$swap_ok" != "true" ]]; then warn "Failed to fully route ruleset finding #$issue in $repo to \`dev-lead:hands-off\` (label add/remove failed — issue may be incorrectly routed)" return 1 fi @@ -2956,12 +2956,12 @@ create_issue_for_finding() { | head -1 || echo "") # Only a plain issue number may reach `gh issue edit/comment` below. - if [ -n "$existing" ] && ! [[ "$existing" =~ ^[0-9]+$ ]]; then + if [[ -n "$existing" ]] && ! [[ "$existing" =~ ^[0-9]+$ ]]; then warn "Ignoring non-numeric existing-issue lookup result in $repo for: $check — not updating or creating an issue" return 0 fi - if [ -n "$existing" ]; then + if [[ -n "$existing" ]]; then # Migrate the machine-owned finding label onto pre-existing issues so the # auto-closer (which keys on FINDING_LABEL) can retire them once resolved # (issue #1036, AC1c). Idempotent; a no-op if already present. @@ -2977,7 +2977,7 @@ This finding is still open. **Detail:** $detail **Standard:** [$standard_ref](https://github.com/$ORG/.github/blob/main/$standard_ref)" 2>/dev/null || update_ok=false - if [ "$update_ok" = "true" ]; then + if [[ "$update_ok" = "true" ]]; then info "Updated existing issue #$existing in $repo for: $check" ISSUES_EXISTING=$((ISSUES_EXISTING + 1)) else @@ -2989,13 +2989,13 @@ This finding is still open. # we swap the actor label to dev-lead:hands-off. The comment update above # already re-reported the finding (AC3) — hands-off removes the actor without # resolving the finding; convergence happens via apply-rulesets.sh (#1045). - if [ "$category" = "rulesets" ]; then + if [[ "$category" = "rulesets" ]]; then # Only drop the active dev-lead route once the re-report comment above # actually landed. If the update failed (update_ok=false), swapping to # hands-off would remove dev-lead without a successful re-report, leaving # the finding with no active route; keep dev-lead in place so the next # audit retries. - if [ "$update_ok" = "true" ]; then + if [[ "$update_ok" = "true" ]]; then swap_rulesets_finding_to_hands_off "$repo" "$existing" else warn "Skipped routing ruleset finding #$existing in $repo to hands-off — re-report comment failed; leaving dev-lead in place for retry" @@ -3106,14 +3106,14 @@ ${AUDIT_GENERATED_MARKER}" --label "$actor_label" \ --body "$body" 2>/dev/null || echo "") - if [ -n "$issue_url" ]; then + if [[ -n "$issue_url" ]]; then local new_issue new_issue=$(echo "$issue_url" | grep -oE '[0-9]+$' || echo "") info "Created issue #$new_issue in $repo for: $check ($issue_url)" ISSUES_ADDED=$((ISSUES_ADDED + 1)) # Record created issue for umbrella - if [ -n "$new_issue" ]; then + if [[ -n "$new_issue" ]]; then jq --null-input \ --arg repo "$repo" \ --arg category "$category" \ @@ -3139,7 +3139,7 @@ create_umbrella_issue() { # Skip if no findings local total_findings total_findings=$(jq length "$FINDINGS_FILE") - if [ "$total_findings" -eq 0 ]; then + if [[ "$total_findings" -eq 0 ]]; then info "No findings — skipping umbrella issue" return fi @@ -3154,7 +3154,7 @@ create_umbrella_issue() { -q ".[] | select(.title == \"$title\") | .number" \ 2>/dev/null | head -1 || echo "") - if [ -n "$existing_umbrella" ]; then + if [[ -n "$existing_umbrella" ]]; then info "Umbrella issue #$existing_umbrella already exists for $audit_date — skipping" return fi @@ -3194,7 +3194,7 @@ Findings are grouped by remediation category. Address each category together to local cat_count cat_count=$(echo "$cat_findings" | jq 'length') - [ "$cat_count" -eq 0 ] && continue + [[ "$cat_count" -eq 0 ]] && continue local affected_repos affected_repos=$(echo "$cat_findings" | jq -r '[.[].repo] | unique | join(", ")') @@ -3217,12 +3217,12 @@ Findings are grouped by remediation category. Address each category together to # Look up issue link if we tracked it local issue_link="" - if [ -s "$ISSUES_FILE" ]; then + if [[ -s "$ISSUES_FILE" ]]; then local issue_entry issue_entry=$(grep -F "\"repo\":\"$f_repo\"" "$ISSUES_FILE" 2>/dev/null | \ jq -c --arg repo "$f_repo" --arg check "$f_check" \ 'select(.repo == $repo and .check == $check)' 2>/dev/null | head -1 || echo "") - if [ -n "$issue_entry" ]; then + if [[ -n "$issue_entry" ]]; then f_number=$(echo "$issue_entry" | jq -r '.number') f_url=$(echo "$issue_entry" | jq -r '.url') issue_link=" ([#$f_number]($f_url))" @@ -3249,7 +3249,7 @@ Findings are grouped by remediation category. Address each category together to --label "dev-lead" \ --body "$body" 2>/dev/null || echo "") - if [ -n "$umbrella_url" ]; then + if [[ -n "$umbrella_url" ]]; then info "Created umbrella issue: $umbrella_url" else warn "Failed to create umbrella issue in $ORG/.github" @@ -3288,11 +3288,11 @@ close_resolved_issues() { # repo is a failed scan, not a compliant org. local total_findings total_findings=$(jq 'length' "$FINDINGS_FILE" 2>/dev/null || echo "") - if [ -z "$total_findings" ]; then + if [[ -z "$total_findings" ]]; then warn "Could not read total finding count from $FINDINGS_FILE — skipping issue closure (fail closed)" return fi - if [ "$total_findings" -eq 0 ]; then + if [[ "$total_findings" -eq 0 ]]; then warn "Total finding count is zero across all repos — treating as a failed scan and refusing to close any issue (issue #1036, AC3)" return fi @@ -3305,7 +3305,7 @@ close_resolved_issues() { --state open \ --limit 1000 \ --json number,title,body 2>/dev/null || echo "[]") - [ -z "$open_issues_json" ] && open_issues_json="[]" + [[ -z "$open_issues_json" ]] && open_issues_json="[]" # Get current findings for this repo (bail if jq fails to avoid false closures) local current_checks @@ -3318,7 +3318,7 @@ close_resolved_issues() { # body, so a multi-line body cannot corrupt the loop). local obj while IFS= read -r obj; do - [ -z "$obj" ] && continue + [[ -z "$obj" ]] && continue local issue_num issue_title issue_body issue_num=$(jq -r '.number' <<< "$obj") issue_title=$(jq -r '.title' <<< "$obj") @@ -3349,11 +3349,11 @@ close_resolved_issues() { # never close a possibly-live finding (#1401). local match_rc=0 grep -Fxq -- "$check_name" <<< "$current_checks" || match_rc=$? - if [ "$match_rc" -gt 1 ]; then + if [[ "$match_rc" -gt 1 ]]; then warn "Could not evaluate whether check '$check_name' is still present in $repo (grep exit $match_rc) — not closing #$issue_num" continue fi - if [ "$match_rc" -eq 1 ]; then + if [[ "$match_rc" -eq 1 ]]; then if gh issue close "$issue_num" --repo "$ORG/$repo" \ --comment "Resolved! This check is now passing as of $(date -u +%Y-%m-%d). Closing automatically." \ 2>/dev/null; then @@ -3383,7 +3383,7 @@ print_planned_issue_actions() { local p_repo p_check while IFS=$'\t' read -r p_repo p_check; do - [ -z "$p_repo" ] && continue + [[ -z "$p_repo" ]] && continue info " would file/update: $ORG/$p_repo — Compliance: $p_check" done <<< "$planned_actions" } @@ -3415,7 +3415,7 @@ generate_summary() { HEREDOC - if [ "$total_findings" -eq 0 ]; then + if [[ "$total_findings" -eq 0 ]]; then echo "All repositories are fully compliant! No findings." >> "$SUMMARY_FILE" return fi @@ -3475,7 +3475,7 @@ HEREDOC for category in ci-workflows reusable-workflows action-pinning dependabot settings push-protection labels rulesets standards; do local cat_count cat_count=$(jq --arg cat "$category" '[.[] | select(.category == $cat)] | length' "$FINDINGS_FILE") - if [ "$cat_count" -gt 0 ]; then + if [[ "$cat_count" -gt 0 ]]; then echo "- **$category:** $cat_count finding(s)" >> "$SUMMARY_FILE" fi done @@ -3486,7 +3486,7 @@ HEREDOC # Issue & PR link summary (appended after issue creation) # --------------------------------------------------------------------------- append_issue_pr_links() { - [ -s "$ISSUES_FILE" ] || return + [[ -s "$ISSUES_FILE" ]] || return # Collect open PRs per affected repo (one GraphQL call per repo) to find # those whose closingIssuesReferences include one of our compliance issues. @@ -3547,7 +3547,7 @@ HEREDOC ' "$ISSUES_FILE" 2>/dev/null || echo "") while IFS= read -r check; do - [ -z "$check" ] && continue + [[ -z "$check" ]] && continue local check_issues severity category issue_count check_issues=$(jq -cn --arg c "$check" '[inputs | select(.check == $c)] | sort_by(.repo)' "$ISSUES_FILE") @@ -3596,7 +3596,7 @@ HEREDOC # observable from the audit issue history (issue #647, AC #1). structural_finding_count() { local file="$1" - [ -s "$file" ] || { printf '0'; return 0; } + [[ -s "$file" ]] || { printf '0'; return 0; } # Branch on grep's exit status instead of discarding it. grep exits 1 on zero # matches (a genuine finding-free cycle → 0) but exits >=2 on an actual error # (e.g. a non-empty file that cannot be read). Swallowing the latter would @@ -3613,8 +3613,8 @@ structural_finding_count() { # the function is invoked — including from the bats tests that call it directly. local n rc if n="$(grep -c . "$file" 2>/dev/null)"; then rc=0; else rc=$?; fi - if [ "$rc" -eq 0 ]; then printf '%s' "$n"; return 0; fi - if [ "$rc" -eq 1 ]; then printf '0'; return 0; fi + if [[ "$rc" -eq 0 ]]; then printf '%s' "$n"; return 0; fi + if [[ "$rc" -eq 1 ]]; then printf '0'; return 0; fi printf '::error::structural_finding_count: cannot read %s (grep exit %s)\n' "$file" "$rc" >&2 return 1 } @@ -3633,7 +3633,7 @@ append_structural_findings_summary() { # Mark the cycle INDETERMINATE so it can never be recorded as a clean # zero-finding cycle (issue #647, AC #1). local lint_incomplete="" - if [ -f "$STRUCTURAL_LINT_ERROR_FILE" ] && [ -s "$STRUCTURAL_LINT_ERROR_FILE" ]; then + if [[ -f "$STRUCTURAL_LINT_ERROR_FILE" ]] && [[ -s "$STRUCTURAL_LINT_ERROR_FILE" ]]; then lint_incomplete="yes" fi { @@ -3645,7 +3645,7 @@ append_structural_findings_summary() { # the maintainer's confirmed-false-positive determination. The audit cannot # itself confirm false positives (that is a human review), so it records the # count and points at the append-only committed cycle log. - if [ -n "$lint_incomplete" ]; then + if [[ -n "$lint_incomplete" ]]; then printf '**Structural findings this cycle: INDETERMINATE.** The structural linter did not complete for one or more repositories this cycle, so the finding count is not trustworthy. **This cycle MUST NOT be recorded as clean** in the append-only [cycle log](%s) — investigate the linter failure and re-run before recording any determination. Promotion is never automatic.\n\n' \ "$cycle_log_link" else @@ -3654,8 +3654,8 @@ append_structural_findings_summary() { fi } >> "$SUMMARY_FILE" - if [ ! -s "$STRUCTURAL_FINDINGS_FILE" ]; then - if [ -n "$lint_incomplete" ]; then + if [[ ! -s "$STRUCTURAL_FINDINGS_FILE" ]]; then + if [[ -n "$lint_incomplete" ]]; then printf 'No findings were recorded, but the structural linter did not complete for every scanned repo this cycle — see the INDETERMINATE note above. This is **not** a clean cycle.\n' >> "$SUMMARY_FILE" else printf 'No structural findings — every scanned `AGENTS.md` is structurally valid.\n' >> "$SUMMARY_FILE" @@ -3670,7 +3670,7 @@ append_structural_findings_summary() { local repo sev rule line msg while IFS=$'\t' read -r repo sev rule line msg; do - [ -n "$repo" ] || continue + [[ -n "$repo" ]] || continue # A finding message can contain a literal '|' (e.g. a rule's alternation # regex like /standard|development standards/); escape it so it stays inside # the cell instead of splitting the markdown table column. @@ -3684,7 +3684,7 @@ append_structural_findings_summary() { main() { # Explicit apply flag (issue #1036, AC4). --apply enables mutation; it can also # be enabled via COMPLIANCE_AUDIT_APPLY=true (see APPLY default above). - while [ "$#" -gt 0 ]; do + while [[ "$#" -gt 0 ]]; do case "$1" in --apply) APPLY="true"; shift ;; --) shift; break ;; @@ -3693,7 +3693,7 @@ main() { done # Preflight: verify GH_TOKEN is set and gh CLI is authenticated - if [ -z "${GH_TOKEN:-}" ]; then + if [[ -z "${GH_TOKEN:-}" ]]; then echo "::error::GH_TOKEN is not set. Ensure ORG_SCORECARD_TOKEN secret is configured and passed as an env var to this step." \ "Job-level env vars should be inherited, but add GH_TOKEN explicitly to the step env block as a workaround." >&2 exit 1 @@ -3724,7 +3724,7 @@ main() { local repos repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500) - if [ -z "$repos" ]; then + if [[ -z "$repos" ]]; then warn "No repositories found in $ORG — check GH_TOKEN permissions" echo "[]" > "$FINDINGS_FILE" return 1 @@ -3737,7 +3737,7 @@ main() { log "Auditing $ORG/$repo" detect_ecosystems "$repo" - if [ ${#ECOSYSTEMS[@]} -eq 0 ]; then + if [[ ${#ECOSYSTEMS[@]} -eq 0 ]]; then info "Detected ecosystems: none" else info "Detected ecosystems: ${ECOSYSTEMS[*]}" @@ -3746,7 +3746,7 @@ main() { # Fetch full repo JSON once and share with settings/push-protection checks local repo_json repo_json=$(gh_api "repos/$ORG/$repo" 2>/dev/null || echo "{}") - if [ "$repo_json" = "{}" ]; then + if [[ "$repo_json" = "{}" ]]; then add_finding "$repo" "settings" "repo_metadata_unavailable" "error" \ "Could not fetch repository metadata; settings and push-protection checks were skipped" \ "standards/github-settings.md#repository-settings--standard-defaults" @@ -3801,7 +3801,7 @@ main() { append_structural_findings_summary # Create/update/close issues — only when mutation is explicitly enabled (AC4). - if mutations_enabled && [ "$CREATE_ISSUES" = "true" ]; then + if mutations_enabled && [[ "$CREATE_ISSUES" = "true" ]]; then info "Managing issues..." for repo in $repos; do @@ -3810,7 +3810,7 @@ main() { # Create issues for new findings (process substitution avoids subshell) while IFS= read -r finding; do - [ -z "$finding" ] && continue + [[ -z "$finding" ]] && continue local f_check f_severity f_detail f_standard_ref f_category f_category=$(echo "$finding" | jq -r '.category') f_check=$(echo "$finding" | jq -r '.check') @@ -3832,7 +3832,7 @@ main() { # Append per-check issue links and related open PRs to the step summary info "Fetching linked PRs for issue summary..." append_issue_pr_links - elif [ "$CREATE_ISSUES" = "true" ]; then + elif [[ "$CREATE_ISSUES" = "true" ]]; then # Read-only (or dry-run): report what issue management WOULD do, mutate nothing (AC4). info "Read-only — issue management skipped (APPLY=$APPLY, DRY_RUN=$DRY_RUN). Planned actions:" print_planned_issue_actions @@ -3841,7 +3841,7 @@ main() { fi # Write issue-management counts and append to summary (conditional on issue management running) - if mutations_enabled && [ "$CREATE_ISSUES" = "true" ]; then + if mutations_enabled && [[ "$CREATE_ISSUES" = "true" ]]; then printf '{"added":%d,"existing":%d,"removed":%d,"retriggered":%d}\n' \ "$ISSUES_ADDED" "$ISSUES_EXISTING" "$ISSUES_REMOVED" "$ISSUES_RETRIGGERED" > "$ISSUE_COUNTS_FILE" cat >> "$SUMMARY_FILE" <&2 echo " On macOS: brew install bash, then run with /opt/homebrew/bin/bash" >&2 exit 1 @@ -128,7 +128,7 @@ report_fail() { remediate_setting() { local repo="$1" setting="$2" expected_value="$3" check="$4" - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "[DRY] Would PATCH $ORG/$repo: $setting=$expected_value" report_direct "$repo" "$check" "DRY: would PATCH \`$setting=$expected_value\`" return 0 @@ -187,7 +187,7 @@ remediate_security_analysis_setting() { remediate_check_suite_auto_trigger() { local repo="$1" app_id="$2" check="$3" - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "[DRY] Would disable check-suite auto-trigger for app $app_id on $ORG/$repo" report_direct "$repo" "$check" "DRY: would set \`auto_trigger=false\` for app_id=$app_id" return 0 @@ -212,7 +212,7 @@ remediate_check_suite_auto_trigger() { --input - 2>&1 > /dev/null) http_status=$? - if [ "$http_status" -eq 0 ]; then + if [[ "$http_status" -eq 0 ]]; then ok "Disabled check-suite auto-trigger for app $app_id on $ORG/$repo" report_direct "$repo" "$check" "Set \`auto_trigger=false\` for app_id=$app_id" elif echo "$api_response" | grep -qE '(HTTP 40[34]|Not Found|Resource not accessible)'; then @@ -242,7 +242,7 @@ remediate_label() { IFS='|' read -r _ color desc <<< "$spec" fi - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "[DRY] Would create label '$label' in $ORG/$repo" report_direct "$repo" "missing-label-$label" "DRY: would create label \`$label\`" return 0 @@ -294,7 +294,7 @@ remediate_codeowners() { * @combsmith/org-leads " - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "[DRY] Would open CODEOWNERS PR in $ORG/$repo" report_pr "$repo" "$check" "DRY: would open PR creating/updating \`.github/CODEOWNERS\` with \`@combsmith/org-leads\`" return 0 @@ -307,7 +307,7 @@ remediate_codeowners() { local base_sha base_sha=$(gh api "repos/$ORG/$repo/git/refs/heads/$default_branch" --jq '.object.sha' 2>/dev/null || echo "") - if [ -z "$base_sha" ]; then + if [[ -z "$base_sha" ]]; then warn "Could not get HEAD SHA for $ORG/$repo/$default_branch" report_fail "$repo" "$check" "Could not resolve HEAD SHA for $default_branch" return 0 @@ -320,7 +320,7 @@ remediate_codeowners() { for path in .github/CODEOWNERS CODEOWNERS docs/CODEOWNERS; do local existing_resp existing_resp=$(gh api "repos/$ORG/$repo/contents/$path" 2>/dev/null || echo "") - if [ -n "$existing_resp" ] && echo "$existing_resp" | jq -e '.sha' > /dev/null 2>&1; then + if [[ -n "$existing_resp" ]] && echo "$existing_resp" | jq -e '.sha' > /dev/null 2>&1; then existing_file_sha=$(echo "$existing_resp" | jq -r '.sha') existing_path="$path" break @@ -351,7 +351,7 @@ remediate_codeowners() { -f content="$encoded_content" -f branch="$branch_name") # If the file already exists, we need to supply its SHA so the PUT updates rather than creates - [ -n "$existing_file_sha" ] && api_args+=(-f sha="$existing_file_sha") + [[ -n "$existing_file_sha" ]] && api_args+=(-f sha="$existing_file_sha") if ! gh api "${api_args[@]}" > /dev/null 2>&1; then err "Failed to create/update CODEOWNERS in $ORG/$repo" @@ -364,7 +364,7 @@ remediate_codeowners() { ensure_compliance_label "$repo" local action_verb="Adds" - [ -n "$existing_file_sha" ] && action_verb="Updates" + [[ -n "$existing_file_sha" ]] && action_verb="Updates" local pr_url pr_url=$(gh pr create \ @@ -397,7 +397,7 @@ Ref: \`standards/codeowners-standard.md\` *Auto-generated by \`scripts/compliance-remediate.sh\`.*" \ --label "compliance-audit" 2>/dev/null || echo "") - if [ -n "$pr_url" ]; then + if [[ -n "$pr_url" ]]; then ok "Opened CODEOWNERS PR in $ORG/$repo: $pr_url" report_pr "$repo" "$check" "$pr_url" else @@ -444,7 +444,7 @@ remediate_unpinned_actions() { local raw_content raw_content=$(gh api "repos/$ORG/$repo/contents/.github/workflows/$workflow_file" 2>/dev/null || echo "") - if [ -z "$raw_content" ]; then + if [[ -z "$raw_content" ]]; then warn "Could not fetch $workflow_file from $ORG/$repo" report_fail "$repo" "unpinned-actions-$workflow_file" "Could not fetch workflow file" return 0 @@ -454,14 +454,14 @@ remediate_unpinned_actions() { encoded=$(echo "$raw_content" | jq -r '.content // empty') file_sha=$(echo "$raw_content" | jq -r '.sha // empty') - if [ -z "$encoded" ] || [ -z "$file_sha" ]; then + if [[ -z "$encoded" ]] || [[ -z "$file_sha" ]]; then report_fail "$repo" "unpinned-actions-$workflow_file" "Could not parse file content or SHA" return 0 fi local decoded decoded=$(printf '%s' "$encoded" | b64_decode 2>/dev/null || echo "") - [ -z "$decoded" ] && { report_fail "$repo" "unpinned-actions-$workflow_file" "base64 decode failed"; return 0; } + [[ -z "$decoded" ]] && { report_fail "$repo" "unpinned-actions-$workflow_file" "base64 decode failed"; return 0; } # Find all unpinned uses: directives local unpinned_refs @@ -472,7 +472,7 @@ remediate_unpinned_actions() { | awk '{print $2}' \ | sort -u || true) - if [ -z "$unpinned_refs" ]; then + if [[ -z "$unpinned_refs" ]]; then info "No unpinned actions found in $workflow_file (may already be fixed)" report_skip "$repo" "unpinned-actions-$workflow_file" "No unpinned actions found in current file (re-audit may resolve)" return 0 @@ -483,7 +483,7 @@ remediate_unpinned_actions() { local pins_failed=0 while IFS= read -r ref; do - [ -z "$ref" ] && continue + [[ -z "$ref" ]] && continue local action_part tag_part repo_part action_part="${ref%@*}" @@ -504,7 +504,7 @@ remediate_unpinned_actions() { # commits APIs take `owner/repo`, so peel off any trailing path segments # while keeping `action_part` intact for the replacement. repo_part=$(echo "$action_part" | awk -F/ 'NF>=2 {print $1"/"$2}') - if [ -z "$repo_part" ]; then + if [[ -z "$repo_part" ]]; then warn "Could not parse owner/repo from $ref — skipping" pins_failed=$((pins_failed + 1)) continue @@ -523,12 +523,12 @@ remediate_unpinned_actions() { local ref_json ref_json=$(gh api "repos/$repo_part/git/refs/tags/$tag_part" 2>/dev/null || echo "") - if [ -n "$ref_json" ]; then + if [[ -n "$ref_json" ]]; then local obj_type obj_sha obj_type=$(echo "$ref_json" | jq -r '.object.type // empty') obj_sha=$(echo "$ref_json" | jq -r '.object.sha // empty') - if [ "$obj_type" = "tag" ]; then + if [[ "$obj_type" = "tag" ]]; then # Annotated tag — dereference the tag object commit_sha=$(gh api "repos/$repo_part/git/tags/$obj_sha" \ --jq '.object.sha' 2>/dev/null || echo "") @@ -538,12 +538,12 @@ remediate_unpinned_actions() { fi # Fallback: commits API - if [ -z "$commit_sha" ]; then + if [[ -z "$commit_sha" ]]; then commit_sha=$(gh api "repos/$repo_part/commits?sha=$tag_part&per_page=1" \ --jq '.[0].sha' 2>/dev/null || echo "") fi - if [ -z "$commit_sha" ]; then + if [[ -z "$commit_sha" ]]; then warn "Could not resolve SHA for $ref — skipping" pins_failed=$((pins_failed + 1)) continue @@ -565,14 +565,14 @@ remediate_unpinned_actions() { pins_applied=$((pins_applied + 1)) done <<< "$unpinned_refs" - if [ "$pins_applied" -eq 0 ]; then + if [[ "$pins_applied" -eq 0 ]]; then info "No actions could be pinned in $workflow_file (all lookups failed)" report_fail "$repo" "unpinned-actions-$workflow_file" \ "SHA resolution failed for all $pins_failed action(s)" return 0 fi - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then skip "[DRY] Would open PR pinning $pins_applied action(s) in $ORG/$repo/.github/workflows/$workflow_file" report_pr "$repo" "unpinned-actions-$workflow_file" \ "DRY: would open PR pinning $pins_applied action(s)" @@ -587,7 +587,7 @@ remediate_unpinned_actions() { base_sha=$(gh api "repos/$ORG/$repo/git/refs/heads/$default_branch" \ --jq '.object.sha' 2>/dev/null || echo "") - if [ -z "$base_sha" ]; then + if [[ -z "$base_sha" ]]; then report_fail "$repo" "unpinned-actions-$workflow_file" "Could not resolve HEAD SHA" return 0 fi @@ -612,7 +612,7 @@ remediate_unpinned_actions() { new_encoded=$(printf '%s' "$patched_content" | b64_encode) local failed_note="" - [ "$pins_failed" -gt 0 ] && \ + [[ "$pins_failed" -gt 0 ]] && \ failed_note=" $pins_failed action(s) could not be resolved and were left unpinned." @@ -635,7 +635,7 @@ Ref: standards/ci-standards.md#action-pinning-policy" ensure_compliance_label "$repo" local pr_failed_note="" - [ "$pins_failed" -gt 0 ] && pr_failed_note=" + [[ "$pins_failed" -gt 0 ]] && pr_failed_note=" > **Note:** $pins_failed action(s) could not be resolved to SHAs and were left unchanged. > Review the workflow file and pin them manually." @@ -662,7 +662,7 @@ Ref: \`standards/ci-standards.md#action-pinning-policy\` *Auto-generated by \`scripts/compliance-remediate.sh\` to resolve \`unpinned-actions-$workflow_file\`.*" \ --label "compliance-audit" 2>/dev/null || echo "") - if [ -n "$pr_url" ]; then + if [[ -n "$pr_url" ]]; then ok "Opened action-pinning PR in $ORG/$repo: $pr_url" report_pr "$repo" "unpinned-actions-$workflow_file" "$pr_url" else @@ -732,14 +732,14 @@ remediate_gitignore_baseline() { fi # Idempotency: identical content means the repo already carries the baseline. - if [ "$existing" = "$upserted" ]; then + if [[ "$existing" = "$upserted" ]]; then rm -f "$block_file" "$existing_file" info "$ORG/$repo already carries the current baseline — no-op" report_skip "$repo" "$check" "\`.gitignore\` already carries the current org secrets-baseline block (no-op)" return 0 fi - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then rm -f "$block_file" "$existing_file" skip "[DRY] Would open .gitignore baseline PR in $ORG/$repo" report_pr "$repo" "$check" "DRY: would open PR upserting the org secrets baseline into \`.gitignore\`" @@ -940,17 +940,17 @@ remediate_finding() { # Main # --------------------------------------------------------------------------- main() { - if [ -z "$FINDINGS_FILE" ]; then + if [[ -z "$FINDINGS_FILE" ]]; then err "FINDINGS_FILE is required — set it to the path of findings.json from compliance-audit.sh" exit 1 fi - if [ ! -f "$FINDINGS_FILE" ]; then + if [[ ! -f "$FINDINGS_FILE" ]]; then err "FINDINGS_FILE not found: $FINDINGS_FILE" exit 1 fi - if [ -z "${GH_TOKEN:-}" ]; then + if [[ -z "${GH_TOKEN:-}" ]]; then err "GH_TOKEN is required" exit 1 fi @@ -995,7 +995,7 @@ HEADER fi rm -f "$jq_err_file" - if [ -z "$repos" ]; then + if [[ -z "$repos" ]]; then info "No findings to remediate — nothing to do" exit 0 fi @@ -1004,7 +1004,7 @@ HEADER info "--- Remediating findings for $ORG/$repo ---" while IFS= read -r finding; do - [ -z "$finding" ] && continue + [[ -z "$finding" ]] && continue local category check category=$(echo "$finding" | jq -r '.category') @@ -1051,13 +1051,13 @@ SUMMARY cat "$REMEDIATION_REPORT" - if [ "$skipped_count" -gt 0 ] || [ "$failed_count" -gt 0 ]; then + if [[ "$skipped_count" -gt 0 ]] || [[ "$failed_count" -gt 0 ]]; then echo "" cat "$SKIPPED_REPORT" fi # Exit non-zero only if there were unexpected failures (skips are normal) - [ "$failed_count" -gt 0 ] && exit 1 || exit 0 + [[ "$failed_count" -gt 0 ]] && exit 1 || exit 0 } main "$@" diff --git a/scripts/compliance-retrigger.sh b/scripts/compliance-retrigger.sh index ca01472ec..6135bb688 100644 --- a/scripts/compliance-retrigger.sh +++ b/scripts/compliance-retrigger.sh @@ -94,7 +94,7 @@ error() { echo "[error] $*" >&2; } # exhausted. Both overridable via env so tests can run with zero delay. GH_SEARCH_ATTEMPTS="${GH_SEARCH_ATTEMPTS:-3}" GH_API_RETRY_BASE_DELAY="${GH_API_RETRY_BASE_DELAY:-2}" -if ! [[ "$GH_SEARCH_ATTEMPTS" =~ ^[0-9]+$ ]] || [ "$GH_SEARCH_ATTEMPTS" -lt 1 ]; then +if ! [[ "$GH_SEARCH_ATTEMPTS" =~ ^[0-9]+$ ]] || [[ "$GH_SEARCH_ATTEMPTS" -lt 1 ]]; then error "GH_SEARCH_ATTEMPTS must be an integer >= 1 (got: '${GH_SEARCH_ATTEMPTS}')" exit 1 fi @@ -113,8 +113,8 @@ gh_api_search() { GH_API_SEARCH_OUT="" for ((attempt=1; attempt<=GH_SEARCH_ATTEMPTS; attempt++)); do GH_API_SEARCH_OUT=$(gh api "$@" 2>&1) && rc=0 || rc=$? - [ "$rc" -eq 0 ] && return 0 - if [ "$attempt" -lt "$GH_SEARCH_ATTEMPTS" ]; then + [[ "$rc" -eq 0 ]] && return 0 + if [[ "$attempt" -lt "$GH_SEARCH_ATTEMPTS" ]]; then warn "search/issues API call failed (exit $rc), attempt ${attempt}/${GH_SEARCH_ATTEMPTS} — retrying in $((attempt * GH_API_RETRY_BASE_DELAY))s" sleep "$((attempt * GH_API_RETRY_BASE_DELAY))" fi @@ -162,7 +162,7 @@ retrigger_stale_issues() { && rc=0 || rc=$? raw="$GH_API_SEARCH_OUT" - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then error "search/issues API call failed (exit $rc) after ${GH_SEARCH_ATTEMPTS} attempts. Response:" echo "$raw" | head -5 >&2 error "Cannot retrigger issues; aborting. Check GH_TOKEN scope — token must be able to read issues across all repos in org ${ORG}." @@ -186,12 +186,12 @@ retrigger_stale_issues() { local issues issues=$(echo "$raw" | jq -c '.items[] | {number: .number, repo: (.repository_url | split("/") | last), created_at: .created_at, title: .title}') - if [ -z "$issues" ]; then + if [[ -z "$issues" ]]; then info "No open compliance-audit issues found with '$TRIGGER_LABEL' label." fi while IFS= read -r issue_json; do - [ -z "$issue_json" ] && continue + [[ -z "$issue_json" ]] && continue local number repo created_at title number=$(echo "$issue_json" | jq -r '.number') repo=$(echo "$issue_json" | jq -r '.repo') @@ -207,7 +207,7 @@ retrigger_stale_issues() { # One engagement per repo per run. If this repo already got an issue # re-triggered (or was found active) this run, defer the rest to a later sweep. - if [ -n "${REPO_ENGAGED[$repo]:-}" ]; then + if [[ -n "${REPO_ENGAGED[$repo]:-}" ]]; then info "Deferring $repo#$number ($title) — $repo already engaged this run (one issue per repo per run)" ISSUES_DEFERRED=$((ISSUES_DEFERRED + 1)) continue @@ -234,7 +234,7 @@ retrigger_stale_issues() { warn "Failed to re-trigger dev-lead on issue #$number in $repo — attempting to restore label" # The label may have been deleted but the re-add failed. Restore it so the # issue remains visible to the next sweep's search query. - if [ "$DRY_RUN" != "true" ]; then + if [[ "$DRY_RUN" != "true" ]]; then gh api -X POST "repos/$ORG/$repo/issues/$number/labels" \ --field "labels[]=$TRIGGER_LABEL" >/dev/null 2>&1 \ && info "Restored $TRIGGER_LABEL on $repo#$number" \ @@ -250,7 +250,7 @@ retrigger_stale_issues() { # compliance issues with the legacy label would otherwise be invisible to the # main search above and never retriggered. Adding TRIGGER_LABEL to each such # issue both recovers the lost event and acts as a lazy per-issue migration. - if [ -n "${LEGACY_TRIGGER_LABEL:-}" ] && [ "$LEGACY_TRIGGER_LABEL" != "$TRIGGER_LABEL" ]; then + if [[ -n "${LEGACY_TRIGGER_LABEL:-}" ]] && [[ "$LEGACY_TRIGGER_LABEL" != "$TRIGGER_LABEL" ]]; then info "Sweeping pre-migration '$LEGACY_TRIGGER_LABEL'-labeled issues..." local legacy_raw legacy_issues legacy_rc # Exclude issues that already carry TRIGGER_LABEL — they were handled above. @@ -261,7 +261,7 @@ retrigger_stale_issues() { "search/issues?q=org:${ORG}+label:${AUDIT_LABEL}+label:${LEGACY_TRIGGER_LABEL}+-label:${TRIGGER_LABEL}+state:open+is:issue&sort=created&order=asc&per_page=100" \ && legacy_rc=0 || legacy_rc=$? legacy_raw="$GH_API_SEARCH_OUT" - if [ "$legacy_rc" -ne 0 ]; then + if [[ "$legacy_rc" -ne 0 ]]; then warn "Legacy label sweep search failed (exit $legacy_rc after ${GH_SEARCH_ATTEMPTS} attempts) — pre-migration issues not swept this run" elif echo "$legacy_raw" | jq -se 'any(.[]; has("message") and (.items | not))' >/dev/null 2>&1; then warn "Legacy label sweep search returned an error response — pre-migration issues not swept this run" @@ -271,7 +271,7 @@ retrigger_stale_issues() { legacy_total=$(echo "$legacy_raw" | jq -rs '.[0].total_count // 0') info "Legacy sweep found ${legacy_total} matching issues" while IFS= read -r issue_json; do - [ -z "$issue_json" ] && continue + [[ -z "$issue_json" ]] && continue number=$(echo "$issue_json" | jq -r '.number') repo=$(echo "$issue_json" | jq -r '.repo') created_at=$(echo "$issue_json" | jq -r '.created_at') @@ -282,7 +282,7 @@ retrigger_stale_issues() { fi # Honour the shared one-engagement-per-repo budget set by the primary # sweep, so a repo already engaged above is not also label-bumped here. - if [ -n "${REPO_ENGAGED[$repo]:-}" ]; then + if [[ -n "${REPO_ENGAGED[$repo]:-}" ]]; then info "Deferring legacy $repo#$number ($title) — $repo already engaged this run" ISSUES_DEFERRED=$((ISSUES_DEFERRED + 1)) continue @@ -295,7 +295,7 @@ retrigger_stale_issues() { fi REPO_ENGAGED[$repo]=1 info "Adding '$TRIGGER_LABEL' to pre-migration issue $repo#$number: $title" - if [ "$DRY_RUN" = "true" ]; then + if [[ "$DRY_RUN" = "true" ]]; then info "[dry-run] would ensure '$TRIGGER_LABEL' label exists in $repo and add it to #$number" ISSUES_RETRIGGERED=$((ISSUES_RETRIGGERED + 1)) else @@ -337,7 +337,7 @@ print_summary() { echo " Issues deferred : ${ISSUES_DEFERRED} (repo already engaged this run)" echo "==========================================" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { echo "## Compliance Re-trigger Summary" echo "" diff --git a/scripts/cut-standards-release.sh b/scripts/cut-standards-release.sh index 4e4698566..8d8757f07 100755 --- a/scripts/cut-standards-release.sh +++ b/scripts/cut-standards-release.sh @@ -102,7 +102,7 @@ _gh_tag_commit() { err="$(mktemp)" ref_info="$(gh api "repos/$repo/git/ref/tags/$tag" \ --jq '[(.object?.sha // "" | tostring), (.object?.type // "" | tostring)] | @tsv' 2>"$err")"; rc=$? - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then if grep -qiE 'HTTP 404|not found|does not exist' "$err"; then rm -f "$err"; return 0 # tag genuinely absent → empty fi @@ -110,9 +110,9 @@ _gh_tag_commit() { rm -f "$err"; return 1 fi rm -f "$err" - [ -z "$ref_info" ] && return 0 + [[ -z "$ref_info" ]] && return 0 IFS=$'\t' read -r obj type <<< "$ref_info" - if [ "$type" = "tag" ]; then + if [[ "$type" = "tag" ]]; then gh api "repos/$repo/git/tags/$obj" --jq '(.object?.sha // "" | tostring)' 2>/dev/null || true else printf '%s\n' "$obj" @@ -126,9 +126,9 @@ _gh_tag_commit() { _gh_release_versions() { local repo="$1" ref v while IFS= read -r ref; do - [ -z "$ref" ] && continue + [[ -z "$ref" ]] && continue v="$(sr_version_from_tag "${ref#refs/tags/}")" - [ -n "$v" ] && printf '%s\n' "$v" + [[ -n "$v" ]] && printf '%s\n' "$v" done < <(gh api "repos/$repo/git/matching-refs/tags/standards/v" --jq '.[].ref' 2>/dev/null || true) } @@ -138,15 +138,15 @@ _gh_release_versions() { # refs (#1091 review). Channel tags dropped by sr_version_from_tag. _published_versions() { local v out=() - while IFS= read -r v; do [ -n "$v" ] && out+=("$v"); done < <(_gh_release_versions "$SR_REPO") + while IFS= read -r v; do [[ -n "$v" ]] && out+=("$v"); done < <(_gh_release_versions "$SR_REPO") # Numeric-desc sort via the pure comparator (repeatedly extract the max). local remaining=("${out[@]+"${out[@]}"}") max - while [ "${#remaining[@]}" -gt 0 ]; do + while [[ "${#remaining[@]}" -gt 0 ]]; do max="$(sr_max_version "${remaining[@]}")" - [ -z "$max" ] && break + [[ -z "$max" ]] && break printf '%s\n' "$max" local next=() r - for r in "${remaining[@]}"; do [ "$r" != "$max" ] && next+=("$r"); done + for r in "${remaining[@]}"; do [[ "$r" != "$max" ]] && next+=("$r"); done remaining=("${next[@]+"${next[@]}"}") done } @@ -157,15 +157,15 @@ _cmd_versions() { _published_versions; } # _cmd_resolve — print current + N-1 published versions and their channels (AC #4). _cmd_resolve() { local versions=() v cur prev - while IFS= read -r v; do [ -n "$v" ] && versions+=("$v"); done < <(_published_versions) + while IFS= read -r v; do [[ -n "$v" ]] && versions+=("$v"); done < <(_published_versions) IFS=$'\t' read -r cur prev < <(sr_current_and_previous "${versions[@]+"${versions[@]}"}") - if [ -z "$cur" ]; then + if [[ -z "$cur" ]]; then echo "current: (none published yet — run 'cut' to publish standards/v1.0.0)" echo "previous: (none)" return 0 fi printf 'current: standards/v%s (channel %s)\n' "$cur" "$(sr_channel_for "$cur")" - if [ -n "$prev" ]; then + if [[ -n "$prev" ]]; then printf 'previous: standards/v%s (channel %s)\n' "$prev" "$(sr_channel_for "$prev")" else echo 'previous: (none — single-version starting state; a consumer accepting' @@ -195,14 +195,14 @@ _gh_commit_sha() { # . Returns non-zero when no checkout is available. _consumer_checkout() { local repo="$1" work="$2" name="${1##*/}" dir - if [ -n "${SR_CONSUMERS_ROOT:-}" ]; then + if [[ -n "${SR_CONSUMERS_ROOT:-}" ]]; then dir="${SR_CONSUMERS_ROOT%/}/$name" - [ -d "$dir" ] || return 1 + [[ -d "$dir" ]] || return 1 printf '%s' "$dir" return 0 fi dir="$work/$name" - if [ ! -d "$dir" ]; then + if [[ ! -d "$dir" ]]; then gh repo clone "$repo" "$dir" -- --depth 1 --quiet >/dev/null 2>&1 || return 1 fi printf '%s' "$dir" @@ -214,7 +214,7 @@ _consumer_checkout() { # malformed row and an unavailable checkout all fail. _cmd_verify() { local ref="${1:-}" - [ -n "$ref" ] || _usage + [[ -n "$ref" ]] || _usage command -v jq >/dev/null 2>&1 || { echo "::error::verify needs jq" >&2; return 1; } local count count="$(jq -r '(.consumers // []) | length' "$SR_CONSUMERS_FILE" 2>/dev/null)" || count="" @@ -224,7 +224,7 @@ _cmd_verify() { fi local sha sha="$(_gh_commit_sha "$SR_REPO" "$ref")" - if [ -z "$sha" ]; then + if [[ -z "$sha" ]]; then echo "::error::could not resolve '$ref' to a commit on $SR_REPO — cannot verify" >&2 return 1 fi @@ -233,7 +233,7 @@ _cmd_verify() { local work i=0 failed=0 row id repo ref_env command expect reject dir out rc verdict work="$(mktemp -d)" - while [ "$i" -lt "$count" ]; do + while [[ "$i" -lt "$count" ]]; do row="$(jq -c --argjson i "$i" '.consumers[$i]' "$SR_CONSUMERS_FILE")" id="$(jq -r '.id // empty' <<< "$row")" repo="$(jq -r '.repo // empty' <<< "$row")" @@ -245,7 +245,7 @@ _cmd_verify() { : > "$out" # expect_output is required: without it a consumer would pass on its exit code # alone, which is exactly the hollow green this check exists to refuse. - if [ -z "$id" ] || [ -z "$repo" ] || [ -z "$command" ] || [ -z "$expect" ] \ + if [[ -z "$id" ]] || [[ -z "$repo" ]] || [[ -z "$command" ]] || [[ -z "$expect" ]] \ || ! [[ "$ref_env" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then verdict="FAIL malformed-entry" elif ! dir="$(_consumer_checkout "$repo" "$work")"; then @@ -256,9 +256,9 @@ _cmd_verify() { verdict="$(sr_consumer_verdict "$rc" "$out" "$expect" "$reject")" fi printf ' %-28s %-34s %s\n' "${id:-}" "${repo:-?}" "$verdict" - if [ "$verdict" != "PASS" ]; then + if [[ "$verdict" != "PASS" ]]; then failed=$((failed + 1)) - if [ -s "$out" ]; then + if [[ -s "$out" ]]; then echo " --- last lines of ${id:-row $i} output ---" tail -n 20 "$out" | sed 's/^/ /' fi @@ -267,7 +267,7 @@ _cmd_verify() { done rm -rf "$work" - if [ "$failed" -gt 0 ]; then + if [[ "$failed" -gt 0 ]]; then echo "::error::$failed of $count channel consumer(s) failed against ${sha:0:12} — do not promote the channel to this commit" >&2 return 1 fi @@ -278,10 +278,10 @@ _cmd_verify() { # release and move the channel. _cmd_cut() { local raw="${1:-}"; shift || true - [ -n "$raw" ] || _usage + [[ -n "$raw" ]] || _usage local version commit="" dry_run=0 verify=0 version="$(_normalize_version "$raw")" - while [ $# -gt 0 ]; do + while [[ $# -gt 0 ]]; do case "$1" in --commit) commit="${2:-}"; shift 2 ;; --dry-run) dry_run=1; shift ;; @@ -289,8 +289,8 @@ _cmd_cut() { *) echo "::error::unknown flag '$1'" >&2; _usage ;; esac done - [ -n "$commit" ] || commit="$(git rev-parse HEAD 2>/dev/null || true)" - if [ -z "$commit" ]; then + [[ -n "$commit" ]] || commit="$(git rev-parse HEAD 2>/dev/null || true)" + if [[ -z "$commit" ]]; then echo "::error::could not resolve a target commit (no --commit and no local HEAD)" >&2 exit 1 fi @@ -325,19 +325,19 @@ _cmd_cut() { # --verify (#1173): every channel consumer must pass against the target commit # before anything is written. It reads only, so a dry run verifies too. - if [ "$verify" -eq 1 ]; then + if [[ "$verify" -eq 1 ]]; then if ! _cmd_verify "$commit"; then echo "::error::REFUSE to cut $release_tag: channel-consumer verification failed at ${commit:0:12}. No tags written." >&2 exit 1 fi fi - if [ "$dry_run" -eq 1 ]; then + if [[ "$dry_run" -eq 1 ]]; then echo "[dry-run] no tags written." return 0 fi - if [ "$decision" = "CREATE" ]; then + if [[ "$decision" = "CREATE" ]]; then echo "creating immutable release $release_tag at ${commit:0:12}..." _gh_create_annotated_tag "$SR_REPO" "$release_tag" "$commit" "standards release $version" fi @@ -354,11 +354,11 @@ _cmd_cut() { major="$(sr_major "$version")" local -a same_major=("$version") while IFS= read -r v; do - [ -n "$v" ] || continue - if [ "$(sr_major "$v" || true)" = "$major" ]; then same_major+=("$v"); fi + [[ -n "$v" ]] || continue + if [[ "$(sr_major "$v" || true)" = "$major" ]]; then same_major+=("$v"); fi done < <(_gh_release_versions "$SR_REPO") highest="$(sr_max_version "${same_major[@]}")" - if [ "$highest" != "$version" ]; then + if [[ "$highest" != "$version" ]]; then echo "channel $channel_tag: a newer release standards/v$highest is already published on the v$major line; leaving the channel on the newer release (skipping backward move)." echo "done." return 0 @@ -370,12 +370,12 @@ _cmd_cut() { # regressing the channel to an older version; the higher cut will move it forward. local -a check_major=("$version") while IFS= read -r v; do - [ -n "$v" ] || continue - if [ "$(sr_major "$v" || true)" = "$major" ]; then check_major+=("$v"); fi + [[ -n "$v" ]] || continue + if [[ "$(sr_major "$v" || true)" = "$major" ]]; then check_major+=("$v"); fi done < <(_gh_release_versions "$SR_REPO") local check_highest check_highest="$(sr_max_version "${check_major[@]}")" - if [ "$check_highest" != "$version" ]; then + if [[ "$check_highest" != "$version" ]]; then echo "channel $channel_tag: release standards/v$check_highest is now published; leaving the channel on that newer release (skipping backward move)." return 0 fi @@ -403,7 +403,7 @@ _gh_create_annotated_tag() { --jq '.sha // empty')" || { echo "::error::could not create annotated tag $tag on $repo" >&2; return 1; } - [ -n "$obj" ] || { echo "::error::annotated tag $tag created but object SHA unreadable" >&2; return 1; } + [[ -n "$obj" ]] || { echo "::error::annotated tag $tag created but object SHA unreadable" >&2; return 1; } gh api -X POST "repos/$repo/git/refs" \ -f ref="refs/tags/$tag" -f sha="$obj" >/dev/null 2>&1 || { echo "::error::created tag object $obj on $repo but could not publish ref refs/tags/$tag" >&2; return 1; @@ -434,7 +434,7 @@ _gh_move_tag() { } main() { - [ $# -ge 1 ] || _usage + [[ $# -ge 1 ]] || _usage local cmd="$1"; shift case "$cmd" in cut) _cmd_cut "$@" ;; diff --git a/scripts/emit-standard-file.sh b/scripts/emit-standard-file.sh index b39f1022e..b449f623e 100755 --- a/scripts/emit-standard-file.sh +++ b/scripts/emit-standard-file.sh @@ -67,7 +67,7 @@ _assert_vform_pins() { # refs and third-party actions are not '<...>-reusable.yml@' and are exempt.) unmarked="$(grep -E '^[[:space:]]*uses:[[:space:]]*combsmith/[^[:space:]]*-reusable\.yml@' "$file" 2>/dev/null \ | grep -vE 'S7637\) first-party channel ref' || true)" - if [ -n "$unmarked" ]; then + if [[ -n "$unmarked" ]]; then echo "::error::refusing to emit '${file#"${STANDARDS_DIR}"/}': first-party reusable uses: line(s) missing the '# NOSONAR(githubactions:S7637) first-party channel ref' marker — cannot validate the channel pin (fail-closed):" >&2 printf '%s\n' "$unmarked" | sed 's/^/ /' >&2 exit 3 @@ -75,7 +75,7 @@ _assert_vform_pins() { bad="$(grep -E 'S7637\) first-party channel ref' "$file" 2>/dev/null \ | sed -E 's/.*-reusable\.yml@([^[:space:]]+).*/\1/' \ | grep -vE '^[a-z0-9-]+/v[0-9]+-(stable|next|ring[0-9]+)$' || true)" - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then echo "::error::refusing to emit '${file#"${STANDARDS_DIR}"/}': bare channel pin(s) — must be major-scoped /v-:" >&2 printf '%s\n' "$bad" | sed 's/^/ /' >&2 exit 3 @@ -87,7 +87,7 @@ _assert_vform_pins() { agentref_bad="$(grep -E '^[[:space:]]*agent_ref:[[:space:]]' "$file" 2>/dev/null \ | sed -E 's/.*agent_ref:[[:space:]]*([^[:space:]#]+).*/\1/' \ | grep -vE '^[a-z0-9-]+/v[0-9]+-(stable|next|ring[0-9]+)$' || true)" - if [ -n "$agentref_bad" ]; then + if [[ -n "$agentref_bad" ]]; then echo "::error::refusing to emit '${file#"${STANDARDS_DIR}"/}': agent_ref value(s) not major-scoped /v-:" >&2 printf '%s\n' "$agentref_bad" | sed 's/^/ /' >&2 exit 3 @@ -101,7 +101,7 @@ _emit_workflow() { case "$name" in */*|.*|"") echo "::error::invalid workflow name '${name}'" >&2; exit 2 ;; esac - if [ ! -f "$src" ]; then + if [[ ! -f "$src" ]]; then echo "::error::unknown workflow '${name}' — no template at standards/workflows/${name}" >&2 exit 2 fi @@ -109,7 +109,7 @@ _emit_workflow() { cat "$src" } -[ $# -ge 2 ] || _usage +[[ $# -ge 2 ]] || _usage case "$1" in --emit-workflow) _emit_workflow "$2"; exit 0 ;; diff --git a/scripts/fix-check-suite-prefs.sh b/scripts/fix-check-suite-prefs.sh index 3431e8f31..082825006 100755 --- a/scripts/fix-check-suite-prefs.sh +++ b/scripts/fix-check-suite-prefs.sh @@ -40,7 +40,7 @@ fail() { echo "[FAIL] $*" >&2; } # --------------------------------------------------------------------------- validate_token() { local token="${GH_TOKEN:-}" - if [ -z "$token" ]; then + if [[ -z "$token" ]]; then err "GH_TOKEN is not set. Export a classic PAT with 'repo' scope." err " export GH_TOKEN=ghp_..." exit 1 @@ -60,7 +60,7 @@ build_payload() { local payload='{"auto_trigger_checks":[' local first=true for app_id in "${APP_IDS[@]}"; do - if [ "$first" = true ]; then + if [[ "$first" = true ]]; then first=false else payload+=',' @@ -115,12 +115,12 @@ fix_repo() { validate_token export GH_TOKEN -if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then +if [[ "${1:-}" = "--help" ]] || [[ "${1:-}" = "-h" ]]; then sed -n '2,/^set /p' "$0" | grep '^#' | sed 's/^# \?//' exit 0 fi -if [ $# -ge 1 ] && [ "$1" != "--all" ]; then +if [[ $# -ge 1 ]] && [[ "$1" != "--all" ]]; then # Single repo mode fix_repo "$1" exit $? @@ -130,7 +130,7 @@ fi info "Fetching all non-archived repos in $ORG ..." repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500) -if [ -z "$repos" ]; then +if [[ -z "$repos" ]]; then err "No repositories found in $ORG — check GH_TOKEN permissions" exit 1 fi @@ -143,7 +143,7 @@ for repo in $repos; do done echo "" -if [ "$failed" -gt 0 ]; then +if [[ "$failed" -gt 0 ]]; then err "$failed/$total repo(s) failed — check output above" exit 1 fi diff --git a/scripts/fix-ruleset-bypass.sh b/scripts/fix-ruleset-bypass.sh index 8c497c64d..ddd1a6def 100755 --- a/scripts/fix-ruleset-bypass.sh +++ b/scripts/fix-ruleset-bypass.sh @@ -142,14 +142,14 @@ fix_repo() { local default_branch default_branch=$(gh api "repos/$ORG/$repo" --jq '.default_branch // "main"' 2>/dev/null || echo "main") - [ -z "$default_branch" ] || [ "$default_branch" = "null" ] && default_branch="main" + [[ -z "$default_branch" ]] || [[ "$default_branch" = "null" ]] && default_branch="main" local rulesets rulesets=$(gh api "repos/$ORG/$repo/rulesets" 2>/dev/null || echo "[]") local ids ids=$(echo "$rulesets" | jq -r '.[].id' 2>/dev/null || echo "") - if [ -z "$ids" ]; then + if [[ -z "$ids" ]]; then skip " No rulesets on $ORG/$repo" return 0 fi @@ -158,14 +158,14 @@ fix_repo() { for rs_id in $ids; do local rs rs=$(gh api "repos/$ORG/$repo/rulesets/$rs_id" 2>/dev/null || echo "") - [ -z "$rs" ] && continue + [[ -z "$rs" ]] && continue targets_default_branch "$rs" "$default_branch" || continue local name slug name=$(echo "$rs" | jq -r '.name') slug=$(printf '%s' "$name" | tr '[:upper:] /' '[:lower:]--' | tr -cd 'a-z0-9_-') - [ -z "$slug" ] && slug="$rs_id" + [[ -z "$slug" ]] && slug="$rs_id" if is_compliant "$rs"; then skip " $name (id=$rs_id) already compliant" @@ -180,13 +180,13 @@ fix_repo() { before=$(echo "$rs" | jq -c '[.bypass_actors[]? | {t: .actor_type, id: .actor_id, m: .bypass_mode}]') after=$(echo "$payload" | jq -c '[.bypass_actors[] | {t: .actor_type, id: .actor_id, m: .bypass_mode}]') - if [ "$DRY_RUN" = true ]; then + if [[ "$DRY_RUN" = true ]]; then mkdir -p "$OUT_DIR" local out="$OUT_DIR/${repo}__${slug}.json" # Never write through a pre-planted symlink (#1388). Remove any prior # regular file, then create exclusively (noclobber => O_EXCL) so a link # planted after the check is refused too. - if [ -L "$out" ]; then + if [[ -L "$out" ]]; then err " Refusing to write $out: it is a symlink" return 1 fi @@ -210,11 +210,11 @@ fix_repo() { # --------------------------------------------------------------------------- # Parse arguments # --------------------------------------------------------------------------- -if [ $# -eq 0 ]; then +if [[ $# -eq 0 ]]; then usage fi -if [ -z "${GH_TOKEN:-}" ]; then +if [[ -z "${GH_TOKEN:-}" ]]; then err "GH_TOKEN is required — provide a token with administration:write scope" exit 1 fi @@ -230,32 +230,32 @@ for arg in "$@"; do esac done -[ -z "$TARGET" ] && usage +[[ -z "$TARGET" ]] && usage # Default dry-run payload dir: a fresh, owner-only mktemp dir — never a fixed, # predictable path in a shared temp dir (#1388). An explicit OUT_DIR is honored. -if [ "$DRY_RUN" = true ] && [ -z "$OUT_DIR" ]; then +if [[ "$DRY_RUN" = true ]] && [[ -z "$OUT_DIR" ]]; then OUT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/petry-ruleset-payloads.XXXXXX") fi # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- -if [ "$TARGET" = "--all" ]; then +if [[ "$TARGET" = "--all" ]]; then info "Fetching all non-archived repos in $ORG ..." repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500) - [ -z "$repos" ] && { err "No repositories found in $ORG — check GH_TOKEN permissions"; exit 1; } + [[ -z "$repos" ]] && { err "No repositories found in $ORG — check GH_TOKEN permissions"; exit 1; } failed=0 for repo in $repos; do fix_repo "$repo" || failed=$((failed + 1)) done - [ "$failed" -gt 0 ] && { err "$failed repo(s) had errors"; exit 1; } + [[ "$failed" -gt 0 ]] && { err "$failed repo(s) had errors"; exit 1; } else fix_repo "$TARGET" fi -if [ "$DRY_RUN" = true ]; then +if [[ "$DRY_RUN" = true ]]; then info "Dry run complete. Payloads written under $OUT_DIR/" info "Apply one with: gh api -X PUT repos/$ORG//rulesets/ --input " fi diff --git a/scripts/lib/agent-ingress.sh b/scripts/lib/agent-ingress.sh index a5fa640f5..c9d371ed3 100644 --- a/scripts/lib/agent-ingress.sh +++ b/scripts/lib/agent-ingress.sh @@ -39,7 +39,7 @@ agent_ingress_role_for_workflow() { # agent_ingress_is_ingress_workflow — 0 iff (a path or # basename) is the collapsed ingress workflow file. agent_ingress_is_ingress_workflow() { - [ -n "${1:-}" ] && [ "${1##*/}" = "$AGENT_INGRESS_WORKFLOW" ] + [[ -n "${1:-}" ]] && [[ "${1##*/}" = "$AGENT_INGRESS_WORKFLOW" ]] } # agent_ingress_has_role_job — read a workflow YAML on stdin; 0 iff it @@ -51,7 +51,7 @@ agent_ingress_is_ingress_workflow() { # never does. CRLF-tolerant. agent_ingress_has_role_job() { local role="${1:-}" - [ -n "$role" ] || return 1 + [[ -n "$role" ]] || return 1 awk -v role="$role" ' BEGIN { injobs = 0; found = 0; indent = -1 } { sub(/\r$/, "") } @@ -124,7 +124,7 @@ agent_ingress_gh_error_kind() { case "$err" in *"could not find any workflow"*) printf 'missing\n' ;; *"HTTP 404"*|*"Not Found"*) - if [ "$mode" = "workflow-only" ]; then printf 'transient\n'; else printf 'missing\n'; fi + if [[ "$mode" = "workflow-only" ]]; then printf 'transient\n'; else printf 'missing\n'; fi ;; *) printf 'transient\n' ;; esac diff --git a/scripts/lib/agent-rate-limit.sh b/scripts/lib/agent-rate-limit.sh index 7f5fa8250..69b0d5535 100644 --- a/scripts/lib/agent-rate-limit.sh +++ b/scripts/lib/agent-rate-limit.sh @@ -97,7 +97,7 @@ arl_config_path() { # arl_now — current epoch seconds, honoring the $SOURCE_NOW test override. # --------------------------------------------------------------------------- arl_now() { - if [ -n "${SOURCE_NOW:-}" ]; then + if [[ -n "${SOURCE_NOW:-}" ]]; then printf '%s' "$SOURCE_NOW" return 0 fi @@ -110,7 +110,7 @@ arl_now() { # even when DRY_RUN is explicitly set to "false" (not merely unset). # --------------------------------------------------------------------------- arl_is_dry_run() { - [ "${DRY_RUN:-false}" = "true" ] || [ "${DEV_LEAD_DRY_RUN:-false}" = "true" ] + [[ "${DRY_RUN:-false}" = "true" ]] || [[ "${DEV_LEAD_DRY_RUN:-false}" = "true" ]] } # --------------------------------------------------------------------------- @@ -122,7 +122,9 @@ arl_is_dry_run() { arl_sanitize_int() { local value="${1:-}" fallback="${2:-0}" if [[ "$value" =~ ^[0-9]+$ ]]; then - printf '%s' "$value" + # Force base 10 so zero-padded input ("08") is not read as invalid octal + # by later [[ -ge ]] comparisons. + printf '%s' "$((10#$value))" else printf '%s' "$fallback" fi @@ -154,7 +156,7 @@ arl_is_exempt_label() { local IFS=',' for label in $labels_csv; do label="${label// /}" - [ -z "$label" ] && continue + [[ -z "$label" ]] && continue if jq -e --arg l "$label" '(.exempt_labels // []) | index($l) != null' "$config" >/dev/null 2>&1; then return 0 fi @@ -175,7 +177,8 @@ arl_agent_threshold() { value="$(jq -er --arg t "$agent_type" --arg k "$key" \ '(.agent_types[$t][$k])? // empty' "$config" 2>/dev/null || printf '')" if [[ "$value" =~ ^[0-9]+$ ]]; then - printf '%s' "$value" + # Force base 10 so zero-padded config values are not read as octal. + printf '%s' "$((10#$value))" fi } @@ -228,7 +231,7 @@ arl_admission_decision() { # 1. Concurrency — hard cap on simultaneous runs of this type (counted, never # cancelled; ADR §5). - if [ -n "$max" ] && [ "$max" -gt 0 ] && [ "$concurrent" -ge "$max" ]; then + if [[ -n "$max" ]] && [[ "$max" -gt 0 ]] && [[ "$concurrent" -ge "$max" ]]; then decision="defer" reason="concurrency ${concurrent}/${max} at or over max_concurrent_runs" fi @@ -236,26 +239,26 @@ arl_admission_decision() { # 2. Cooldown — minimum quiet interval since the last run (kills dispatch # races; ADR §5). Skipped when disabled (0) or when there is no last run. # A future last_run (elapsed < 0) is malformed state — fail open (allow). - if [ "$decision" = "allow" ] && [ -n "$cooldown" ] && [ "$cooldown" -gt 0 ] && [ "$last_run" -gt 0 ]; then + if [[ "$decision" = "allow" ]] && [[ -n "$cooldown" ]] && [[ "$cooldown" -gt 0 ]] && [[ "$last_run" -gt 0 ]]; then local elapsed=$(( now - last_run )) local window=$(( cooldown * 60 )) - if [ "$elapsed" -lt 0 ]; then + if [[ "$elapsed" -lt 0 ]]; then arl_log "warning: last_run_epoch is in the future — skipping cooldown check (degraded allow)" - elif [ "$elapsed" -lt "$window" ]; then + elif [[ "$elapsed" -lt "$window" ]]; then decision="defer" reason="cooldown ${elapsed}s/${window}s since last run has not elapsed" fi fi # 3. Daily budget — runs (dispatches for initiative-driver) per rolling 24h. - if [ "$decision" = "allow" ] && [ -n "$daily" ] && [ "$daily" -gt 0 ] && [ "$daily_count" -ge "$daily" ]; then + if [[ "$decision" = "allow" ]] && [[ -n "$daily" ]] && [[ "$daily" -gt 0 ]] && [[ "$daily_count" -ge "$daily" ]]; then decision="defer" reason="daily budget ${daily_count}/${daily} at or over daily_run_budget" fi arl_log "admission for '${agent_type}': ${decision} (${reason})" printf 'decision=%s\n' "$decision" - [ "$decision" = "allow" ] && return 0 + [[ "$decision" = "allow" ]] && return 0 return 1 } @@ -296,15 +299,19 @@ arl_breaker_decision() { local threshold backoff threshold="$(arl_breaker_threshold "$agent_type" consecutive_failure_threshold)" backoff="$(arl_breaker_threshold "$agent_type" backoff_minutes)" + # Leading-zero digit strings (e.g. "08") are valid config; force base 10 so bash + # arithmetic does not reject them as octal. + [[ "$threshold" =~ ^[0-9]+$ ]] && threshold=$((10#$threshold)) + [[ "$backoff" =~ ^[0-9]+$ ]] && backoff=$((10#$backoff)) # Malformed / missing breaker config -> cannot evaluate -> allow (never block). - if [ -z "$threshold" ] || [ "$threshold" -le 0 ]; then + if [[ -z "$threshold" ]] || [[ "$threshold" -le 0 ]]; then arl_log "breaker for '${agent_type}': state=closed (no usable consecutive_failure_threshold — allowing)" printf 'decision=allow\n' return 0 fi - if [ "$failures" -lt "$threshold" ]; then + if [[ "$failures" -lt "$threshold" ]]; then arl_log "breaker for '${agent_type}': state=closed (${failures}/${threshold} consecutive failures)" printf 'decision=allow\n' return 0 @@ -312,14 +319,14 @@ arl_breaker_decision() { # Breaker has tripped. Without a usable backoff we cannot time a half-open # probe; degrade to allow rather than block indefinitely on bad config. - if [ -z "$backoff" ] || [ "$backoff" -le 0 ]; then + if [[ -z "$backoff" ]] || [[ "$backoff" -le 0 ]]; then arl_log "breaker for '${agent_type}': state=open but no usable backoff_minutes — allowing (degraded)" printf 'decision=allow\n' return 0 fi # Just tripped (no opened timestamp recorded yet) -> open, defer. - if [ "$opened" -le 0 ]; then + if [[ "$opened" -le 0 ]]; then arl_log "breaker for '${agent_type}': state=open (${failures}/${threshold} failures, just tripped)" printf 'decision=defer\n' return 1 @@ -327,12 +334,12 @@ arl_breaker_decision() { local elapsed=$(( now - opened )) local window=$(( backoff * 60 )) - if [ "$elapsed" -lt 0 ]; then + if [[ "$elapsed" -lt 0 ]]; then arl_log "breaker for '${agent_type}': warning: breaker_opened_epoch is in the future — failing open (degraded allow)" printf 'decision=allow\n' return 0 fi - if [ "$elapsed" -lt "$window" ]; then + if [[ "$elapsed" -lt "$window" ]]; then arl_log "breaker for '${agent_type}': state=open (backoff ${elapsed}s/${window}s not elapsed)" printf 'decision=defer\n' return 1 @@ -341,7 +348,7 @@ arl_breaker_decision() { # Half-open: allow one probe, but defer if a probe was already dispatched within # the backoff window (reduces the race window for simultaneous callers; callers # must record probe_dispatched_epoch in state after a probe is granted). - if [ "$probe_dispatched" -gt 0 ] && [ $(( now - probe_dispatched )) -lt "$window" ]; then + if [[ "$probe_dispatched" -gt 0 ]] && [[ $(( now - probe_dispatched )) -lt "$window" ]]; then arl_log "breaker for '${agent_type}': state=half-open but probe already dispatched — deferring" printf 'decision=defer\n' return 1 @@ -404,13 +411,13 @@ arl_state_path() { arl_load_state() { local path path="$(arl_state_path)" - if [ -z "$path" ] || [ ! -f "$path" ]; then + if [[ -z "$path" ]] || [[ ! -f "$path" ]]; then printf '{}' return 0 fi local raw raw="$(cat "$path" 2>/dev/null || printf '')" - if [ -z "$raw" ] || ! jq -e . <<<"$raw" >/dev/null 2>&1; then + if [[ -z "$raw" ]] || ! jq -e . <<<"$raw" >/dev/null 2>&1; then arl_log "warning: agent-rate-limit state at '$path' is missing or malformed — treating as empty" printf '{}' return 0 @@ -426,7 +433,7 @@ arl_load_state() { arl_state_field() { local state_json="$1" agent_type="$2" field="$3" fallback="${4:-0}" value value="" - if [ -n "$state_json" ]; then + if [[ -n "$state_json" ]]; then value="$(jq -er --arg t "$agent_type" --arg f "$field" \ '(.[$t][$f])? // empty' <<<"$state_json" 2>/dev/null || printf '')" fi @@ -444,17 +451,17 @@ _arl_gh_run_list() { local workflow="$1" repo="$2" limit="$3" status="${4:-}" out err errfile rc=0 local json_fields="databaseId,status,conclusion,createdAt" local -a args=(run list --workflow "$workflow" --json "$json_fields" --limit "$limit") - [ -n "$repo" ] && args+=(--repo "$repo") - [ -n "$status" ] && args+=(--status "$status") + [[ -n "$repo" ]] && args+=(--repo "$repo") + [[ -n "$status" ]] && args+=(--status "$status") errfile="$(mktemp)" out="$(gh "${args[@]}" 2>"$errfile")" || rc=$? err="$(cat "$errfile" 2>/dev/null || true)" rm -f "$errfile" - if [ "$rc" -ne 0 ]; then - [ "$(agent_ingress_gh_error_kind "$err" workflow-only)" = "missing" ] && return 3 + if [[ "$rc" -ne 0 ]]; then + [[ "$(agent_ingress_gh_error_kind "$err" workflow-only)" = "missing" ]] && return 3 return 1 fi - if [ -z "$out" ] || ! jq -e 'type == "array"' <<<"$out" >/dev/null 2>&1; then + if [[ -z "$out" ]] || ! jq -e 'type == "array"' <<<"$out" >/dev/null 2>&1; then return 1 fi printf '%s' "$out" @@ -517,7 +524,7 @@ arl_resolve_agent_runs() { return 1 ;; *) - if [ -z "$records" ]; then + if [[ -z "$records" ]]; then arl_log "UNRESOLVED: neither workflow '${workflow}' nor ${AGENT_INGRESS_WORKFLOW} exists in ${where} — '${role}' runs cannot be counted (refusing to read this as zero)" printf '[]' return 3 @@ -532,17 +539,17 @@ arl_resolve_agent_runs() { role_count="$(jq -r 'length' <<<"$role_runs" 2>/dev/null || printf '0')" batch_count="$(jq -r 'length' <<<"$page_runs" 2>/dev/null || printf '0')" - if [ "$inflight_only" = "true" ] || [ "$cap_hit" = "true" ] \ - || [ "$role_count" -ge "$limit" ] || [ "$batch_count" -lt "$fetch_limit" ]; then - [ "$skipped_runs" -gt 0 ] && arl_log "warning: ${skipped_runs} ${AGENT_INGRESS_WORKFLOW} run(s) in ${where} had unreadable jobs and were NOT counted (degraded)" + if [[ "$inflight_only" = "true" ]] || [[ "$cap_hit" = "true" ]] \ + || [[ "$role_count" -ge "$limit" ]] || [[ "$batch_count" -lt "$fetch_limit" ]]; then + [[ "$skipped_runs" -gt 0 ]] && arl_log "warning: ${skipped_runs} ${AGENT_INGRESS_WORKFLOW} run(s) in ${where} had unreadable jobs and were NOT counted (degraded)" # Every jobs read failed: nothing was resolved, so this is a transient # no-data outcome (degrades to 0), not a resolved-empty history. - if [ -z "$records" ] && [ "$skipped_runs" -gt 0 ]; then + if [[ -z "$records" ]] && [[ "$skipped_runs" -gt 0 ]]; then printf '[]' return 1 fi printf '%s' "$role_runs" - if [ "$role_runs" = "[]" ] && ! _arl_ingress_declares_role "$role" "$api_repo"; then + if [[ "$role_runs" = "[]" ]] && ! _arl_ingress_declares_role "$role" "$api_repo"; then arl_log "UNRESOLVED: ${AGENT_INGRESS_WORKFLOW} in ${where} declares no '${role}' job — '${role}' runs cannot be counted (refusing to read this as zero)" return 3 fi @@ -559,14 +566,14 @@ arl_resolve_agent_runs() { # _arl_gh_run_list (0 ok / 1 transient / 3 permanently missing). _arl_fetch_ingress_page() { local repo="$1" limit="$2" inflight_only="$3" st out rc acc="[]" - if [ "$inflight_only" != "true" ]; then + if [[ "$inflight_only" != "true" ]]; then _arl_gh_run_list "$AGENT_INGRESS_WORKFLOW" "$repo" "$limit" return $? fi for st in queued in_progress; do rc=0 out="$(_arl_gh_run_list "$AGENT_INGRESS_WORKFLOW" "$repo" "$limit" "$st")" || rc=$? - [ "$rc" -ne 0 ] && return "$rc" + [[ "$rc" -ne 0 ]] && return "$rc" acc="$(jq -sc 'add' <<<"${acc}${out}")" done printf '%s' "$acc" @@ -580,12 +587,12 @@ _arl_fetch_ingress_page() { # cap. _arl_ingest_ingress_runs() { local id status created jobs_out filter='.' - [ "$inflight_only" = "true" ] && filter='map(select((.status // "") != "completed"))' + [[ "$inflight_only" = "true" ]] && filter='map(select((.status // "") != "completed"))' while IFS=$'\t' read -r id status created; do [[ "$id" =~ ^[0-9]+$ ]] || continue case "$seen" in *" ${id} "*) continue ;; esac seen+="${id} " - if [ "$jobs_reads" -ge "$jobs_cap" ]; then + if [[ "$jobs_reads" -ge "$jobs_cap" ]]; then cap_hit=true arl_log "warning: ${AGENT_INGRESS_WORKFLOW} jobs-read cap (${jobs_cap}, AGENT_INGRESS_MAX_JOBS_READS) hit in ${where} — history is TRUNCATED, '${role}' runs beyond it are not counted (degraded)" break @@ -593,7 +600,7 @@ _arl_ingest_ingress_runs() { jobs_reads=$(( jobs_reads + 1 )) jobs_out="$(gh api "repos/${api_repo}/actions/runs/${id}/jobs?per_page=100" 2>/dev/null \ | jq -c '[.jobs[]? | {name, status, conclusion}]' 2>/dev/null || true)" - if [ -z "$jobs_out" ]; then + if [[ -z "$jobs_out" ]]; then arl_log "warning: jobs of ${AGENT_INGRESS_WORKFLOW} run ${id} in ${where} were unreadable — skipping that run (degraded)" skipped_runs=$(( skipped_runs + 1 )) continue @@ -612,7 +619,7 @@ _arl_ingress_declares_role() { raw="$(gh api "repos/${api_repo}/contents/.github/workflows/${AGENT_INGRESS_WORKFLOW}" 2>/dev/null || true)" content="$(jq -r 'select(.type == "file" and .encoding == "base64") | .content // empty' <<<"$raw" 2>/dev/null \ | base64 -d 2>/dev/null || true)" - if [ -z "$content" ]; then + if [[ -z "$content" ]]; then arl_log "warning: could not verify ${AGENT_INGRESS_WORKFLOW} in ${api_repo} — treating '${role}' as declared (degraded)" return 0 fi @@ -645,12 +652,12 @@ arl_count_concurrent_runs() { # The current repo first, then any additional repos for org-wide enforcement. local -a repos=("") - if [ -n "${AGENT_RATE_LIMITS_ORG_REPOS:-}" ]; then + if [[ -n "${AGENT_RATE_LIMITS_ORG_REPOS:-}" ]]; then local -a _arl_repos IFS=',' read -ra _arl_repos <<< "$AGENT_RATE_LIMITS_ORG_REPOS" for repo in "${_arl_repos[@]}"; do repo="${repo// /}" - [ -n "$repo" ] && repos+=("$repo") + [[ -n "$repo" ]] && repos+=("$repo") done fi @@ -667,7 +674,7 @@ arl_count_concurrent_runs() { arl_log "warning: run enumeration for '${agent_type}'${repo:+ in ${repo}} returned no data (treating concurrency as 0)" ;; *) - if [ -z "$repo" ]; then + if [[ -z "$repo" ]]; then unresolved+=" " else arl_log "warning: '${agent_type}' runs in ${repo} are unresolved (no workflow / ingress role job) — counting 0 for that repo" @@ -676,7 +683,7 @@ arl_count_concurrent_runs() { esac done - if [ -n "$unresolved" ]; then + if [[ -n "$unresolved" ]]; then arl_log "UNRESOLVED: concurrency for '${agent_type}' cannot be counted in:${unresolved} — reporting unresolved, not 0" printf 'unresolved' return 3 @@ -785,12 +792,12 @@ arl_token_claude_priority() { # --------------------------------------------------------------------------- arl_token_priority_rank() { local agent_type="$1" a - if [ "$(arl_token_claude_priority)" != "true" ]; then + if [[ "$(arl_token_claude_priority)" != "true" ]]; then printf '0' return 0 fi for a in $ARL_TOKEN_CLAUDE_AGENTS; do - if [ "$a" = "$agent_type" ]; then + if [[ "$a" = "$agent_type" ]]; then printf '0' return 0 fi @@ -809,13 +816,13 @@ arl_token_priority_rank() { # --------------------------------------------------------------------------- arl_token_fetch_envelope() { local raw="" - if [ -n "${AGENT_TOKEN_BUDGET_TELEMETRY_CMD:-}" ]; then + if [[ -n "${AGENT_TOKEN_BUDGET_TELEMETRY_CMD:-}" ]]; then local _timeout="${AGENT_TOKEN_BUDGET_TELEMETRY_TIMEOUT:-10}" raw="$(timeout "$_timeout" bash -c "$AGENT_TOKEN_BUDGET_TELEMETRY_CMD" 2>/dev/null || printf '')" - elif [ -n "${AGENT_TOKEN_BUDGET_TELEMETRY_FILE:-}" ] && [ -f "${AGENT_TOKEN_BUDGET_TELEMETRY_FILE}" ]; then + elif [[ -n "${AGENT_TOKEN_BUDGET_TELEMETRY_FILE:-}" ]] && [[ -f "${AGENT_TOKEN_BUDGET_TELEMETRY_FILE}" ]]; then raw="$(cat "$AGENT_TOKEN_BUDGET_TELEMETRY_FILE" 2>/dev/null || printf '')" fi - if [ -z "$raw" ] || ! jq -e . <<<"$raw" >/dev/null 2>&1; then + if [[ -z "$raw" ]] || ! jq -e . <<<"$raw" >/dev/null 2>&1; then printf '{"status":0}' return 0 fi @@ -880,25 +887,26 @@ arl_token_budget_decision() { threshold="${2:-}" pause_worthy="${3:-}" - if [ "$pause_worthy" != "true" ]; then + if [[ "$pause_worthy" != "true" ]]; then arl_log "token-budget: window is not pause-worthy — allowing (scope guard)" printf 'decision=allow\n' return 0 fi - if ! [[ "$threshold" =~ ^[0-9]+$ ]] || [ "$threshold" -le 0 ]; then + if [[ "$threshold" =~ ^[0-9]+$ ]]; then threshold=$((10#$threshold)); fi + if ! [[ "$threshold" =~ ^[0-9]+$ ]] || [[ "$threshold" -le 0 ]]; then arl_log "token-budget: no usable pause_threshold_pct — allowing (degraded)" printf 'decision=allow\n' return 0 fi local decision="allow" reason="budget ${percent}% under ${threshold}% threshold" - if [ "$percent" -ge "$threshold" ]; then + if [[ "$percent" -ge "$threshold" ]]; then decision="defer" reason="budget ${percent}% at or over ${threshold}% threshold" fi arl_log "token-budget decision: ${decision} (${reason})" printf 'decision=%s\n' "$decision" - [ "$decision" = "allow" ] && return 0 + [[ "$decision" = "allow" ]] && return 0 return 1 } @@ -924,23 +932,23 @@ arl_token_transport_decision() { # blocks (ADR §7). An observation timestamp (observed_at) or absolute deadline # (retry_until) in the envelope guards against a stale file deferring forever: # if the deadline has already passed, treat it as expired degraded telemetry. - if [ "$status" -eq 429 ]; then + if [[ "$status" -eq 429 ]]; then retry_after="$(arl_sanitize_int "$(jq -r '.retry_after? // 0' <<<"$envelope" 2>/dev/null || printf '0')")" - if [ "$retry_after" -gt 0 ]; then + if [[ "$retry_after" -gt 0 ]]; then local retry_deadline observed_at retry_deadline="$(arl_sanitize_int "$(jq -r '.retry_until? // 0' <<<"$envelope" 2>/dev/null || printf '0')")" - if [ "$retry_deadline" -eq 0 ]; then + if [[ "$retry_deadline" -eq 0 ]]; then observed_at="$(arl_sanitize_int "$(jq -r '.observed_at? // 0' <<<"$envelope" 2>/dev/null || printf '0')")" - if [ "$observed_at" -gt 0 ]; then + if [[ "$observed_at" -gt 0 ]]; then retry_deadline=$(( observed_at + retry_after )) fi fi - if [ "$retry_deadline" -eq 0 ]; then + if [[ "$retry_deadline" -eq 0 ]]; then arl_log "warning: token-budget 429 has no deadline anchor (no retry_until/observed_at) — treating as stale, allowing dispatch (degraded)" printf 'allow' return 0 fi - if [ "$now" -ge "$retry_deadline" ]; then + if [[ "$now" -ge "$retry_deadline" ]]; then arl_log "warning: token-budget 429 retry window expired (deadline=${retry_deadline}, now=${now}) — allowing dispatch (degraded)" printf 'allow' return 0 @@ -952,7 +960,7 @@ arl_token_transport_decision() { fi # Any other non-200 fails safe: allow-with-warning. - if [ "$status" -ne 200 ]; then + if [[ "$status" -ne 200 ]]; then arl_log "warning: token-budget telemetry unavailable (status=${status}) — allowing dispatch (degraded)" printf 'allow' return 0 @@ -982,13 +990,13 @@ arl_token_budget_gate() { local pause_worthy threshold pause_worthy="$(arl_token_window_pause_worthy "$window")" - if [ "$pause_worthy" != "true" ]; then + if [[ "$pause_worthy" != "true" ]]; then arl_log "token-budget: window '${window}' is not pause-worthy — allowing (scope guard)" printf 'decision=allow\n' return 0 fi threshold="$(arl_token_pause_threshold "$window")" - if [ -z "$threshold" ]; then + if [[ -z "$threshold" ]]; then arl_log "token-budget: no configured pause_threshold_pct for '${window}' — allowing (degraded)" printf 'decision=allow\n' return 0 @@ -1008,7 +1016,7 @@ arl_token_budget_gate() { body="$(jq -c '.body? // {}' <<<"$envelope" 2>/dev/null || printf '{}')" - if [ "$(arl_token_window_active "$body" "$window")" = "false" ]; then + if [[ "$(arl_token_window_active "$body" "$window")" = "false" ]]; then arl_log "token-budget: window '${window}' is not active — allowing (not binding)" printf 'decision=allow\n' return 0 @@ -1016,7 +1024,7 @@ arl_token_budget_gate() { local percent percent="$(arl_token_extract_percent "$body" "$window")" - if [ -z "$percent" ]; then + if [[ -z "$percent" ]]; then arl_log "warning: token-budget telemetry has no '${window}' window entry — allowing dispatch (degraded)" printf 'decision=allow\n' return 0 @@ -1083,7 +1091,7 @@ arl_token_glide_enabled() { local config value config="$(arl_config_path)" value="" - if [ -n "$config" ]; then + if [[ -n "$config" ]]; then value="$(jq -r '(.org_wide.token_budget.limits.weekly_all.enabled)? // "false"' "$config" 2>/dev/null || printf 'false')" fi case "$value" in @@ -1100,7 +1108,7 @@ arl_token_glide_enabled() { # --------------------------------------------------------------------------- arl_token_iso_to_epoch() { local iso="${1:-}" epoch - [ -z "$iso" ] && return 0 + [[ -z "$iso" ]] && return 0 # Require an explicit timezone offset (Z or ±HH:MM) so date -d does not # silently interpret a timezone-less value as local time. if [[ ! "$iso" =~ (Z|[+-][0-9]{2}:[0-9]{2})$ ]]; then @@ -1112,7 +1120,7 @@ arl_token_iso_to_epoch() { # the reconstructed YYYY-MM-DD will not match the input prefix — reject it. local reconstructed_date reconstructed_date="$(date -u -d "@$epoch" +%Y-%m-%d 2>/dev/null || printf '')" - [ "$reconstructed_date" = "${iso:0:10}" ] || return 0 + [[ "$reconstructed_date" = "${iso:0:10}" ]] || return 0 printf '%s' "$epoch" } @@ -1127,7 +1135,7 @@ arl_token_days_until_reset() { reset="$(arl_sanitize_int "${1:-}")" now="$(arl_sanitize_int "${2:-}")" diff=$(( reset - now )) - if [ "$diff" -le 0 ]; then + if [[ "$diff" -le 0 ]]; then printf '0' return 0 fi @@ -1149,8 +1157,8 @@ arl_token_glide_threshold() { floor="$(arl_sanitize_int "${3:-}")" ceiling="$(arl_sanitize_int "${4:-}")" raw=$(( ceiling - reserve * days )) - if [ "$raw" -gt "$ceiling" ]; then raw="$ceiling"; fi - if [ "$raw" -lt "$floor" ]; then raw="$floor"; fi + if [[ "$raw" -gt "$ceiling" ]]; then raw="$ceiling"; fi + if [[ "$raw" -lt "$floor" ]]; then raw="$floor"; fi printf '%s' "$raw" } @@ -1174,7 +1182,7 @@ arl_token_extract_resets_at() { else null end ) | if . == null then empty else . end ' <<<"$body" 2>/dev/null || printf '')" - if [ -n "$value" ]; then + if [[ -n "$value" ]]; then printf '%s' "$value" fi } @@ -1224,14 +1232,14 @@ arl_token_weekly_glide_gate() { # 1. Config-level arm — inert until a maintainer enables it in config, even # when the integration env flag is on (staged dry-run rollout, AC #7). - if [ "$(arl_token_glide_enabled)" != "true" ]; then + if [[ "$(arl_token_glide_enabled)" != "true" ]]; then arl_log "token-budget weekly-glide: not enabled in config (weekly_all.enabled) — allowing (inert)" printf 'decision=allow\n' return 0 fi # 2. Scope guard — only the account-wide window is pause-worthy (ADR §2.5). - if [ "$(arl_token_window_pause_worthy "$window")" != "true" ]; then + if [[ "$(arl_token_window_pause_worthy "$window")" != "true" ]]; then arl_log "token-budget weekly-glide: window '${window}' is not pause-worthy — allowing (scope guard)" printf 'decision=allow\n' return 0 @@ -1243,7 +1251,7 @@ arl_token_weekly_glide_gate() { reserve="$(arl_token_glide_reserve)" floor="$(arl_token_glide_floor)" ceiling="$(arl_token_glide_ceiling)" - if [ -z "$reserve" ] || [ -z "$floor" ] || [ -z "$ceiling" ]; then + if [[ -z "$reserve" ]] || [[ -z "$floor" ]] || [[ -z "$ceiling" ]]; then arl_log "token-budget weekly-glide: incomplete glide config (reserve/floor/ceiling) — allowing (degraded)" printf 'decision=allow\n' return 0 @@ -1263,7 +1271,7 @@ arl_token_weekly_glide_gate() { body="$(jq -c '.body? // {}' <<<"$envelope" 2>/dev/null || printf '{}')" # 6. An inactive window is not binding. - if [ "$(arl_token_window_active "$body" "$window")" = "false" ]; then + if [[ "$(arl_token_window_active "$body" "$window")" = "false" ]]; then arl_log "token-budget weekly-glide: window '${window}' is not active — allowing (not binding)" printf 'decision=allow\n' return 0 @@ -1272,7 +1280,7 @@ arl_token_weekly_glide_gate() { # 7. Missing percent -> cannot evaluate -> allow-with-warning (AC #5). local percent percent="$(arl_token_extract_percent "$body" "$window")" - if [ -z "$percent" ]; then + if [[ -z "$percent" ]]; then arl_log "warning: token-budget weekly-glide telemetry has no '${window}' window entry — allowing dispatch (degraded)" printf 'decision=allow\n' return 0 @@ -1282,13 +1290,13 @@ arl_token_weekly_glide_gate() { # hardcoded weekday (AC #2). A missing/unparseable value fails safe. local resets_at reset_epoch resets_at="$(arl_token_extract_resets_at "$body" "$window")" - if [ -z "$resets_at" ]; then + if [[ -z "$resets_at" ]]; then arl_log "warning: token-budget weekly-glide telemetry has no resets_at for '${window}' — allowing dispatch (degraded)" printf 'decision=allow\n' return 0 fi reset_epoch="$(arl_token_iso_to_epoch "$resets_at")" - if [ -z "$reset_epoch" ]; then + if [[ -z "$reset_epoch" ]]; then arl_log "warning: token-budget weekly-glide could not parse resets_at='${resets_at}' — allowing dispatch (degraded)" printf 'decision=allow\n' return 0 @@ -1303,7 +1311,7 @@ arl_token_weekly_glide_gate() { # `|| true`: the pure core returns non-zero on defer; the decision rides in # stdout — neutralize the exit so a `set -e` caller is not aborted here. decision_out="$(arl_token_budget_decision "$percent" "$threshold" "true")" || true - if [ "$decision_out" = "decision=defer" ]; then + if [[ "$decision_out" = "decision=defer" ]]; then arl_log "token-budget weekly-glide TRIP: $(arl_token_glide_trip_reason "$percent" "$threshold" "$days" "$resets_at")" printf 'decision=defer\n' return 1 @@ -1354,7 +1362,7 @@ arl_finish() { arl_log "decision=${decision} for '${agent_type}' (${reason})" printf 'decision=%s\n' "$decision" - [ "$decision" = "allow" ] && return 0 + [[ "$decision" = "allow" ]] && return 0 return 1 } @@ -1378,7 +1386,7 @@ arl_finish() { # --------------------------------------------------------------------------- arl_admission_gate() { local agent_type="${1:-}" actor="${2:-}" labels="${3:-}" - if [ -z "$agent_type" ]; then + if [[ -z "$agent_type" ]]; then arl_log "error: arl_admission_gate requires an argument" return 2 fi @@ -1387,18 +1395,18 @@ arl_admission_gate() { # the config source must never stop the fleet. local config config="$(arl_config_path)" - if [ ! -f "$config" ] || ! jq -e . "$config" >/dev/null 2>&1; then + if [[ ! -f "$config" ]] || ! jq -e . "$config" >/dev/null 2>&1; then arl_log "warning: config at '$config' is missing or malformed — allowing dispatch (degraded)" arl_finish "$agent_type" "allow" "config unreadable — degraded allow" return $? fi # 2. Exempt actors and exempt labels are always allowed and never counted. - if [ -n "$actor" ] && arl_is_exempt_actor "$actor"; then + if [[ -n "$actor" ]] && arl_is_exempt_actor "$actor"; then arl_finish "$agent_type" "allow" "actor '${actor}' is exempt (not subject to the limits)" return $? fi - if [ -n "$labels" ] && arl_is_exempt_label "$labels"; then + if [[ -n "$labels" ]] && arl_is_exempt_label "$labels"; then arl_finish "$agent_type" "allow" "run carries an exempt label (not subject to the limits)" return $? fi @@ -1411,11 +1419,11 @@ arl_admission_gate() { # admission check. `|| true`: the decision rides in captured stdout and a # defer returns non-zero — neutralize it so a `set -e` caller is not # aborted before the decision is emitted. - if [ "${AGENT_TOKEN_BUDGET_ENABLED:-false}" = "true" ]; then + if [[ "${AGENT_TOKEN_BUDGET_ENABLED:-false}" = "true" ]]; then local token_decision glide_decision shared_envelope shared_envelope="$(arl_token_fetch_envelope)" token_decision="$(arl_token_budget_gate session "$shared_envelope")" || true - if [ "$token_decision" = "decision=defer" ]; then + if [[ "$token_decision" = "decision=defer" ]]; then arl_log "token-budget escalation: $(arl_token_breaker_marker session) — add to tracking issue/PR body, remove when cleared" arl_finish "$agent_type" "defer" "org-wide token-budget breaker is open (5-hour Claude session window at/over threshold)" return $? @@ -1427,7 +1435,7 @@ arl_admission_gate() { # it inert until sign-off even when this env flag is on. `|| true`: the # decision rides in captured stdout and a defer returns non-zero. glide_decision="$(arl_token_weekly_glide_gate "$shared_envelope")" || true - if [ "$glide_decision" = "decision=defer" ]; then + if [[ "$glide_decision" = "decision=defer" ]]; then arl_log "token-budget escalation: $(arl_token_breaker_marker weekly_all) — add to tracking issue/PR body, remove when cleared" arl_finish "$agent_type" "defer" "org-wide token-budget breaker is open (7-day Claude weekly window over glide-path threshold)" return $? @@ -1449,7 +1457,7 @@ arl_admission_gate() { # aborted at this assignment before the decision is emitted. local breaker breaker="$(arl_breaker_decision "$agent_type" "$failures" "$opened" "$now" "$probe_dispatched")" || true - if [ "$breaker" != "decision=allow" ]; then + if [[ "$breaker" != "decision=allow" ]]; then arl_finish "$agent_type" "defer" "consecutive-failure breaker is open" return $? fi @@ -1459,7 +1467,7 @@ arl_admission_gate() { concurrent="$(arl_count_concurrent_runs "$agent_type")" || concurrent_rc=$? # An UNRESOLVED count (no workflow and no agent-ingress.yml role job to count — # #1226) fails CLOSED: reading it as 0 would never throttle that repo. - if [ "$concurrent_rc" -eq 3 ]; then + if [[ "$concurrent_rc" -eq 3 ]]; then arl_finish "$agent_type" "defer" "concurrency unresolved — no '${agent_type}' workflow and no ${AGENT_INGRESS_WORKFLOW} to count role jobs from (failing closed)" return $? fi @@ -1469,7 +1477,7 @@ arl_admission_gate() { # Reset daily_count when the recorded window is more than 24 h old so # yesterday's usage cannot block runs indefinitely. Callers are expected to # persist daily_window_start alongside daily_count when recording a dispatch. - if [ "$daily_window_start" -gt 0 ] && [ $(( now - daily_window_start )) -ge 86400 ]; then + if [[ "$daily_window_start" -gt 0 ]] && [[ $(( now - daily_window_start )) -ge 86400 ]]; then arl_log "daily window for '${agent_type}' expired ($(( now - daily_window_start ))s) — resetting daily_count" daily_count=0 fi @@ -1483,7 +1491,7 @@ arl_admission_gate() { # Deployment or Environments lock) — that belongs to Phase-4 wiring, not this # guard-only library. admission="$(arl_admission_decision "$agent_type" "$concurrent" "$last_run" "$daily_count" "$now")" || true - if [ "$admission" = "decision=allow" ]; then + if [[ "$admission" = "decision=allow" ]]; then arl_finish "$agent_type" "allow" "under all limits with a clean breaker" return $? fi diff --git a/scripts/lib/canary-rollout.sh b/scripts/lib/canary-rollout.sh index 9570e9325..943a36d5f 100644 --- a/scripts/lib/canary-rollout.sh +++ b/scripts/lib/canary-rollout.sh @@ -22,8 +22,8 @@ # clamp — echo value bounded to [lo, hi]. clamp() { local v="$1" lo="$2" hi="$3" - if [ "$v" -lt "$lo" ]; then echo "$lo"; return 0; fi - if [ "$v" -gt "$hi" ]; then echo "$hi"; return 0; fi + if [[ "$v" -lt "$lo" ]]; then echo "$lo"; return 0; fi + if [[ "$v" -gt "$hi" ]]; then echo "$hi"; return 0; fi echo "$v" } @@ -31,7 +31,7 @@ clamp() { # Denominator must be > 0. Pure arithmetic, no bc. round_div() { local n="$1" d="$2" - if [ "${d:-0}" -le 0 ]; then echo 0; return 1; fi + if [[ "${d:-0}" -le 0 ]]; then echo 0; return 1; fi echo $(( (2 * n + d) / (2 * d) )) } @@ -39,14 +39,14 @@ round_div() { # half-integer median stays exact). Empty set → 0. median_x2() { local n=$# - [ "$n" -eq 0 ] && { echo 0; return 0; } + [[ "$n" -eq 0 ]] && { echo 0; return 0; } local sorted sorted=$(printf '%s\n' "$@" | sort -n) local arr=() local x while IFS= read -r x; do arr+=("$x"); done <<< "$sorted" local mid=$(( n / 2 )) - if [ $(( n % 2 )) -eq 1 ]; then + if [[ $(( n % 2 )) -eq 1 ]]; then echo $(( 2 * arr[mid] )) else echo $(( arr[mid - 1] + arr[mid] )) @@ -65,13 +65,13 @@ median_x2() { robust_sample_target() { local frac="$1" lo="$2" hi="$3" cap_multiple="${4:-3}"; shift 4 local n=$# - [ "$n" -eq 0 ] && { clamp 0 "$lo" "$hi"; return 0; } + [[ "$n" -eq 0 ]] && { clamp 0 "$lo" "$hi"; return 0; } local m2 cap3 c c2 sum2=0 m2=$(median_x2 "$@") cap3=$(( cap_multiple * m2 )) for c in "$@"; do c2=$(( 2 * c )) - [ "$c2" -gt "$cap3" ] && c2=$cap3 + [[ "$c2" -gt "$cap3" ]] && c2=$cap3 sum2=$(( sum2 + c2 )) done # target_raw = round( frac · sum2 / (2000·n) ) @@ -135,8 +135,8 @@ bump_version() { decide_bump() { local breaking="${1:-0}" feat="${2:-0}" override="${3:-}" case "$override" in major|minor|patch) echo "$override"; return 0 ;; esac - if [ "$breaking" = 1 ]; then echo major; return 0; fi - if [ "$feat" = 1 ]; then echo minor; return 0; fi + if [[ "$breaking" = 1 ]]; then echo major; return 0; fi + if [[ "$feat" = 1 ]]; then echo minor; return 0; fi echo patch } @@ -167,8 +167,9 @@ promotion_failure_should_escalate() { local count="$1" threshold="$2" case "$count" in ''|*[!0-9]*) echo 0; return 0 ;; esac case "$threshold" in ''|*[!0-9]*) echo 0; return 0 ;; esac - [ "$threshold" -lt 1 ] && threshold=1 - if [ "$count" -ge "$threshold" ]; then echo 1; else echo 0; fi + count=$((10#$count)); threshold=$((10#$threshold)) # leading zeros are decimal, not octal + [[ "$threshold" -lt 1 ]] && threshold=1 + if [[ "$count" -ge "$threshold" ]]; then echo 1; else echo 0; fi } # ── autocut pagination termination (#1023) ──────────────────────────────────── @@ -178,7 +179,7 @@ promotion_failure_should_escalate() { # exist), else return 0 (keep paginating). Pure: no I/O, unit-testable. commit_page_done() { local found="$1" count="${2:-0}" per_page="${3:-100}" - if [ "$found" = "true" ] || [ "$count" -lt "$per_page" ]; then return 1; fi + if [[ "$found" = "true" ]] || [[ "$count" -lt "$per_page" ]]; then return 1; fi return 0 } @@ -230,23 +231,23 @@ workflow_call_iface() { interface_break() { local old="$1" new="$2" kind name req key local -A new_has=() old_has=() new_input_req=() old_input_req=() - while read -r kind name req || [ -n "$kind" ]; do - [ -z "$kind" ] && continue + while read -r kind name req || [[ -n "$kind" ]]; do + [[ -z "$kind" ]] && continue kind="${kind%$'\r'}"; name="${name%$'\r'}"; req="${req%$'\r'}" new_has["$kind/$name"]=1 - [ "$kind" = input ] && new_input_req["$name"]="${req:-0}" + [[ "$kind" = input ]] && new_input_req["$name"]="${req:-0}" done <<< "$new" - while read -r kind name req || [ -n "$kind" ]; do - [ -z "$kind" ] && continue + while read -r kind name req || [[ -n "$kind" ]]; do + [[ -z "$kind" ]] && continue kind="${kind%$'\r'}"; name="${name%$'\r'}"; req="${req%$'\r'}" old_has["$kind/$name"]=1 - [ "$kind" = input ] && old_input_req["$name"]="${req:-0}" + [[ "$kind" = input ]] && old_input_req["$name"]="${req:-0}" done <<< "$old" for key in "${!old_has[@]}"; do - if [ -z "${new_has[$key]:-}" ]; then echo 1; return 0; fi # removed / renamed + if [[ -z "${new_has[$key]:-}" ]]; then echo 1; return 0; fi # removed / renamed done for name in "${!new_input_req[@]}"; do - if [ "${new_input_req[$name]}" = 1 ] && [ "${old_input_req[$name]:-x}" != 1 ]; then + if [[ "${new_input_req[$name]}" = 1 ]] && [[ "${old_input_req[$name]:-x}" != 1 ]]; then echo 1; return 0 # newly-required input fi done @@ -259,9 +260,9 @@ _semver_gt() { local a1=0 a2=0 a3=0 b1=0 b2=0 b3=0 IFS=. read -r a1 a2 a3 <<< "$1" IFS=. read -r b1 b2 b3 <<< "$2" - if [ "$a1" -ne "$b1" ]; then [ "$a1" -gt "$b1" ]; return $?; fi - if [ "$a2" -ne "$b2" ]; then [ "$a2" -gt "$b2" ]; return $?; fi - [ "$a3" -gt "$b3" ] + if [[ "$a1" -ne "$b1" ]]; then [[ "$a1" -gt "$b1" ]]; return $?; fi + if [[ "$a2" -ne "$b2" ]]; then [[ "$a2" -gt "$b2" ]]; return $?; fi + [[ "$a3" -gt "$b3" ]] } # max_semver — echo the highest strict MAJOR.MINOR.PATCH among the @@ -270,7 +271,7 @@ max_semver() { local v hi="" for v in "$@"; do [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue - if [ -z "$hi" ] || _semver_gt "$v" "$hi"; then hi="$v"; fi + if [[ -z "$hi" ]] || _semver_gt "$v" "$hi"; then hi="$v"; fi done echo "$hi" } @@ -297,7 +298,7 @@ _is_release_tag_suffix() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; } # ring-pins.sh); without it the legacy bare `/`. Pure. (Epic #657 F4.) channel_tag() { local agent="$1" tier="$2" major="${3:-}" - if [ -n "$major" ]; then printf '%s/v%s-%s' "$agent" "$major" "$tier" + if [[ -n "$major" ]]; then printf '%s/v%s-%s' "$agent" "$major" "$tier" else printf '%s/%s' "$agent" "$tier"; fi } @@ -358,7 +359,7 @@ _looks_like_oid() { [[ "$1" =~ ^[0-9a-f]{7,64}$ ]]; } # least floor_hours on the source tier, else 0. dwell_met() { local dwell="${1:-0}" floor="${2:-0}" - if [ "$dwell" -ge "$floor" ]; then echo 1; else echo 0; fi + if [[ "$dwell" -ge "$floor" ]]; then echo 1; else echo 0; fi } # iso_after — echo "yes" if ISO-8601 UTC timestamp a is at or after b, else @@ -378,8 +379,8 @@ run_list_covers() { local n="${1:-0}" cap="${2:-0}" oldest="${3:-}" since="${4:-}" case "$n" in ''|*[!0-9]*) return 1 ;; esac case "$cap" in ''|*[!0-9]*) return 1 ;; esac - [ "$n" -lt "$cap" ] && return 0 - [ -n "$since" ] && [ -n "$oldest" ] && [[ "$oldest" < "$since" ]] + [[ "$n" -lt "$cap" ]] && return 0 + [[ -n "$since" ]] && [[ -n "$oldest" ]] && [[ "$oldest" < "$since" ]] } # decide_graduated \ @@ -394,13 +395,13 @@ run_list_covers() { decide_graduated() { local dwell="${1:-0}" floor="${2:-0}" sample="${3:-0}" target="${4:-0}" local waived="${5:-false}" cum_fail="${6:-0}" cum_startup="${7:-0}" - if [ "$cum_fail" -gt 0 ] || [ "$cum_startup" -gt 0 ]; then + if [[ "$cum_fail" -gt 0 ]] || [[ "$cum_startup" -gt 0 ]]; then echo "BLOCKED"; return 0 fi local dwell_ok sample_ok dwell_ok=$(dwell_met "$dwell" "$floor") - if [ "$waived" = "true" ] || [ "$sample" -ge "$target" ]; then sample_ok=1; else sample_ok=0; fi - if [ "$dwell_ok" -eq 1 ] && [ "$sample_ok" -eq 1 ]; then + if [[ "$waived" = "true" ]] || [[ "$sample" -ge "$target" ]]; then sample_ok=1; else sample_ok=0; fi + if [[ "$dwell_ok" -eq 1 ]] && [[ "$sample_ok" -eq 1 ]]; then echo "PROMOTE"; return 0 fi echo "SOAKING" @@ -433,9 +434,9 @@ classify_failure() { case "$category" in comment-cap|rate-limit|infra|data) echo "PRE_EXISTING"; return 0 ;; esac - if [ "$differs" = "unknown" ]; then echo "-"; return 0; fi - if [ "$differs" != "1" ]; then echo "PRE_EXISTING"; return 0; fi - if [ "$suspect" -gt 0 ]; then echo "SUSPECT"; else echo "REGRESSION"; fi + if [[ "$differs" = "unknown" ]]; then echo "-"; return 0; fi + if [[ "$differs" != "1" ]]; then echo "PRE_EXISTING"; return 0; fi + if [[ "$suspect" -gt 0 ]]; then echo "SUSPECT"; else echo "REGRESSION"; fi } # triage_label — the triage as a human reads it: the indeterminate "-" (or an empty @@ -462,15 +463,15 @@ triage_label() { # no-worse; min_baseline_sample 0 = no tiny-n guard, though the orchestrator always sets it). decide_suspect_downgrade() { local cand="${1:-0}" base="${2:-0}" base_sample="${3:-0}" knobs="$4" - [ -z "$knobs" ] && knobs='{}' + [[ -z "$knobs" ]] && knobs='{}' local min_base margin min_base="$(jq -r '.min_baseline_sample // 0' <<< "$knobs" 2>/dev/null || echo 0)" margin="$(jq -r '.margin_permille // 0' <<< "$knobs" 2>/dev/null || echo 0)" case "$min_base" in ''|*[!0-9]*) min_base=0 ;; esac case "$margin" in ''|*[!0-9]*) margin=0 ;; esac # Tiny-n guard first: too little baseline data → keep the human (conservative), regardless of rate. - if [ "$base_sample" -lt "$min_base" ]; then echo "HOLD"; return 0; fi - if [ "$cand" -le $(( base + margin )) ]; then echo "DOWNGRADE"; return 0; fi + if [[ "$base_sample" -lt "$min_base" ]]; then echo "HOLD"; return 0; fi + if [[ "$cand" -le $(( base + margin )) ]]; then echo "DOWNGRADE"; return 0; fi echo "HOLD" } @@ -483,8 +484,8 @@ decide_suspect_downgrade() { # since ERE has no inline case-insensitivity flag.) benign_match() { local wf="$1" sig="$2" wf_re="$3" step_re="$4" - [ -z "$step_re" ] && { echo "no"; return 0; } - if [ -n "$wf_re" ] && ! [[ "$wf" =~ $wf_re ]]; then echo "no"; return 0; fi + [[ -z "$step_re" ]] && { echo "no"; return 0; } + if [[ -n "$wf_re" ]] && ! [[ "$wf" =~ $wf_re ]]; then echo "no"; return 0; fi if [[ "$sig" =~ $step_re ]]; then echo "yes"; else echo "no"; fi } @@ -501,11 +502,11 @@ benign_match() { # conclusion alone. Emits nothing; sourced under `set -euo pipefail`, so it returns # and never exits. _is_evicted_run() { - [ "$#" -ge 2 ] || return 1 + [[ "$#" -ge 2 ]] || return 1 local conclusion="$1" count="$2" - [ "$conclusion" = "cancelled" ] || return 1 + [[ "$conclusion" = "cancelled" ]] || return 1 case "$count" in ''|*[!0-9]*) return 1 ;; esac - [ "$count" -eq 0 ] + [[ "$count" -eq 0 ]] } # next_channel_in_order @@ -517,7 +518,7 @@ next_channel_in_order() { local chan_array=() IFS=, read -r -a chan_array <<< "$csv" for ch in "${chan_array[@]}"; do - if [ "$prev" = "$current" ]; then found="$ch"; break; fi + if [[ "$prev" = "$current" ]]; then found="$ch"; break; fi prev="$ch" done echo "$found" @@ -533,7 +534,7 @@ transition_key() { local chan_array=() IFS=, read -r -a chan_array <<< "$csv" for ch in "${chan_array[@]}"; do - if [ "$ch" = "$frontier" ] && [ -n "$prev" ]; then echo "${prev}->${frontier}"; return 0; fi + if [[ "$ch" = "$frontier" ]] && [[ -n "$prev" ]]; then echo "${prev}->${frontier}"; return 0; fi prev="$ch" done echo "" @@ -575,10 +576,10 @@ set_difference() { local a="$1" b="$2" line declare -A b_map while IFS= read -r line; do - [ -n "$line" ] && b_map["$line"]=1 + [[ -n "$line" ]] && b_map["$line"]=1 done <<< "$b" while IFS= read -r line; do - [ -z "$line" ] && continue + [[ -z "$line" ]] && continue if [[ -z "${b_map["$line"]:-}" ]]; then printf '%s\n' "$line" fi @@ -600,14 +601,14 @@ set_difference() { watched_hits() { local files="$1" patterns="$2" p f local -a pats=() - while IFS= read -r p; do [ -n "$p" ] && pats+=("$p"); done <<< "$patterns" - [ "${#pats[@]}" -eq 0 ] && return 0 + while IFS= read -r p; do [[ -n "$p" ]] && pats+=("$p"); done <<< "$patterns" + [[ "${#pats[@]}" -eq 0 ]] && return 0 for p in "${pats[@]}"; do while IFS= read -r f; do - [ -z "$f" ] && continue + [[ -z "$f" ]] && continue case "$p" in - */) if [ "${f#"$p"}" != "$f" ]; then printf '%s\n' "$p"; break; fi ;; - *) if [ "$f" = "$p" ] || [ "${f#"$p"/}" != "$f" ]; then printf '%s\n' "$p"; break; fi ;; + */) if [[ "${f#"$p"}" != "$f" ]]; then printf '%s\n' "$p"; break; fi ;; + *) if [[ "$f" = "$p" ]] || [[ "${f#"$p"/}" != "$f" ]]; then printf '%s\n' "$p"; break; fi ;; esac done <<< "$files" done @@ -616,7 +617,7 @@ watched_hits() { # any_watched_change — echo 1 if any changed file is covered by # any watched pattern, else 0. Thin wrapper over watched_hits (DRY). any_watched_change() { - if [ -n "$(watched_hits "$1" "$2")" ]; then echo 1; else echo 0; fi + if [[ -n "$(watched_hits "$1" "$2")" ]]; then echo 1; else echo 0; fi } # gate_summary_line [cum_benign] @@ -642,7 +643,7 @@ gate_summary_line() { # branch, only the taken branch runs). Empty when no decision step ran (absent/unparseable). decision_class() { local prefix="$1" json="$2" - [ -z "$json" ] && json='{}' + [[ -z "$json" ]] && json='{}' jq -r --arg p "$prefix" ' [ (.jobs[]?.steps[]?) | select((.conclusion // "") != "skipped") @@ -667,9 +668,9 @@ decision_class() { # to max_shift_permille (inclusive). Gross always/never/gross shifts only — by design (n=10–25). decide_decision_shift() { local cand="$1" base="$2" knobs="$3" - [ -z "$cand" ] && cand='{}' - [ -z "$base" ] && base='{}' - [ -z "$knobs" ] && knobs='{}' + [[ -z "$cand" ]] && cand='{}' + [[ -z "$base" ]] && base='{}' + [[ -z "$knobs" ]] && knobs='{}' local min_c min_b max_shift min_c="$(jq -r '.min_candidate_sample // 0' <<< "$knobs" 2>/dev/null || echo 0)" min_b="$(jq -r '.min_baseline_sample // 0' <<< "$knobs" 2>/dev/null || echo 0)" @@ -680,10 +681,10 @@ decide_decision_shift() { # Unparseable / non-object / empty → INSUFFICIENT (no decision steps to compare). case "$cand_total" in ''|*[!0-9]*) echo "INSUFFICIENT"; return 0 ;; esac case "$base_total" in ''|*[!0-9]*) echo "INSUFFICIENT"; return 0 ;; esac - if [ "$cand_total" -lt "$min_c" ] || [ "$base_total" -lt "$min_b" ]; then + if [[ "$cand_total" -lt "$min_c" ]] || [[ "$base_total" -lt "$min_b" ]]; then echo "INSUFFICIENT"; return 0 fi - if [ "$cand_total" -eq 0 ] || [ "$base_total" -eq 0 ]; then + if [[ "$cand_total" -eq 0 ]] || [[ "$base_total" -eq 0 ]]; then echo "INSUFFICIENT"; return 0 fi # Max absolute per-class share delta, in integer per-mille (round half-up), over the union @@ -698,5 +699,5 @@ decide_decision_shift() { | (($cs - $bs) | if . < 0 then -. else . end) ] | max // 0' 2>/dev/null || echo 0)" case "$max_delta" in ''|*[!0-9]*) max_delta=0 ;; esac - if [ "$max_delta" -ge "$max_shift" ]; then echo "SHIFT"; else echo "OK"; fi + if [[ "$max_delta" -ge "$max_shift" ]]; then echo "SHIFT"; else echo "OK"; fi } diff --git a/scripts/lib/dev-lead-retrigger.sh b/scripts/lib/dev-lead-retrigger.sh index 2867cad2b..051bc6987 100644 --- a/scripts/lib/dev-lead-retrigger.sh +++ b/scripts/lib/dev-lead-retrigger.sh @@ -56,7 +56,7 @@ dl_dev_lead_active() { return 0 fi pr_count=$(echo "$pr_raw" | awk '{s+=$1} END {print s+0}') - [ "${pr_count:-0}" -gt 0 ] && return 0 + [[ "${pr_count:-0}" -gt 0 ]] && return 0 # An agent currently mid-run marks the issue `in-progress` before it pushes a # PR — respect that window, but cap it at IN_PROGRESS_MAX_HOURS so a crashed or @@ -69,15 +69,22 @@ dl_dev_lead_active() { return 0 fi - if [ -n "$in_progress" ]; then + if [[ -n "$in_progress" ]]; then local in_progress_max_hours="${IN_PROGRESS_MAX_HOURS:-4}" + # Normalise to base 10 so "010" means ten, not octal eight; fall back to the + # default when the value is not a plain non-negative integer. + if [[ "$in_progress_max_hours" =~ ^[0-9]+$ ]]; then + in_progress_max_hours=$((10#$in_progress_max_hours)) + else + in_progress_max_hours=4 + fi local labeled_at labeled_at=$(gh api "repos/$org/$repo/issues/$issue/events?per_page=100" \ --paginate \ --jq '.[] | select(.event == "labeled" and .label.name == "in-progress") | .created_at' \ 2>/dev/null | head -1 || echo "") - if [ -z "$labeled_at" ] || [ "$labeled_at" = "null" ]; then + if [[ -z "$labeled_at" ]] || [[ "$labeled_at" = "null" ]]; then # Cannot determine when the label was applied; trust it conservatively. return 0 fi @@ -90,18 +97,18 @@ dl_dev_lead_active() { print(int(datetime.datetime.fromisoformat(ts).timestamp()))" 2>/dev/null \ <<< "$labeled_at" || echo "") - if [ -z "$labeled_epoch" ]; then + if [[ -z "$labeled_epoch" ]]; then return 0 fi elapsed_hours=$(( (now_epoch - labeled_epoch) / 3600 )) - [ "$elapsed_hours" -le "$in_progress_max_hours" ] && return 0 + [[ "$elapsed_hours" -le "$in_progress_max_hours" ]] && return 0 # in-progress label is stale (agent likely crashed). Remove it before # returning inactive: AGENTS.md's claim protocol requires any agent to skip # an issue that still carries in-progress, so leaving the label in place # means the next dev-lead run will immediately skip the issue even after the # trigger label is cycled, leaving the crash unrecovered. - if [ "${DRY_RUN:-false}" = "true" ]; then + if [[ "${DRY_RUN:-false}" = "true" ]]; then echo "[dry-run] would remove stale 'in-progress' from $org/$repo#$issue" >&2 else gh api -X DELETE "repos/$org/$repo/issues/$issue/labels/in-progress" \ @@ -124,7 +131,7 @@ dl_dev_lead_active() { dl_cycle_trigger_label() { local org="$1" repo="$2" issue="$3" label="$4" dry_run="${5:-false}" - if [ "$dry_run" = "true" ]; then + if [[ "$dry_run" = "true" ]]; then echo "[dry-run] would cycle '$label' on $org/$repo#$issue" >&2 return 0 fi diff --git a/scripts/lib/gitignore-baseline.sh b/scripts/lib/gitignore-baseline.sh index df7628150..f6a1a32e5 100644 --- a/scripts/lib/gitignore-baseline.sh +++ b/scripts/lib/gitignore-baseline.sh @@ -23,7 +23,7 @@ # shellcheck shell=bash # Guard against double-sourcing. -if [ -n "${_GITIGNORE_BASELINE_SOURCED:-}" ]; then +if [[ -n "${_GITIGNORE_BASELINE_SOURCED:-}" ]]; then return 0 2>/dev/null || true fi _GITIGNORE_BASELINE_SOURCED=1 @@ -39,7 +39,7 @@ GIB_END_MARKER='# <<< END combsmith secrets baseline <<<' # a half-open block). gib_extract_baseline_block() { local file="${1:-}" - [ -n "$file" ] && [ -f "$file" ] || { echo "gib: canonical gitignore not found: ${file:-}" >&2; return 2; } + [[ -n "$file" ]] && [[ -f "$file" ]] || { echo "gib: canonical gitignore not found: ${file:-}" >&2; return 2; } # The two -v values are single-line constants (no newline, no backslash), so # they are safe under every awk — see _gib_neutralize_l2 for why that matters. local rc=0 @@ -144,7 +144,7 @@ _gib_neutralize_l2() { } ')" || rc=$? rm -f "$blockfile" - if [ "$rc" -ne 0 ]; then + if [[ "$rc" -ne 0 ]]; then echo "gib: awk failed (exit $rc) while neutralizing L2; refusing to emit a .gitignore with L2 dropped" >&2 return 1 fi @@ -152,9 +152,9 @@ _gib_neutralize_l2() { local tail tail="$(_gib_negation_tail "$block" "$body")" - [ -n "$body" ] && printf '%s\n' "$body" - if [ -n "$tail" ]; then - [ -n "$body" ] && printf '\n' + [[ -n "$body" ]] && printf '%s\n' "$body" + if [[ -n "$tail" ]]; then + [[ -n "$body" ]] && printf '\n' printf '%s\n' "$tail" fi } @@ -184,7 +184,7 @@ _gib_negation_tail() { ;; esac done <<< "$block" - [ "${#negs[@]}" -eq 0 ] && return 0 + [[ "${#negs[@]}" -eq 0 ]] && return 0 if ! command -v git >/dev/null 2>&1; then printf '%s\n' "${negs[@]}" @@ -194,12 +194,12 @@ _gib_negation_tail() { local d; d="$(mktemp -d -t gitignore-baseline.XXXXXX)" { printf '%s\n' "$block" - [ -n "$body" ] && printf '%s\n' "$body" + [[ -n "$body" ]] && printf '%s\n' "$body" } > "$d/.gitignore" ( cd "$d" && git init -q 2>/dev/null ) local ignored exit_code ignored="$(cd "$d" && git -c core.excludesfile=/dev/null check-ignore -- "${paths[@]}" 2>/dev/null)" || exit_code=$? - [ "${exit_code:-0}" -gt 1 ] && ignored="" + [[ "${exit_code:-0}" -gt 1 ]] && ignored="" rm -rf "$d" local i @@ -233,7 +233,7 @@ _gib_negation_tail() { # Re-running on its own output is a no-op. upsert_gitignore_baseline() { local block_file="${1:-}" existing_file="${2:-}" - [ -n "$block_file" ] && [ -f "$block_file" ] || { echo "gib: block file not found: ${block_file:-}" >&2; return 2; } + [[ -n "$block_file" ]] && [[ -f "$block_file" ]] || { echo "gib: block file not found: ${block_file:-}" >&2; return 2; } local block block="$(cat "$block_file")" @@ -243,12 +243,12 @@ upsert_gitignore_baseline() { fi local existing="" - if [ -n "$existing_file" ] && [ -f "$existing_file" ]; then + if [[ -n "$existing_file" ]] && [[ -f "$existing_file" ]]; then existing="$(cat "$existing_file")" fi # Brand-new file: output block only. - if [ -z "$existing" ]; then + if [[ -z "$existing" ]]; then printf '%s\n' "$block" return 0 fi @@ -258,18 +258,18 @@ upsert_gitignore_baseline() { local has_begin has_end printf '%s\n' "$existing" | grep -qxF "$GIB_BEGIN_MARKER" && has_begin=1 || has_begin=0 printf '%s\n' "$existing" | grep -qxF "$GIB_END_MARKER" && has_end=1 || has_end=0 - if [ "$((has_begin + has_end))" -eq 1 ]; then + if [[ "$((has_begin + has_end))" -eq 1 ]]; then echo "gib: half-open marker state in ${existing_file:-} (exactly one of BEGIN/END present); refusing to upsert" >&2 return 2 fi # Marker-less target: block on top, existing content becomes L2 — but neutralize # any L2 line that duplicates/fights the block (see _gib_neutralize_l2). - if [ "$has_begin" -eq 0 ]; then + if [[ "$has_begin" -eq 0 ]]; then local l2 l2="$(_gib_neutralize_l2 "$block" <<< "$existing")" || return 1 printf '%s\n' "$block" - [ -n "$l2" ] && printf '%s\n' "$l2" + [[ -n "$l2" ]] && printf '%s\n' "$l2" return 0 fi @@ -285,8 +285,8 @@ upsert_gitignore_baseline() { || { echo "gib: awk failed while splitting L2 below END" >&2; return 1; } post="$(_gib_neutralize_l2 "$block" <<< "$post")" || return 1 - [ -n "$pre" ] && printf '%s\n' "$pre" + [[ -n "$pre" ]] && printf '%s\n' "$pre" printf '%s\n' "$block" - [ -n "$post" ] && printf '%s\n' "$post" + [[ -n "$post" ]] && printf '%s\n' "$post" return 0 } diff --git a/scripts/lib/health-alerts.sh b/scripts/lib/health-alerts.sh index 2cbcdd5ca..5d8cf8260 100644 --- a/scripts/lib/health-alerts.sh +++ b/scripts/lib/health-alerts.sh @@ -65,7 +65,7 @@ health_alert_count() { local repo="$1" kind="$2" out err rc=0 count err=$(mktemp) out=$(gh api --paginate "repos/$repo/$kind/alerts?state=open&per_page=100" 2>"$err") || rc=$? - if [ "$rc" -eq 0 ]; then + if [[ "$rc" -eq 0 ]]; then rm -f "$err" if count=$(health_alert_page_count "$out"); then echo "$count" @@ -78,7 +78,7 @@ health_alert_count() { local msg msg="$(cat "$err") $out" rm -f "$err" - if [ "$(health_alert_error_kind "$kind" "$msg")" = "none" ]; then + if [[ "$(health_alert_error_kind "$kind" "$msg")" = "none" ]]; then echo "0" return 0 fi diff --git a/scripts/lib/labels.sh b/scripts/lib/labels.sh index c343c833a..29e23c664 100644 --- a/scripts/lib/labels.sh +++ b/scripts/lib/labels.sh @@ -78,13 +78,13 @@ persona_opt_out_label_configs() { # "no personas" is the fail-closed signal, never a legitimate "family is empty" # (combsmith/.github#755, issue #1139 AC#3). Without this a garbled listing # would read as "no persona labels required". - if [ -z "$ids" ]; then + if [[ -z "$ids" ]]; then warn " Persona manifest listing from $PERSONA_MANIFEST_REPO was empty or unparseable — opt-out labels NOT applied" return 1 fi while IFS= read -r id; do - [ -z "$id" ] && continue + [[ -z "$id" ]] && continue # Prefer the manifest's declared opt_out_label; fall back to the :hands-off # convention (persona-standards.md §4 rule 4) when the field cannot be read. if opt_out_raw=$(gh api "repos/$PERSONA_MANIFEST_REPO/contents/personas/$id/persona.yml?ref=$PERSONA_MANIFEST_REF" \ @@ -112,14 +112,14 @@ persona_opt_out_label_configs() { opt_out="" rc=1 fi - [ -z "$opt_out" ] && opt_out="$id:hands-off" + [[ -z "$opt_out" ]] && opt_out="$id:hands-off" # The label name is the FIRST pipe-delimited field of the emitted spec, so a # name that itself contains '|' (opt_out_label is free-form, and GitHub label # names may contain a pipe) would shift the color/description fields and # corrupt every downstream parser (std_label_spec's `${spec%%|*}`, the audit/ # applier splits). We cannot represent such a name in this format, so reject # it: warn, skip it, and fail the derivation — never emit a corrupt record. - if [ "${opt_out#*|}" != "$opt_out" ]; then + if [[ "${opt_out#*|}" != "$opt_out" ]]; then warn " personas/$id opt_out_label '$opt_out' contains '|' (the field delimiter) — skipping; opt-out label NOT applied" rc=1 continue @@ -147,7 +147,7 @@ std_label_spec() { local want="$1" spec name for spec in "${STANDARD_LABEL_SPECS[@]}"; do name="${spec%%|*}" - if [ "$name" = "$want" ]; then + if [[ "$name" = "$want" ]]; then printf '%s\n' "$spec" return 0 fi @@ -155,9 +155,9 @@ std_label_spec() { local derived derived=$(persona_opt_out_label_configs 2>/dev/null) || true while IFS= read -r spec; do - [ -z "$spec" ] && continue + [[ -z "$spec" ]] && continue name="${spec%%|*}" - if [ "$name" = "$want" ]; then + if [[ "$name" = "$want" ]]; then printf '%s\n' "$spec" return 0 fi diff --git a/scripts/lib/persona-mention.sh b/scripts/lib/persona-mention.sh index 2118bd52d..0f7fb5247 100644 --- a/scripts/lib/persona-mention.sh +++ b/scripts/lib/persona-mention.sh @@ -52,16 +52,16 @@ PERSONA_AGENT_MARKER='