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 ed9c0aea1..6a766ed65 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 @@ -277,14 +277,14 @@ sweep_project() { init="${decided%%$'\t'*}" theme="${decided#*$'\t'}" - if [ -z "${init}" ]; then + if [[ -z "${init}" ]]; then unmatched=$((unmatched + 1)) printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${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}" "${title}" >&2 skipped=$((skipped + 1)) continue @@ -306,9 +306,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}" "${title}" >&2 fi fi @@ -317,6 +317,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 63d214c33..d89aa5508 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 @@ -40,13 +40,13 @@ _atp_require_env() { # "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 @@ -59,7 +59,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 @@ -89,7 +89,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}" \ @@ -135,7 +135,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 @@ -178,8 +178,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. @@ -190,7 +190,7 @@ find_project_item() { return 0 fi - if [ "${has_next}" != "true" ]; then + if [[ "${has_next}" != "true" ]]; then return 0 fi cursor="${end_cursor}" @@ -198,7 +198,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 @@ -214,7 +214,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 @@ -233,14 +233,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' "${title}" return 0 fi @@ -258,13 +258,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 @@ -303,7 +303,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 @@ -311,7 +311,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/feature-ideation/collect-signals.sh b/.github/scripts/feature-ideation/collect-signals.sh index 692a70727..28e461c9b 100755 --- a/.github/scripts/feature-ideation/collect-signals.sh +++ b/.github/scripts/feature-ideation/collect-signals.sh @@ -44,11 +44,11 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" . "${SCRIPT_DIR}/lib/date-utils.sh" main() { - if [ -z "${REPO:-}" ]; then + if [[ -z "${REPO:-}" ]]; then printf '[collect-signals] REPO env var is required\n' >&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 @@ -103,7 +103,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"}]') @@ -144,7 +144,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 @@ -177,7 +177,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"}]') @@ -228,7 +228,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" @@ -239,7 +239,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 @@ -248,6 +248,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 d0a7a6a28..c3fb17dfc 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 @@ -85,7 +85,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 @@ -122,7 +122,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/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/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..20faa8937 100644 --- a/.github/scripts/pr-auto-review/lib/sweep.sh +++ b/.github/scripts/pr-auto-review/lib/sweep.sh @@ -33,8 +33,10 @@ 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 + # fire an unbounded dispatch burst. Avoid arithmetic comparison for large + # values that exceed Bash's signed-integer range — check for zero-only strings + # to avoid wrap-around errors with values like "9223372036854775808" (2^63). + if ! [[ "$max" =~ ^[0-9]+$ ]] || [[ "$max" =~ ^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..25cb61f1f 100644 --- a/.github/scripts/pr-auto-review/sweep-dispatch.sh +++ b/.github/scripts/pr-auto-review/sweep-dispatch.sh @@ -74,7 +74,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 +82,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 +105,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 +117,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 +136,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 +182,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 +194,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 +204,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 +226,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..f77e2308b 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. @@ -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,15 +491,15 @@ 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 @@ -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..e3f4aad16 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,20 @@ 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 + # Reject values too large for 64-bit Bash arithmetic before conversion. A 20-digit + # value wraps to 0; a 19-digit can wrap to a negative number. The 18-digit bound + # is always safe (fits in a signed 64-bit int: 2^63 - 1 = 9,223,372,036,854,775,807). + if [[ ${#findings} -gt 18 ]] || [[ ${#fps} -gt 18 ]]; then + printf 'cycle %s: count exceeds supported range\n' "$cycle" >&2 + rc=1; continue + fi + # Normalize the validated counts to base 10 before any arithmetic comparison. + # amcl_is_uint accepts leading-zero decimals like "010"; left as-is, bash + # arithmetic in [[ -gt ]]/[[ -eq ]] would read "010" as OCTAL 8, so a row + # claiming findings=9, fps=010 (ten) would wrongly pass the fps<=findings + # check. `10#` forces base-10 while still accepting the leading-zero input. + findings=$((10#$findings)); fps=$((10#$fps)) + 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 @@ -148,7 +161,7 @@ amcl_validate_log() { # 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 [[ "$((10#$fps))" -gt "$((10#$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 +169,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 [[ "$((10#$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 [[ "$((10#$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 @@ -192,6 +205,10 @@ amcl_clean_cycles_met() { printf 'false' return 0 fi + if ! amcl_is_uint "$required" || [[ ${#required} -gt 18 ]] || [[ "$((10#$required))" -lt 1 ]]; then + printf 'false' + return 0 + fi local rows deduped recent count clean=0 rows="$(amcl_data_rows "$log")" # Deduplicate by cycle date: retain only the last row for each distinct cycle @@ -210,21 +227,26 @@ amcl_clean_cycles_met() { } }' | sort) count="$(printf '%s' "$deduped" | grep -c . || true)" - if [ "$count" -lt "$required" ]; then + # Normalize to base 10 before comparison so leading-zero decimals like "010" + # are not misread as octal (see amcl_validate_log). + if [[ "$count" -lt "$((10#$required))" ]]; then printf 'false' return 0 fi - recent="$(printf '%s' "$deduped" | tail -n "$required")" + recent="$(printf '%s' "$deduped" | tail -n "$((10#$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 + # Normalize to base 10 before the `-eq 0` test so a leading-zero decimal + # like "00"/"010" is not misread as octal (see amcl_validate_log). + 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 + # Normalize to base 10 before comparison (consistent with line 221). + if [[ "$clean" -eq "$((10#$required))" ]]; then printf 'true' else printf 'false' @@ -255,12 +277,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,16 +294,16 @@ 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" || [[ ${#required} -gt 18 ]] || [[ "$((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 @@ -295,7 +317,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 +338,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 6b6355e14..6b1144a5b 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 @@ -244,11 +244,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' @@ -259,25 +259,25 @@ amdl_check_cross_references() { local _fc _flen local inline_regex='(.*)\]\(([^)]*)\)(.*)' 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 # Extract inline links: [text](target) temp_line="$line" @@ -367,13 +367,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") @@ -400,7 +400,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 ;; @@ -427,27 +427,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 @@ -455,7 +455,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 61f371e8d..b2efb9120 100755 --- a/scripts/apply-rulesets.sh +++ b/scripts/apply-rulesets.sh @@ -67,7 +67,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 @@ -79,14 +79,14 @@ 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 - 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 @@ -103,7 +103,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}" if ! cmp -s <(jq -cS . "$file") <(jq -cS . "$payload"); then echo " [dry-run] keeps the live, stricter code_scanning thresholds: $(jq -c '[.rules[] | select(.type == "code_scanning") | .parameters.code_scanning_tools[]]' "$payload")" @@ -119,7 +119,7 @@ apply_one() { rm -f "$payload" else echo " create ruleset '${name}' on ${repo}" - if [ "$DRY_RUN" = "true" ]; then echo " [dry-run] POST repos/${repo}/rulesets"; return 0; fi + if [[ "$DRY_RUN" = "true" ]]; then echo " [dry-run] POST repos/${repo}/rulesets"; return 0; fi gh api --method POST "repos/${repo}/rulesets" --input "$file" >/dev/null || { echo "::error::failed to create ruleset '${name}' on ${repo}" >&2 return 1 @@ -147,10 +147,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 ;; @@ -159,49 +159,49 @@ 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 repo failed=0 repos_rc=0 repos="$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500)" && 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 - [ -n "$repos" ] || { echo "::error::no repositories found in ${ORG} — check GH_TOKEN" >&2; return 1; } + [[ -n "$repos" ]] || { 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 86e8ba86a..4014b5d8b 100755 --- a/scripts/automerge-standards-sync.sh +++ b/scripts/automerge-standards-sync.sh @@ -73,7 +73,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 ;; @@ -84,8 +84,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 @@ -96,7 +96,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 } @@ -108,8 +108,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 @@ -133,7 +133,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 @@ -151,12 +151,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 @@ -165,13 +165,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 @@ -183,11 +183,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 @@ -220,23 +220,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 ae845fe02..cd14f7e84 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,7 +246,7 @@ _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 @@ -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 @@ -440,7 +440,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 @@ -491,7 +491,7 @@ step_gitignore() { fi 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 @@ -507,19 +507,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 @@ -532,12 +532,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 @@ -555,6 +555,6 @@ main() { } # 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 36c123536..cd0fbbf09 100755 --- a/scripts/canary-rollout.sh +++ b/scripts/canary-rollout.sh @@ -77,8 +77,11 @@ 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]+$ ]] || [[ "$((10#$CANARY_MAX_COMMIT_PAGES))" -le 0 ]]; then CANARY_MAX_COMMIT_PAGES=50 +else + # Normalize to base 10 to prevent leading-zero octal misinterpretation. + CANARY_MAX_COMMIT_PAGES=$((10#$CANARY_MAX_COMMIT_PAGES)) fi # CANARY_PROMOTION_FAILURE_ESCALATE_AFTER — how many CONSECUTIVE scheduled runs a tag write may @@ -188,12 +191,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" ;; @@ -227,7 +230,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 +239,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" @@ -281,7 +284,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 +305,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 out="$(_gh_write api -X PATCH "repos/$repo/git/refs/tags/$tag" \ -f sha="$sha" -F force=true 2>&1)" && return 0 @@ -349,24 +352,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=" @@ -385,12 +388,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 @@ -420,7 +423,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" @@ -440,7 +443,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 \ @@ -459,7 +462,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")" } @@ -575,7 +578,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" @@ -601,15 +604,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) @@ -634,14 +637,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) @@ -661,18 +664,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 } @@ -686,7 +689,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" } @@ -730,20 +733,20 @@ _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 @@ -767,13 +770,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 @@ -783,33 +786,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" @@ -824,7 +827,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 []. @@ -1342,7 +1345,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:--}" } @@ -1404,27 +1407,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") @@ -1447,10 +1450,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]}" @@ -1474,10 +1477,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 } @@ -1490,12 +1493,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 } @@ -1604,7 +1607,7 @@ _cumulative_health() { 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 @@ -1680,9 +1683,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 "")" @@ -1695,8 +1698,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 } @@ -1722,7 +1725,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 @@ -1754,18 +1757,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") @@ -1775,7 +1778,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" } @@ -1810,7 +1813,7 @@ _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 @@ -1829,10 +1832,10 @@ _decision_mix_table() { 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")" @@ -1841,8 +1844,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 @@ -1871,7 +1874,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 @@ -1897,7 +1900,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 @@ -1906,10 +1909,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 @@ -1976,9 +1979,9 @@ _pair_state() { # 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 @@ -1996,7 +1999,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 @@ -2011,11 +2014,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 @@ -2026,16 +2029,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 @@ -2048,28 +2051,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 @@ -2186,7 +2189,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 "----" @@ -2203,25 +2206,25 @@ cmd_evaluate() { 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") @@ -2237,12 +2240,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" @@ -2261,7 +2264,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 ;; @@ -2337,7 +2340,7 @@ cmd_promote() { fi local advance=false via_allow_pre=false soak_unmet=false [ "$state" = "PROMOTE" ] && advance=true - [ "$pair_override" = true ] && advance=true + [ "$pair_override" = true ] && [ "$state" != "AWAITING_CONFIRMATION" ] && 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. @@ -2428,7 +2431,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 @@ -2444,14 +2447,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" @@ -2472,9 +2475,9 @@ 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}") + [[ "$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]}" @@ -2500,17 +2503,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; } # Consistent path (#1076), mirroring cmd_promote: the target lookup AND the move both go # through gh api on the agent's HOST repo for every agent — never local git. host defaults # to THIS_REPO for an agent whose registry entry omits it. @@ -2519,9 +2522,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 @@ -2564,11 +2567,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 @@ -2596,7 +2599,7 @@ _blocker_evidence() { 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" } @@ -2642,7 +2645,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 < @@ -2665,16 +2668,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):** @@ -2683,11 +2686,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. @@ -2701,16 +2704,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." @@ -2755,12 +2758,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)" @@ -2840,7 +2843,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 @@ -2850,7 +2853,7 @@ _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" @@ -2872,8 +2875,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 @@ -2944,11 +2947,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 — @@ -3054,7 +3057,7 @@ cmd_sync_issues() { 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 echo " opened blocker issue #$num for $agent"; bl_link="#$num" @@ -3104,7 +3107,7 @@ cmd_sync_issues() { 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 @@ -3156,7 +3159,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 @@ -3168,7 +3171,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 } @@ -3185,7 +3188,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]++' } @@ -3193,14 +3196,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." @@ -3230,19 +3233,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 @@ -3264,11 +3267,11 @@ cmd_sync_promotion_failures() { 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 @@ -3284,18 +3287,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" 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"]:-}" @@ -3303,32 +3306,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" @@ -3390,8 +3393,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]++' } @@ -3411,12 +3414,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 @@ -3427,9 +3430,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) @@ -3442,7 +3445,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 } @@ -3505,10 +3508,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 @@ -3524,17 +3527,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" } @@ -3547,9 +3550,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 @@ -3559,7 +3562,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" } @@ -3568,7 +3571,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 } @@ -3595,22 +3598,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 @@ -3620,14 +3623,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" ;; @@ -3644,7 +3647,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" } @@ -3657,24 +3660,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 @@ -3685,22 +3688,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 @@ -3712,14 +3715,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 @@ -3731,8 +3734,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 @@ -3751,17 +3754,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" @@ -3815,7 +3818,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 @@ -3905,9 +3908,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 } @@ -3916,7 +3919,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 ;; @@ -3925,11 +3928,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")" @@ -3945,23 +3948,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 @@ -3969,7 +3972,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' @@ -3980,10 +3983,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" @@ -3993,12 +3996,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 @@ -4017,36 +4020,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" \ @@ -4065,12 +4068,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)) @@ -4078,7 +4081,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" \ @@ -4186,9 +4189,9 @@ 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 @@ -4201,12 +4204,12 @@ main() { 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) @@ -4220,6 +4223,6 @@ main() { [ "${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 544a9fe6d..e34a307e7 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -67,6 +67,10 @@ DEV_LEAD_HANDS_OFF_DESC="Exclude from dev-lead agent automation" # this label from the persona manifest; the audit ensures it locally too so a # finding issue can carry it on a repo the applier has not yet reconciled. DEV_LEAD_HANDS_OFF_COLOR="ededed" +# Finding categories and match types +CATEGORY_RULESETS="rulesets" +MATCH_TYPE_CLAUDE_ISSUE="claude-issue" +SETTING_MISSING_VALUE="missing" REPORT_DIR="${REPORT_DIR:-$(mktemp -d)}" DRY_RUN="${DRY_RUN:-false}" CREATE_ISSUES="${CREATE_ISSUES:-true}" @@ -183,7 +187,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 +197,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 +217,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 +263,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 @@ -398,18 +402,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" = "$SETTING_MISSING_VALUE" ]] && 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" = "$SETTING_MISSING_VALUE" ]] && return 2 return 1 } @@ -466,9 +470,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" @@ -524,11 +528,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> @@ -538,7 +542,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 @@ -590,9 +594,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 @@ -618,7 +622,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 @@ -637,9 +641,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" @@ -656,7 +660,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" @@ -685,7 +689,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" @@ -724,14 +728,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" @@ -741,7 +745,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" @@ -780,7 +784,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" @@ -788,7 +792,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" } @@ -836,7 +840,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 @@ -846,19 +850,19 @@ 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 "") if ! echo "$names" | grep -qx "pr-quality"; then - add_finding "$repo" "rulesets" "missing-pr-quality" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "missing-pr-quality" "error" \ "Missing \`pr-quality\` repository ruleset" \ "standards/github-settings.md#pr-quality--standard-ruleset-all-repositories" fi if ! echo "$names" | grep -qx "code-quality"; then - add_finding "$repo" "rulesets" "missing-code-quality" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "missing-code-quality" "error" \ "Missing \`code-quality\` repository ruleset (required status checks)" \ "standards/github-settings.md#code-quality--required-checks-ruleset-all-repositories" fi @@ -961,16 +965,16 @@ _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 - add_finding "$repo" "rulesets" "ruleset-contents-source-missing-$name" "error" \ + if [[ ! -f "$codified_file" ]]; then + add_finding "$repo" "$CATEGORY_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" return 0 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 - add_finding "$repo" "rulesets" "ruleset-contents-source-invalid-$name" "error" \ + if [[ -z "$exp_params" ]]; then + add_finding "$repo" "$CATEGORY_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" return 0 @@ -980,13 +984,13 @@ _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 - add_finding "$repo" "rulesets" "ruleset-contents-unfetchable-$name" "error" \ + if [[ -z "$rs" ]] || ! echo "$rs" | jq empty >/dev/null 2>&1; then + add_finding "$repo" "$CATEGORY_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" return 0 @@ -995,8 +999,8 @@ _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 - add_finding "$repo" "rulesets" "ruleset-drift-$name-missing-rule" "error" \ + if [[ "$has_rule" != "true" ]]; then + add_finding "$repo" "$CATEGORY_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" return 0 @@ -1004,23 +1008,23 @@ _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 if ! drift=$(jq -rn --argjson exp "$exp_params" --argjson act "$act_params" "$RULESET_DRIFT_JQ" 2>/dev/null); then - add_finding "$repo" "rulesets" "ruleset-contents-uncomparable-$name" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "ruleset-contents-uncomparable-$name" "error" \ "Could not compare the \`$name\` ruleset (id $rs_id) against \`standards/rulesets/$name.json\` (parameter comparison failed). Fail closed." \ "$std_ref" return 0 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" - add_finding "$repo" "rulesets" "ruleset-drift-$name-$slug" "error" \ + [[ -z "$slug" ]] && slug="param" + add_finding "$repo" "$CATEGORY_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" done <<< "$drift" @@ -1129,13 +1133,28 @@ 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 + if [[ -z "$rs" ]] || ! echo "$rs" | jq empty >/dev/null 2>&1; then + # Fail closed (consistent with _ruleset_contents_one): a ruleset we cannot + # fetch/parse must never read as "no finding" — that would let an + # incomplete audit silently pass and close existing bypass-actor findings. + # Mark the repo inconclusive as well: the emitted error finding alone does + # not stop close_resolved_issues from closing the existing + # ruleset-bypass-orgadmin-* / ruleset-bypass-dependabot-* issues (their + # check ids are absent from this partial run), and it defeats the + # zero-findings safeguard. Recording the repo as inconclusive keeps it from + # being treated as fully audited so no bypass finding is falsely resolved. + mark_repo_inconclusive "$repo" + add_finding "$repo" "$CATEGORY_RULESETS" "ruleset-bypass-unfetchable-$rs_id" "error" \ + "Could not fetch or parse ruleset id $rs_id to verify its required bypass actors. Treating as a finding rather than a pass (fail closed): an error must never be conflated with a compliant ruleset. Re-run the audit with a token that can read rulesets." \ + "$std_ref" + continue + fi # Does this ruleset target the default branch? Match the GitHub aliases # (~DEFAULT_BRANCH, ~ALL) or an explicit refs/heads/ include. @@ -1151,7 +1170,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") @@ -1159,7 +1178,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 @@ -1173,33 +1192,33 @@ 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 - add_finding "$repo" "rulesets" "ruleset-bypass-orgadmin-mode-$slug" "error" \ + if [[ "$oa_always" != "true" ]]; then + if [[ "$oa_any" = "true" ]]; then + add_finding "$repo" "$CATEGORY_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 - add_finding "$repo" "rulesets" "ruleset-bypass-orgadmin-$slug" "error" \ + elif [[ "$repo_admin" = "true" ]]; then + add_finding "$repo" "$CATEGORY_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" else - add_finding "$repo" "rulesets" "ruleset-bypass-orgadmin-$slug" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "ruleset-bypass-orgadmin-$slug" "error" \ "Ruleset \`$rs_name\` (targets \`$default_branch\`) is missing the required \`OrganizationAdmin\` bypass actor (\`bypass_mode: always\`) for emergency admin override." \ "$std_ref" fi fi # --- dependabot-automerge-petry app ----------------------------------- - if [ "$dep_always" != "true" ]; then - if [ "$dep_any" = "true" ]; then - add_finding "$repo" "rulesets" "ruleset-bypass-dependabot-mode-$slug" "error" \ + if [[ "$dep_always" != "true" ]]; then + if [[ "$dep_any" = "true" ]]; then + add_finding "$repo" "$CATEGORY_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" else - add_finding "$repo" "rulesets" "ruleset-bypass-dependabot-$slug" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "ruleset-bypass-dependabot-$slug" "error" \ "Ruleset \`$rs_name\` (targets \`$default_branch\`) is missing the required \`dependabot-automerge-petry\` app (id $DEPENDABOT_APP_ACTOR_ID) bypass actor (\`bypass_mode: always\`). Without it, Dependabot auto-merge API calls are rejected by this ruleset — GitHub evaluates bypass per-ruleset, so it must be present on every ruleset targeting the default branch, not only \`pr-quality\`." \ "$std_ref" fi @@ -1233,7 +1252,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. @@ -1258,9 +1277,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" @@ -1269,7 +1288,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 @@ -1283,26 +1302,26 @@ 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}Add them to \`code-quality\` first, 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\`)." fi - add_finding "$repo" "rulesets" "legacy-ruleset-$slug" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "legacy-ruleset-$slug" "error" \ "Legacy ruleset \`$rs_name\` (id $rs_id) targets the default branch. Only \`pr-quality\` and \`code-quality\` are sanctioned; classic \`protect-branches\` / ad-hoc \`main\` rulesets are deprecated and must be migrated into the two sanctioned rulesets and removed (a duplicate ruleset is a second place every bypass actor must be kept in sync). $migrate_note" \ "$std_ref" done @@ -1328,30 +1347,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." \ @@ -1373,7 +1392,7 @@ check_codeowners() { local owner_lines owner_lines=$(echo "$codeowners_content" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$') - 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" @@ -1384,16 +1403,16 @@ check_codeowners() { # (the first whitespace-separated token after the pattern). local bad_first_owner="" while IFS= read -r line; do - [ -z "$line" ] && continue + [[ -z "$line" ]] && continue # awk $2 = first owner token after the pattern local first_owner first_owner=$(echo "$line" | awk '{print $2}') - if [ "$first_owner" != "@combsmith/org-leads" ]; then + if [[ "$first_owner" != "@combsmith/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 \`@combsmith/org-leads\` as the FIRST owner. Offending line: \`$bad_first_owner\`" \ "standards/codeowners-standard.md" @@ -1407,7 +1426,7 @@ check_codeowners() { | grep -E '^@' \ | grep -vE '^@[^/]+/' \ | 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" \ @@ -1474,14 +1493,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 - echo "missing" + if [[ -z "$keys" ]]; then + echo "$SETTING_MISSING_VALUE" 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) @@ -1490,12 +1509,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" @@ -1528,14 +1547,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. @@ -1548,10 +1567,10 @@ 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 - echo "missing" + elif [[ "$missing" -eq 1 ]]; then + echo "$SETTING_MISSING_VALUE" else echo "present" fi @@ -1575,9 +1594,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 ;; @@ -1586,7 +1605,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 @@ -1595,10 +1614,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" @@ -1606,7 +1625,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") @@ -1615,7 +1634,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" @@ -1682,7 +1701,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. @@ -1702,7 +1721,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" @@ -1788,11 +1807,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 @@ -1811,7 +1830,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" @@ -1836,11 +1855,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 @@ -1883,11 +1902,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 @@ -1917,12 +1936,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 @@ -1933,7 +1952,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 @@ -1965,12 +1984,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="" @@ -1982,7 +2001,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")" @@ -1998,11 +2017,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 @@ -2091,14 +2110,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 @@ -2142,7 +2161,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\`." \ @@ -2196,8 +2215,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. @@ -2207,16 +2226,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 @@ -2225,7 +2244,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" @@ -2310,7 +2329,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 @@ -2358,14 +2377,14 @@ 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 for entry in "${renames[@]}"; do IFS=':' read -r old new <<< "$entry" if echo "$contexts" | grep -qxF "$old"; then - add_finding "$repo" "rulesets" "stale-required-check-${old// /-}" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "stale-required-check-${old// /-}" "error" \ "Required-status-check ruleset references the stale check name \`$old\`. After workflow centralization (combsmith/.github#87) this check is published as \`$new\`. Update the ruleset (and any classic branch protection) to use the new name." \ "standards/ci-standards.md#centralization-tiers" fi @@ -2381,26 +2400,26 @@ 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" ;; - "claude-issue") match_type="claude-issue" ;; + "$MATCH_TYPE_CLAUDE_ISSUE") match_type="$MATCH_TYPE_CLAUDE_ISSUE" ;; *"/ claude") match_type="claude" ;; - *"/ claude-issue") match_type="claude-issue" ;; + *"/ $MATCH_TYPE_CLAUDE_ISSUE") match_type="$MATCH_TYPE_CLAUDE_ISSUE" ;; *) continue ;; esac # 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" = "$MATCH_TYPE_CLAUDE_ISSUE" ]]; then check_id="required-claude-issue-check-broken" else check_id="required-claude-check-broken" fi - add_finding "$repo" "rulesets" "$check_id" "error" \ + add_finding "$repo" "$CATEGORY_RULESETS" "$check_id" "error" \ "Required-status-check ruleset includes \`$context\`, which is incompatible with workflow-modifying PRs. claude-code-action's GitHub App refuses to mint an OAuth token for any PR whose diff includes a workflow file, so the check fails on every workflow PR and the merge gate becomes a deadlock. **Remove \`$context\` from required status checks** — do NOT rename it. The Claude review check still runs on normal PRs and surfaces feedback without being a merge gate. See the codified \`standards/rulesets/code-quality.json\` for the canonical required-checks list." \ "standards/ci-standards.md#centralization-tiers" done <<< "$contexts" @@ -2416,7 +2435,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" @@ -2443,7 +2462,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' @@ -2461,7 +2480,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")" @@ -2477,7 +2496,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" @@ -2492,7 +2511,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" @@ -2503,16 +2522,14 @@ 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 - # 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) - # Both are treated as references to the org-level standards file. - if ! echo "$decoded" | grep -qE '(\.github/AGENTS\.md|combsmith/\.github/blob/.+/AGENTS\.md)'; then - add_finding "$repo" "standards" "agents-md-missing-org-ref" "error" \ - "\`AGENTS.md\` does not reference the org-level \`.github/AGENTS.md\` standards" \ - "AGENTS.md" - fi + # 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) + # Both are treated as references to the org-level standards file. + if [[ "$repo" != ".github" ]] && ! echo "$decoded" | grep -qE '(\.github/AGENTS\.md|combsmith/\.github/blob/.+/AGENTS\.md)'; then + add_finding "$repo" "standards" "agents-md-missing-org-ref" "error" \ + "\`AGENTS.md\` does not reference the org-level \`.github/AGENTS.md\` standards" \ + "AGENTS.md" fi # Phase 3 (#645): run the structural linter over the fetched file, INFORMATIONAL @@ -2539,7 +2556,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" @@ -2624,13 +2641,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" @@ -2676,19 +2693,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" @@ -2697,16 +2714,16 @@ check_check_suite_prefs() { for app_id in "${CHECK_SUITE_APP_IDS[@]}"; do local setting - setting=$(echo "$prefs" | jq -r --argjson id "$app_id" \ - '.preferences.auto_trigger_checks // [] | map(select(.app_id == $id)) | first | .setting // "missing"') + setting=$(echo "$prefs" | jq -r --argjson id "$app_id" --arg missing "$SETTING_MISSING_VALUE" \ + '.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_VALUE" means the app has never run in this repo — no orphaned suite possible + [[ "$setting" = "$SETTING_MISSING_VALUE" ]] && 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\`" \ @@ -2770,7 +2787,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 @@ -2783,7 +2800,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 @@ -2809,7 +2826,7 @@ create_issue_for_finding() { -q ".[] | select(.title == \"$search_title\") | .number" \ 2>/dev/null | head -1 || echo "") - 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. @@ -2825,7 +2842,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 @@ -2837,13 +2854,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" = "$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" @@ -2954,14 +2971,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" \ @@ -2987,7 +3004,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 @@ -3002,7 +3019,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 @@ -3042,7 +3059,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(", ")') @@ -3065,12 +3082,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))" @@ -3097,7 +3114,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" @@ -3136,11 +3153,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 @@ -3153,7 +3170,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 @@ -3166,7 +3183,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") @@ -3222,7 +3239,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" } @@ -3254,7 +3271,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 @@ -3314,7 +3331,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 @@ -3325,7 +3342,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. @@ -3386,7 +3403,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") @@ -3435,7 +3452,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 @@ -3452,8 +3469,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 } @@ -3472,7 +3489,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 { @@ -3484,7 +3501,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 @@ -3493,8 +3510,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" @@ -3509,7 +3526,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. @@ -3523,7 +3540,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 ;; @@ -3532,7 +3549,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 @@ -3563,7 +3580,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 @@ -3576,7 +3593,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[*]}" @@ -3585,7 +3602,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" @@ -3640,7 +3657,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 @@ -3649,7 +3666,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') @@ -3671,7 +3688,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 @@ -3680,7 +3697,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 @@ -418,7 +418,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 @@ -428,14 +428,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 @@ -446,7 +446,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 @@ -457,7 +457,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%@*}" @@ -468,7 +468,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 @@ -487,12 +487,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 "") @@ -502,12 +502,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 @@ -526,14 +526,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)" @@ -548,7 +548,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 @@ -573,7 +573,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." @@ -596,7 +596,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." @@ -623,7 +623,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 @@ -693,14 +693,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\`" @@ -901,17 +901,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 @@ -951,7 +951,7 @@ HEADER exit 1 fi - if [ -z "$repos" ]; then + if [[ -z "$repos" ]]; then info "No findings to remediate — nothing to do" exit 0 fi @@ -960,7 +960,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') @@ -1007,13 +1007,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..5c2d9e246 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' @@ -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 5d4f70567..e6cc397b5 100755 --- a/scripts/fix-ruleset-bypass.sh +++ b/scripts/fix-ruleset-bypass.sh @@ -141,14 +141,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 @@ -157,14 +157,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" @@ -179,7 +179,7 @@ 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" echo "$payload" | jq '.' > "$out" @@ -198,11 +198,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 @@ -218,26 +218,26 @@ for arg in "$@"; do esac done -[ -z "$TARGET" ] && usage +[[ -z "$TARGET" ]] && usage # --------------------------------------------------------------------------- # 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-rate-limit.sh b/scripts/lib/agent-rate-limit.sh index 7f5fa8250..be3214093 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" ]] } # --------------------------------------------------------------------------- @@ -154,7 +154,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 @@ -228,7 +228,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" ]] && [[ "$((10#$max))" -gt 0 ]] && [[ "$((10#$concurrent))" -ge "$((10#$max))" ]]; then decision="defer" reason="concurrency ${concurrent}/${max} at or over max_concurrent_runs" fi @@ -236,26 +236,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" ]] && [[ "$((10#$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" ]] && [[ "$((10#$daily))" -gt 0 ]] && [[ "$((10#$daily_count))" -ge "$((10#$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 } @@ -298,13 +298,13 @@ arl_breaker_decision() { backoff="$(arl_breaker_threshold "$agent_type" backoff_minutes)" # 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 [[ "$((10#$failures))" -lt "$threshold" ]]; then arl_log "breaker for '${agent_type}': state=closed (${failures}/${threshold} consecutive failures)" printf 'decision=allow\n' return 0 @@ -312,14 +312,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 +327,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 +341,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 +404,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 +426,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 @@ -785,12 +785,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 +809,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 +880,25 @@ 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]+$ ]] || [[ "$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 +924,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 +952,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 +982,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 +1008,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 +1016,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 +1083,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 +1100,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 @@ -1108,11 +1108,14 @@ arl_token_iso_to_epoch() { fi epoch="$(date -d "$iso" +%s 2>/dev/null || printf '')" [[ "$epoch" =~ ^[0-9]+$ ]] || return 0 - # Round-trip: if date normalised an invalid calendar date (e.g. Feb 30 → Mar 2) - # the reconstructed YYYY-MM-DD will not match the input prefix — reject it. + # Reject an invalid calendar date (e.g. Feb 30, which `date` would normalise to + # Mar 2) by round-tripping the DATE PORTION of the input directly. Using the + # date part — not the epoch reconstructed in UTC — keeps this timezone-neutral: + # a valid non-UTC timestamp near midnight (whose UTC date rolls to an adjacent + # day) must not be rejected just because it carries an offset. 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="$(date -d "${iso:0:10}" +%Y-%m-%d 2>/dev/null || printf '')" + [[ "$reconstructed_date" = "${iso:0:10}" ]] || return 0 printf '%s' "$epoch" } @@ -1127,7 +1130,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 +1152,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 +1177,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 +1227,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 +1246,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 +1266,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 +1275,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 +1285,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 +1306,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 +1357,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 +1381,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 +1390,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 +1414,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 +1430,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 +1452,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 @@ -1469,7 +1472,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 +1486,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 f501802ff..6cc4ed77a 100644 --- a/scripts/lib/canary-rollout.sh +++ b/scripts/lib/canary-rollout.sh @@ -22,8 +22,23 @@ # 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 + # Normalize to base 10 before comparisons so leading-zero decimals like "08" + # are not misread as octal. + # Signs are preserved; only the magnitude is normalized. + local name value sign + for name in v lo hi; do + value="${!name:-0}" + sign=1 + if [[ "$value" == -* ]]; then + sign=-1 + value="${value#-}" + elif [[ "$value" == +* ]]; then + value="${value#+}" + fi + printf -v "$name" "%s" "$(( sign * (10#${value:-0}) ))" + done + if [[ "$v" -lt "$lo" ]]; then echo "$lo"; return 0; fi + if [[ "$v" -gt "$hi" ]]; then echo "$hi"; return 0; fi echo "$v" } @@ -31,7 +46,12 @@ 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 + # Reject a nonpositive denominator first: "10#-1" is a bash arithmetic error. + # Then normalize to base 10 so leading-zero decimals like "08" are not misread + # as octal. Strip any leading plus sign before conversion. + if [[ "${d:-0}" == -* ]] || [[ "$((10#${d:-0}))" -le 0 ]]; then echo 0; return 1; fi + d="${d#+}" + d=$((10#${d:-0})) echo $(( (2 * n + d) / (2 * d) )) } @@ -39,14 +59,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 +85,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 +155,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 +187,11 @@ 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 + # Normalize to base 10 before comparison so leading-zero decimals like "08" + # are not misread as octal. + threshold="$((10#$threshold))" + [[ "$threshold" -lt 1 ]] && threshold=1 + if [[ "$((10#$count))" -ge "$threshold" ]]; then echo 1; else echo 0; fi } # ── autocut pagination termination (#1023) ──────────────────────────────────── @@ -178,7 +201,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 +253,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 @@ -270,7 +293,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 +320,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 } @@ -308,7 +331,11 @@ _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 + # Normalize to base 10 before comparison so leading-zero decimals like "08" + # are not misread as octal. + dwell=$((10#$dwell)) + floor=$((10#$floor)) + 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 @@ -344,13 +371,19 @@ 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 + # Normalize to base 10 before comparisons so leading-zero decimals like "08" + # are not misread as octal. + cum_fail=$((10#$cum_fail)) + cum_startup=$((10#$cum_startup)) + sample=$((10#$sample)) + target=$((10#$target)) + 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" @@ -383,9 +416,13 @@ 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 + # Normalize to base 10 before comparison so leading-zero decimals like "08" + # are not misread as octal. + [[ "$differs" =~ ^[0-9]+$ ]] && differs=$((10#$differs)) + [[ "$suspect" =~ ^[0-9]+$ ]] && suspect=$((10#$suspect)) + 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 @@ -412,15 +449,22 @@ 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 + # Normalize to base 10 before comparisons so leading-zero decimals like "08" + # are not misread as octal. + cand=$((10#${cand:-0})) + base=$((10#${base:-0})) + base_sample=$((10#${base_sample:-0})) + min_base=$((10#$min_base)) + margin=$((10#$margin)) # 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" } @@ -433,8 +477,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 } @@ -451,11 +495,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 @@ -467,7 +511,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" @@ -483,7 +527,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 "" @@ -525,10 +569,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 @@ -550,14 +594,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 @@ -566,7 +610,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] @@ -592,7 +636,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") @@ -617,9 +661,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)" @@ -630,10 +674,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 @@ -648,5 +692,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 33866c968..487f6bb3a 100644 --- a/scripts/lib/dev-lead-retrigger.sh +++ b/scripts/lib/dev-lead-retrigger.sh @@ -44,7 +44,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 @@ -57,7 +57,7 @@ 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}" local labeled_at labeled_at=$(gh api "repos/$org/$repo/issues/$issue/events?per_page=100" \ @@ -65,7 +65,7 @@ dl_dev_lead_active() { --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 @@ -78,18 +78,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" \ @@ -112,7 +112,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..e4f31c186 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 @@ -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/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 0707b2862..fc93e51c5 100644 --- a/scripts/lib/persona-mention.sh +++ b/scripts/lib/persona-mention.sh @@ -52,16 +52,16 @@ PERSONA_AGENT_MARKER='