From f895ac98f25a4e54be2d30a759c69876a5957a8a Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:44:01 -0500 Subject: [PATCH 1/2] chore(ci): add daily node-security-check workflow Add node-security-check.yml and .github/scripts/node_security_check.sh. Once a day the check compares Node.js security releases from nodejs.org/dist/index.json with two sources: - the NODE_VERSION pins in Dockerfile.unified - NODE_VERSION in the published stable- and beta- images, read anonymously from the ghcr.io image configs It derives the Node majors and the Node-based agents from the Dockerfile, so Node 22 and Node 24 (kimi) are covered without a hard-coded list. While a pin or a published image is behind, the check keeps one open issue labeled node-security, creating it or updating it in place. It closes the issue once everything is current. Stale data does not fail the workflow, as requested in #1567. Only a broken check fails it, such as a parse error, a network error, or an unexpected registry status. close-unlinked-stale-issues.yml now skips node-security issues. Without that exclusion it would close the tracking issue after 14 days and the next daily run would open a new one. Depends on the NODE22_VERSION/NODE24_VERSION pins from the companion PR. Without them the script fails with a clear error. Refs #1567 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/node_security_check.sh | 226 ++++++++++++++++++ .../workflows/close-unlinked-stale-issues.yml | 3 +- .github/workflows/node-security-check.yml | 26 ++ 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/node_security_check.sh create mode 100644 .github/workflows/node-security-check.yml diff --git a/.github/scripts/node_security_check.sh b/.github/scripts/node_security_check.sh new file mode 100644 index 000000000..ef6faf6d6 --- /dev/null +++ b/.github/scripts/node_security_check.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Tracks Node.js security releases against the Node pins in Dockerfile.unified +# and the Node inside the published ghcr.io/openabdev/openab images. Keeps one +# open issue labeled node-security while anything is behind and closes it once +# everything is current. Exits non-zero only when the check itself breaks, so +# stale data never turns the workflow red. See #1567. +# +# Env: DOCKERFILE (default Dockerfile.unified), DRY_RUN=1 prints the decision +# and issue body without calling gh, GH_TOKEN for gh. +set -euo pipefail +shopt -s inherit_errexit + +DOCKERFILE=${DOCKERFILE:-Dockerfile.unified} +LABEL='node-security' +REPO=openabdev/openab +CHANNELS=(stable beta) +MANIFEST_ACCEPT=application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json + +MAJORS=() +IMAGE_ROWS=() +declare -A PINNED=() AGENTS=() LATEST=() PIN_STATUS=() STALE_MAJOR=() + +die() { echo "::error::$*" >&2; exit 1; } + +version_lt() { [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1) == "$1" ]]; } + +status_of() { + if [[ -z $1 ]]; then echo "not published" + elif [[ -n ${LATEST[$2]} ]] && version_lt "$1" "${LATEST[$2]}"; then echo stale + else echo current + fi +} + +parse_dockerfile() { + [[ -r $DOCKERFILE ]] || die "cannot read $DOCKERFILE" + local line stage m + local -a stages=() unpinned=() + local -A stage_major=() + while IFS= read -r line; do + if [[ $line =~ ^ARG\ NODE([0-9]+)_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + PINNED[${BASH_REMATCH[1]}]=${BASH_REMATCH[2]} + elif [[ $line =~ ^FROM\ node:\$\{NODE([0-9]+)_VERSION\}[^\ ]*\ AS\ ([a-z0-9_-]+) ]]; then + stage_major[${BASH_REMATCH[2]}]=${BASH_REMATCH[1]} + stages+=("${BASH_REMATCH[2]}") + elif [[ $line =~ ^FROM\ node: ]]; then + unpinned+=("$line") + elif [[ $line =~ ^FROM\ ([a-z0-9_-]+)\ AS\ ([a-z0-9_-]+) && -n ${stage_major[${BASH_REMATCH[1]}]:-} ]]; then + stage_major[${BASH_REMATCH[2]}]=${stage_major[${BASH_REMATCH[1]}]} + stages+=("${BASH_REMATCH[2]}") + fi + done <"$DOCKERFILE" + + ((${#PINNED[@]})) || die "no 'ARG NODE_VERSION=' found in $DOCKERFILE" + ((${#unpinned[@]} == 0)) || die "Node stage not pinned through NODE_VERSION in $DOCKERFILE: ${unpinned[*]}" + for stage in "${stages[@]}"; do + m=${stage_major[$stage]} + [[ -n ${PINNED[$m]:-} ]] || die "stage $stage uses NODE${m}_VERSION but $DOCKERFILE declares no ARG for it" + # base-* stages are shared parents, not published image targets. + [[ $stage == base-* ]] || AGENTS[$m]+="$stage " + done + mapfile -t MAJORS < <(printf '%s\n' "${!PINNED[@]}" | sort -n) +} + +load_latest_security() { + local index m + index=$(curl -fsSL --retry 3 https://nodejs.org/dist/index.json) || die "cannot fetch https://nodejs.org/dist/index.json" + for m in "${MAJORS[@]}"; do + LATEST[$m]=$(jq -r --arg p "v$m." '.[] | select(.security and (.version | startswith($p))) | .version | ltrimstr("v")' <<<"$index" | sort -V | tail -n1) + done +} + +# Prints the response body, or nothing when the registry answers 404. +registry_get() { + local code + code=$(curl -sSL --retry 3 -o "$TMP/registry" -w '%{http_code}' \ + -H "Authorization: Bearer $REGISTRY_TOKEN" -H "Accept: $2" \ + "https://ghcr.io/v2/$REPO/$1") || die "GET ghcr.io/v2/$REPO/$1 failed" + case $code in + 200) cat "$TMP/registry" ;; + 404) ;; + *) die "GET ghcr.io/v2/$REPO/$1 returned HTTP $code" ;; + esac +} + +# Prints NODE_VERSION from the linux/amd64 image config, or nothing when the tag is not published. +image_node_version() { + local manifest digest config_digest config + manifest=$(registry_get "manifests/$1" "$MANIFEST_ACCEPT") + [[ -n $manifest ]] || return 0 + digest=$(jq -r '.manifests[]? | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' <<<"$manifest" | head -n1) + if [[ -n $digest ]]; then + manifest=$(registry_get "manifests/$digest" "$MANIFEST_ACCEPT") + fi + config_digest=$(jq -er '.config.digest' <<<"$manifest") || die "$1: no linux/amd64 config in manifest" + config=$(registry_get "blobs/$config_digest" '*/*') + jq -er '.config.Env[] | select(startswith("NODE_VERSION=")) | ltrimstr("NODE_VERSION=")' <<<"$config" || + die "$1: NODE_VERSION not set in image config" +} + +assess() { + local m agent channel node status + REGISTRY_TOKEN=$(curl -fsS --retry 3 "https://ghcr.io/token?scope=repository:$REPO:pull" | jq -er .token) || + die "cannot get an anonymous ghcr.io pull token" + for m in "${MAJORS[@]}"; do + PIN_STATUS[$m]=$(status_of "${PINNED[$m]}" "$m") + if [[ ${PIN_STATUS[$m]} == stale ]]; then STALE_MAJOR[$m]=1; fi + for agent in ${AGENTS[$m]:-}; do + for channel in "${CHANNELS[@]}"; do + node=$(image_node_version "$channel-$agent") + status=$(status_of "$node" "$m") + IMAGE_ROWS+=("| \`$channel-$agent\` | ${node:--} | $status |") + if [[ $status == stale ]]; then STALE_MAJOR[$m]=1; fi + done + done + done +} + +release_link() { + if [[ -n $1 ]]; then echo "[$1](https://nodejs.org/en/blog/release/v$1)"; else echo none; fi +} + +render_tables() { + local m agents + echo "### Pinned base images (\`$DOCKERFILE\`)" + echo + echo "| Node line | Pinned | Latest security release | Status | Agents |" + echo "|---|---|---|---|---|" + for m in "${MAJORS[@]}"; do + agents=${AGENTS[$m]:-} + agents=${agents% } + echo "| $m | ${PINNED[$m]} | $(release_link "${LATEST[$m]}") | ${PIN_STATUS[$m]} | ${agents// /, } |" + done + echo + echo "### Published images (\`ghcr.io/$REPO\`, linux/amd64)" + echo + echo "| Tag | Node | Status |" + echo "|---|---|---|" + printf '%s\n' "${IMAGE_ROWS[@]}" +} + +render_issue_body() { + cat <_VERSION\` to the latest security release in every Dockerfile that declares it (\`git grep -lE 'ARG NODE(22|24)_VERSION' -- 'Dockerfile*'\`). The smoke tests check that the values match. +2. Follow the "Base image security release" runbook in [RELEASING.md](https://github.com/$REPO/blob/main/RELEASING.md#base-image-security-release) to cut a beta, verify \`node --version\` in each \`beta-\` image, and promote to stable. +3. Background and policy: #1567. + +Last checked: $(date -u '+%Y-%m-%d %H:%M UTC') +EOF +} + +issue_title() { + local m versions=() + for m in "${MAJORS[@]}"; do + if [[ -n ${STALE_MAJOR[$m]:-} ]]; then versions+=("${LATEST[$m]}"); fi + done + local joined + joined=$(printf '%s / ' "${versions[@]}") + echo "Node.js security release pending: ${joined% / }" +} + +# (anything stale, labeled issue open) -> action. +decide() { + case "$1$2" in + 10) echo create ;; + 11) echo update ;; + 01) echo close ;; + 00) echo none ;; + esac +} + +ensure_label() { + local exists + exists=$(gh label list --search "$LABEL" --json name --jq "any(.[]; .name == \"$LABEL\")") + [[ $exists == true ]] || gh label create "$LABEL" --color B60205 --description "Node.js security release pending for openab images" +} + +# Args: stale (0|1), title, body file. +reconcile() { + local issue action has_issue=0 + issue=$(gh issue list --label "$LABEL" --state open --limit 1 --json number --jq '.[0].number // empty') + if [[ -n $issue ]]; then has_issue=1; fi + action=$(decide "$1" "$has_issue") + echo "decision: $action${issue:+ (#$issue)}" + case $action in + create) + ensure_label + gh issue create --title "$2" --label "$LABEL" --body-file "$3" + ;; + update) gh issue edit "$issue" --title "$2" --body-file "$3" ;; + close) gh issue close "$issue" --comment "Every Node pin and published image is on the latest Node.js security release for its line. Closing. + +$(render_tables)" ;; + none) ;; + esac +} + +main() { + TMP=$(mktemp -d) + trap 'rm -rf "$TMP"' EXIT + parse_dockerfile + load_latest_security + assess + + local stale=0 title + if ((${#STALE_MAJOR[@]})); then stale=1; fi + title=$(issue_title) + render_issue_body >"$TMP/body.md" + if [[ -n ${GITHUB_STEP_SUMMARY:-} ]]; then render_tables >>"$GITHUB_STEP_SUMMARY"; fi + + if [[ ${DRY_RUN:-} == 1 ]]; then + if ((stale)); then echo "decision: create or update issue"; else echo "decision: close the open issue if any, else none"; fi + echo "title: $title" + echo + cat "$TMP/body.md" + else + reconcile "$stale" "$title" "$TMP/body.md" + fi +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index 3988610b5..f34bb4ea0 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -312,7 +312,8 @@ jobs: } // ── Phase 2: process new candidates ── - const query = `${repoQ} is:issue state:open -linked:pr -label:${HANDLED_LABEL} -label:${PENDING_LABEL} -label:${CLOSING_LABEL} created:<${cutoff}`; + // node-security issues are owned by node-security-check.yml, which closes them itself. + const query = `${repoQ} is:issue state:open -linked:pr -label:${HANDLED_LABEL} -label:${PENDING_LABEL} -label:${CLOSING_LABEL} -label:node-security created:<${cutoff}`; core.info(`query: ${query}${DRY_RUN ? ' (dry run)' : ''}`); const result = await searchIssues(query, { sort: 'created', order: 'asc' }); for (const issue of result.items) { diff --git a/.github/workflows/node-security-check.yml b/.github/workflows/node-security-check.yml new file mode 100644 index 000000000..246e5f3d4 --- /dev/null +++ b/.github/workflows/node-security-check.yml @@ -0,0 +1,26 @@ +name: Node security check + +on: + schedule: + - cron: '47 7 * * *' # daily at 07:47 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +# A scheduled run and a manual dispatch racing could each create an issue. +concurrency: + group: node-security-check + +jobs: + check: + if: github.repository == 'openabdev/openab' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Compare Node pins and published images with Node.js security releases + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/node_security_check.sh From d95667249bef8e54461fcdd6d893d2886c60e146 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:29:38 +0000 Subject: [PATCH 2/2] fix(review): add PR dry-run gate and derive majors in remediation hint - node-security-check.yml: add a pull_request trigger (paths: script, workflow, Dockerfile.unified) that runs the script with DRY_RUN=1 and contents: read only. Parser-breaking Dockerfile changes now fail in their own PR instead of the next scheduled run. issues: write moves to the scheduled/dispatch job, which keeps the repository guard. PR runs use a per-PR concurrency group so they never queue behind reconcile. - node_security_check.sh: the remediation hint greps 'ARG NODE[0-9]+_VERSION' instead of hard-coding 22|24, matching the script's dynamic major discovery. Addresses review findings F3 and F4 on #1571. --- .github/scripts/node_security_check.sh | 2 +- .github/workflows/node-security-check.yml | 28 ++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/scripts/node_security_check.sh b/.github/scripts/node_security_check.sh index ef6faf6d6..438678783 100644 --- a/.github/scripts/node_security_check.sh +++ b/.github/scripts/node_security_check.sh @@ -146,7 +146,7 @@ $(render_tables) ### Next steps -1. For each stale pin, bump \`ARG NODE_VERSION\` to the latest security release in every Dockerfile that declares it (\`git grep -lE 'ARG NODE(22|24)_VERSION' -- 'Dockerfile*'\`). The smoke tests check that the values match. +1. For each stale pin, bump \`ARG NODE_VERSION\` to the latest security release in every Dockerfile that declares it (\`git grep -lE 'ARG NODE[0-9]+_VERSION' -- 'Dockerfile*'\`). The smoke tests check that the values match. 2. Follow the "Base image security release" runbook in [RELEASING.md](https://github.com/$REPO/blob/main/RELEASING.md#base-image-security-release) to cut a beta, verify \`node --version\` in each \`beta-\` image, and promote to stable. 3. Background and policy: #1567. diff --git a/.github/workflows/node-security-check.yml b/.github/workflows/node-security-check.yml index 246e5f3d4..488fdb463 100644 --- a/.github/workflows/node-security-check.yml +++ b/.github/workflows/node-security-check.yml @@ -4,20 +4,42 @@ on: schedule: - cron: '47 7 * * *' # daily at 07:47 UTC workflow_dispatch: + # Dry-run regression gate: a change that breaks the Dockerfile parser or the + # script fails in its own PR instead of in the next scheduled run. + pull_request: + paths: + - .github/scripts/node_security_check.sh + - .github/workflows/node-security-check.yml + - Dockerfile.unified permissions: contents: read - issues: write # A scheduled run and a manual dispatch racing could each create an issue. +# PR dry runs never touch issues, so they get their own per-PR group. concurrency: - group: node-security-check + group: node-security-check-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'reconcile' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + dry-run: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Dry-run the Node security check (no gh calls) + env: + DRY_RUN: '1' + run: bash .github/scripts/node_security_check.sh + check: - if: github.repository == 'openabdev/openab' + if: github.event_name != 'pull_request' && github.repository == 'openabdev/openab' runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v6 - name: Compare Node pins and published images with Node.js security releases