diff --git a/.github/workflows/status_check.yml b/.github/workflows/status_check.yml index f650df4..1c95fff 100644 --- a/.github/workflows/status_check.yml +++ b/.github/workflows/status_check.yml @@ -156,7 +156,37 @@ jobs: key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- + # Shell is the other language in this repo -- four scripts, one of them the CI + # entry point itself -- and nothing was checking it. `git ls-files` rather than a + # fixed list, so a script added later is covered without editing this workflow. + # + # Full severity, `info` included. The findings it raises today are answered with + # targeted `disable` directives carrying their reason, the same way + # config/detekt/detekt.yml carries only the rules this codebase legitimately + # breaks. A blanket --severity=warning would have hidden them and the next real + # one alike. + # + # PINNED BY DIGEST, for the reason CLAUDE.md already gives for pinning ktlint, + # detekt and JaCoCo: a new rule in a linter makes files nobody touched stop + # passing, so CI goes red on a PR whose diff cannot explain it. That is not + # hypothetical here. The first cut of this step used the runner's ambient + # shellcheck, which is 0.9.0, and 0.9.0 reports a trap handler as seven + # unreachable commands (SC2317) where 0.11.0 reports it once on the declaration + # (SC2329) -- same script, same directive, different answer, and a red build on + # the PR that introduced the step. The version is printed so a finding that + # appears out of nowhere can be tied to a bump of this line. + - name: shellcheck + env: + SHELLCHECK: koalaman/shellcheck@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d + run: | + docker run --rm "$SHELLCHECK" --version + git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK" + + # `!cancelled()` rather than a plain sequence: a shellcheck failure above must not + # cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one + # round trip should produce every list, not stop at the first. - name: ktlint, detekt and Android lint + if: '!cancelled()' run: ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue --stacktrace # The XML matters as much as the HTML: it is the one that can be diffed between diff --git a/CLAUDE.md b/CLAUDE.md index af6e7b0..33d0ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,42 @@ install for code that can never run — and on API 37 the full APK does not fit - `kotlin.code.style=official`. Gradle stays Kotlin DSL. +- **File one-off issues with `tools/github/file-issue.sh`, not `gh issue create`.** `gh issue + create` does not touch the project board, so the issue exists, carries its labels, and is + invisible in the Kanban — indistinguishable from never having been filed. Measured 2026-08-24: + eight issues filed as a scripted batch all reached the board; one filed as a one-off minutes + later did not. A batch carries the board step in its loop; **one-offs are where it slips**, which + is what the script is for. It resolves the project and Status ids by name rather than caching + them, and it **reads the item back** — a mutation returning 200 is not evidence the board shows + what was asked for. Exit 3 means the issue was created but did not reach the board, and prints + the number so it cannot be lost quietly. + + `above-cut` and `backlog` are **labels from the 2026-08-22 triage pass** — "worked autonomously + overnight" and "held for manual review". They are not board columns. Status carries board state; + do not put a cut label on a newly filed ticket. + +- **shellcheck runs in CI**, inside the Static analysis job, over `git ls-files '*.sh'` so a new + script is covered without editing the workflow. It runs at full severity, `info` included: the + two findings that raises today are answered with targeted `disable` directives carrying their + reason, exactly as `config/detekt/detekt.yml` carries only the rules this codebase legitimately + breaks. Do not silence it with `--severity=warning` — that hides the next real finding too. + **It is pinned by image digest, and joins ktlint/detekt/JaCoCo in the "Dependency versions" + rule above** — for exactly the reason stated there, demonstrated the day it was added. The first + cut used the runner's ambient shellcheck. That is **0.9.0**, while the container used to check + locally was 0.11.0, and the two disagree about how to report a trap handler: 0.11.0 says + `SC2329` once on the declaration, 0.9.0 says `SC2317` on each of seven lines in the body. Same + script, same directive, one green and one red. Directives that must survive both name both codes. + + Locally, use the same pin rather than whatever is installed: + `podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... ` + (the digest is in `status_check.yml`; there is no shellcheck system package on this host). + + **It does not cover inline `run:` blocks in the workflows**, and a good deal of this repo's bash + lives there. `actionlint` does cover them — it runs shellcheck over each `run:` — and reports one + pre-existing `info` finding in `build.yml`. It is not wired in because every action here is + pinned by SHA, and actionlint's usual installer is a `curl | bash` off a moving branch; doing it + properly means pinning a container digest. Tracked separately rather than bolted on. + ## Dependency versions Libraries **float on minor + patch** (`coreKtx = "1.+"`). Three groups deliberately do not: diff --git a/tools/github/file-issue.sh b/tools/github/file-issue.sh new file mode 100755 index 0000000..a48a866 --- /dev/null +++ b/tools/github/file-issue.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# +# Files a GitHub issue AND puts it on the project board, as one operation. +# +# Usage: tools/github/file-issue.sh --title TITLE (--body TEXT | --body-file PATH) [options] +# +# --status NAME board column, matched case-insensitively against the board's own +# options; a miss lists what is available. Default: Backlog +# --label NAME repeatable. Passed through to `gh issue create` unchanged. +# --project N project number. Default: $ISSUE_PROJECT_NUMBER, else 6 +# --repo OWNER/NAME default: whatever `gh repo view` resolves in the working directory +# --dry-run resolve and validate everything, create nothing +# +# EXIT CODE: 0 only when the issue exists, is on the board, AND reads back carrying the +# Status that was asked for. 2 for a usage or validation error, before anything is created. +# **3 means the issue was created but did not reach the board** -- the number is printed on +# a line of its own, because that combination is the entire failure this script exists to +# prevent and it must never be quiet. +# +# WHY THIS EXISTS +# +# `gh issue create` does not touch the project board. The issue is created, carries its +# labels, and is invisible in the Kanban -- which looks exactly like a ticket nobody filed. +# Measured 2026-08-24: eight issues filed as a scripted batch all reached the board; one +# filed as a one-off a few minutes later did not, and was caught only because someone went +# looking. A batch carries the board step inside its loop. One-offs are where it slips, so +# one-offs are what this is for. +# +# Adding an item and setting a field value are GraphQL-only. REST can list project items +# and field definitions, but the `fields` array it returns on an item carries Title and +# nothing else -- a REST-only check reports every item's Status as unset, which is why the +# read-back at the end is a GraphQL query rather than the cheaper REST one. +# +# WHAT IT DELIBERATELY DOES NOT DO +# +# It does not cache the project, field or option ids. Resolving them by name costs one +# GraphQL query per run, and it means a renamed or reordered column cannot make this write +# a stale id. The ids are the fragile part; the names are what people actually use. +# +# It does not apply triage labels for you. `above-cut` and `backlog` are labels from one +# specific 2026-08-22 triage pass -- they mean "worked autonomously overnight" and "held for +# manual review", not "this is in the Backlog column". Status carries board state. Pass +# --label only for things that are true about the issue itself. +# +# It does not create the project, the Status field, or a missing option. Anything absent is +# an error to report, not to invent. + +set -euo pipefail + +readonly EXIT_USAGE=2 +readonly EXIT_ORPHANED=3 + +die() { + printf 'file-issue: %s\n' "$1" >&2 + exit "${2:-$EXIT_USAGE}" +} + +title="" +body="" +body_file="" +status="Backlog" +project="${ISSUE_PROJECT_NUMBER:-6}" +repo="" +dry_run=0 +labels=() + +while [ $# -gt 0 ]; do + case "$1" in + --title) [ $# -ge 2 ] || die "--title needs a value"; title="$2"; shift 2 ;; + --body) [ $# -ge 2 ] || die "--body needs a value"; body="$2"; shift 2 ;; + --body-file) [ $# -ge 2 ] || die "--body-file needs a path"; body_file="$2"; shift 2 ;; + --status) [ $# -ge 2 ] || die "--status needs a value"; status="$2"; shift 2 ;; + --label) [ $# -ge 2 ] || die "--label needs a value"; labels+=("$2"); shift 2 ;; + --project) [ $# -ge 2 ] || die "--project needs a number"; project="$2"; shift 2 ;; + --repo) [ $# -ge 2 ] || die "--repo needs OWNER/NAME"; repo="$2"; shift 2 ;; + --dry-run) dry_run=1; shift ;; + -h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0" + exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$title" ] || die "--title is required" +if [ -n "$body" ] && [ -n "$body_file" ]; then + die "pass --body or --body-file, not both" +fi +[ -n "$body" ] || [ -n "$body_file" ] || die "one of --body or --body-file is required" +if [ -n "$body_file" ] && [ ! -r "$body_file" ]; then + die "--body-file is not readable: $body_file" +fi +case "$project" in + ''|*[!0-9]*) die "--project must be a number, got: $project" ;; +esac + +command -v gh >/dev/null 2>&1 || die "gh is not on PATH" + +if [ -z "$repo" ]; then + repo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') \ + || die "could not resolve the repository; pass --repo OWNER/NAME" +fi +owner="${repo%%/*}" +[ -n "$owner" ] || die "could not read an owner out of: $repo" + +# --------------------------------------------------------------------------- +# Resolve the board by NAME. Every id below is read fresh; none is hardcoded. +# --------------------------------------------------------------------------- + +# The $names in the query are GraphQL variables, declared by the query and bound by the +# -f flags. Expanding them in the shell would send this shell's idea of $owner to the +# API instead of declaring a parameter -- which is why every query here is single-quoted. +# shellcheck disable=SC2016 +board=$(gh api graphql \ + -f query=' + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } + }' \ + -f owner="$owner" -F number="$project" 2>&1) \ + || die "could not read project $project for $owner. A 403 naming scopes means gh is +missing 'project'; a 403 naming a rate limit is the GraphQL budget, not permissions. The +API said: $board" + +project_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.id // empty') +field_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.field.id // empty') +project_title=$(printf '%s' "$board" | jq -r '.data.user.projectV2.title // empty') + +[ -n "$project_id" ] || die "no project number $project under user $owner" +[ -n "$field_id" ] || die "project $project has no single-select field named 'Status'" + +# Case-insensitive match, so "backlog" and "Backlog" both work. The canonical name is +# what gets reported back, so a sloppy argument still produces an exact log line. +option=$(printf '%s' "$board" | jq -r --arg want "$status" ' + .data.user.projectV2.field.options[] + | select((.name | ascii_downcase) == ($want | ascii_downcase)) + | "\(.id)\t\(.name)"' | head -n 1) + +if [ -z "$option" ]; then + printf 'file-issue: no Status option named %s. Available:\n' "$status" >&2 + printf '%s' "$board" | jq -r '.data.user.projectV2.field.options[] | " " + .name' >&2 + exit "$EXIT_USAGE" +fi +option_id="${option%%$'\t'*}" +status_canonical="${option#*$'\t'}" + +printf 'repo %s\n' "$repo" +printf 'board %s (project %s)\n' "$project_title" "$project" +printf 'status %s\n' "$status_canonical" +printf 'labels %s\n' "${labels[*]:-(none)}" +printf 'title %s\n' "$title" + +if [ "$dry_run" -eq 1 ]; then + printf '\ndry run: everything above resolved; nothing was created.\n' + exit 0 +fi + +# --------------------------------------------------------------------------- +# Create. Past this line a failure can leave an issue off the board, so every +# error path prints the number. +# --------------------------------------------------------------------------- + +create_args=(--repo "$repo" --title "$title") +if [ -n "$body_file" ]; then + create_args+=(--body-file "$body_file") +else + create_args+=(--body "$body") +fi +for label in ${labels[@]+"${labels[@]}"}; do + create_args+=(--label "$label") +done + +issue_url=$(gh issue create "${create_args[@]}") || die "gh issue create failed; nothing was filed" +issue_number="${issue_url##*/}" +case "$issue_number" in + ''|*[!0-9]*) die "could not read an issue number out of: $issue_url" ;; +esac + +orphaned() { + printf 'file-issue: %s\n' "$1" >&2 + printf 'file-issue: THE ISSUE EXISTS BUT IS NOT ON THE BOARD. Fix it by hand:\n' >&2 + printf '%s\n' "$issue_url" >&2 + exit "$EXIT_ORPHANED" +} + +content_id=$(gh api "/repos/$repo/issues/$issue_number" --jq '.node_id') \ + || orphaned "could not read the node id for #$issue_number" + +# shellcheck disable=SC2016 # GraphQL variables, as above +item_id=$(gh api graphql \ + -f query=' + mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $content}) { + item { id } + } + }' \ + -f project="$project_id" -f content="$content_id" \ + --jq '.data.addProjectV2ItemById.item.id') \ + || orphaned "could not add #$issue_number to the board" +[ -n "$item_id" ] || orphaned "the board add returned no item id for #$issue_number" + +# shellcheck disable=SC2016 # GraphQL variables, as above +gh api graphql \ + -f query=' + mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $field, + value: {singleSelectOptionId: $option} + }) { projectV2Item { id } } + }' \ + -f project="$project_id" -f item="$item_id" -f field="$field_id" -f option="$option_id" \ + >/dev/null \ + || orphaned "#$issue_number is on the board but its Status could not be set" + +# --------------------------------------------------------------------------- +# Read back. A mutation returning 200 is not evidence the board shows what was +# asked for -- this is the only check that is. +# --------------------------------------------------------------------------- + +# shellcheck disable=SC2016 # GraphQL variables, as above +readback=$(gh api graphql \ + -f query=' + query($item: ID!) { + node(id: $item) { + ... on ProjectV2Item { + content { ... on Issue { number } } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + }' \ + -f item="$item_id") \ + || orphaned "#$issue_number was written but could not be read back" + +seen_number=$(printf '%s' "$readback" | jq -r '.data.node.content.number // empty') +seen_status=$(printf '%s' "$readback" | jq -r '.data.node.fieldValueByName.name // empty') + +if [ "$seen_number" != "$issue_number" ]; then + orphaned "read-back names issue #${seen_number:-}, expected #$issue_number" +fi +if [ "$seen_status" != "$status_canonical" ]; then + orphaned "read-back Status is ${seen_status:-}, expected $status_canonical" +fi + +printf '\n#%s on %s as %s -- verified by read-back\n' \ + "$issue_number" "$project_title" "$seen_status" +printf '%s\n' "$issue_url" diff --git a/tools/local-emulator/run-e2e.sh b/tools/local-emulator/run-e2e.sh index 811e809..ed34159 100755 --- a/tools/local-emulator/run-e2e.sh +++ b/tools/local-emulator/run-e2e.sh @@ -456,6 +456,10 @@ cleanup() { # bash runs a trap only between commands, so this starts when whatever was in the foreground # returns -- which for Ctrl-C is immediately, because the same interrupt reached that command # too. `kill -INT` aimed at this script alone waits for the foreground command to finish. +# Invoked indirectly -- installed as the INT and TERM trap a few lines below. Both codes, +# because shellcheck 0.9.0 reports this as unreachable commands (SC2317) and 0.11.0 as an +# uninvoked function (SC2329); CI pins 0.11.0 but a local install may be either. +# shellcheck disable=SC2317,SC2329 on_signal() { echo echo "interrupted (SIG$1) -- stopping the emulator and removing the AVDs this run created"