diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index f6d43542..8467876d 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -348,6 +348,47 @@ jobs: echo "from=type=gha,scope=${{ matrix.image }}" >> "$GITHUB_OUTPUT" echo "to=type=gha,mode=max,scope=${{ matrix.image }}" >> "$GITHUB_OUTPUT" fi + # #3316: build the label list HERE, in a shell, because the newline has + # to be real. This used to be a single `labels:` expression: + # + # ${{ steps.metadata.outputs.labels }}${{ ... && format('\napiary.ci.build-row={0}-{1}-{2}', ...) || '' }} + # + # and GitHub's format() does not interpret \n -- it emits two literal + # characters, backslash and n. So the text was appended to the END of + # the previous label's value and apiary.ci.build-row never existed as + # its own key. The run log shows it exactly as it reached buildkit: + # + # "label:org.opencontainers.image.version": "sha-047a7a3\\napiary.ci.build-row=dashboard-next-36338870116-1" + # + # which made the `--filter "label=apiary.ci.build-row=..."` lookup in + # the boot-smoke step below unmatchable -- every pull_request run + # failed here, and only the push path ever looked, which is why it + # went unnoticed. printf %s\n is the only place in this file that can + # produce an actual line break; an expression cannot. + # + # Everything interpolated arrives through env:, never pasted into this + # run block, for the same zizmor template-injection reason the + # boot-smoke step below uses. + - name: Append the boot-smoke build-row label + id: buildrow + if: matrix.boot_smoke == true + env: + BASE_LABELS: ${{ steps.metadata.outputs.labels }} + ROW: ${{ matrix.image }}-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + # The `<> "$GITHUB_OUTPUT" - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 # id: build -- #3321 reads steps.build.outputs.digest, the pushed # manifest digest, to key the dashboard rows' SBOM to the exact image @@ -376,8 +417,12 @@ jobs: # neighbour's build. A per-row, per-attempt label is the only # handle that is unique to *this* build. Appended only for the two # boot-smoke rows: on a push it is redundant (the digest names the - # image) and the manifest should not carry a CI run id. - labels: ${{ steps.metadata.outputs.labels }}${{ matrix.boot_smoke == true && format('\napiary.ci.build-row={0}-{1}-{2}', matrix.image, github.run_id, github.run_attempt) || '' }} + # image) and the manifest should not carry a CI run id. The + # `steps.buildrow.outputs.labels ||` is what keeps the other + # sixteen rows byte-identical: that step is skipped for them, so + # the output is empty and this falls back to exactly the + # metadata-action list they have always built with. + labels: ${{ steps.buildrow.outputs.labels || steps.metadata.outputs.labels }} # #3315: the revision this image is built from, for the two rows # whose Dockerfile declares `ARG GIT_SHA` (matrix.stamp). It has to # be a build arg and not only a label: backend-service compiles it @@ -450,10 +495,14 @@ jobs: # First tag only: metadata-action emits one per ref, and only one # of them is the branch build this smoke is about. IMAGE_TAG: ${{ steps.metadata.outputs.tags }} - # Empty exactly when nothing was pushed, which is the - # pull_request case -- there the build loaded the image locally - # (see the `load:` input) and there is no digest to pull. - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + # Only meaningful when a push actually happened. build-push-action + # emits a digest even with `push: false`, where it describes the + # locally built image and names nothing in the registry -- so + # testing this for non-empty sent every pull_request down the + # docker-pull path against a tag that was never pushed (#3316). + # On a pull_request the build loaded the image locally (see the + # `load:` input), which is the else branch below. + IMAGE_DIGEST: ${{ github.event_name != 'pull_request' && steps.build.outputs.digest || '' }} BUILD_ROW: ${{ matrix.image }}-${{ github.run_id }}-${{ github.run_attempt }} run: | set -euo pipefail @@ -484,7 +533,12 @@ jobs: # the label the build step stamped on it -- unique to this # matrix row and this run attempt, which matters because the # executor's docker daemon is shared. - image_id=$(docker image ls --no-trunc \ + # `--all` is required, not cosmetic: the docker exporter drops + # the tag, so this image is dangling, and `docker image ls` + # applies the label filter only to tagged images. Without it + # the filter silently matched nothing and the step failed on a + # build that had loaded correctly (#3316). + image_id=$(docker image ls --all --no-trunc \ --filter "label=apiary.ci.build-row=$BUILD_ROW" \ --format '{{.ID}}' | head -1) [ -n "$image_id" ] || { diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 6eca616b..047de130 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -450,7 +450,7 @@ jobs: - name: Node major from the image (#3331) id: node-runtime run: ./scripts/node-runtime-major.sh arcane/home/honeypot-dashboard/frontend-next/Dockerfile - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ steps.node-runtime.outputs.node-version }} cache: npm @@ -645,7 +645,7 @@ jobs: - name: Node major from the image (#3331) id: node-runtime run: ./scripts/node-runtime-major.sh arcane/home/honeypot-dashboard/frontend-next/Dockerfile - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ steps.node-runtime.outputs.node-version }} cache: npm @@ -1850,6 +1850,22 @@ jobs: echo "::error::unquoted name: containing ' #' -- quote it, YAML reads the rest as a comment" exit 1 fi + # #3313: every SHA-pinned third-party `uses:` must carry a full + # `# vX.Y.Z` comment. zizmor's unpinned-uses audit reads the SHA + # and ignores the comment entirely, so a pin can be perfectly + # well-formed and still say the wrong thing: two pins here were + # labelled `# v7` where the SHA is v7.0.0, and one was labelled + # `# v6` for a v4.6.2 SHA. A reviewer reading the comment is + # misled about what a job actually runs, which defeats the point + # of recording it. Offline and deterministic -- no network, so it + # cannot flake. Dependabot rewrites both halves of a pin on a + # bump, so this stays satisfied as versions move. + if grep -nEo 'uses: +[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+@[0-9a-f]{40}( *# *[^ ]+)?' \ + .github/workflows/*.yml \ + | grep -vE '@[0-9a-f]{40} *# *v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::action pin without a full '# vX.Y.Z' comment -- SHA is pinned but its version is not stated, or is stated as a bare major" + exit 1 + fi # #3314: zizmor security audit of the workflows. Fails on every # medium+ finding EXCEPT the two rules left in ADVISORY below. diff --git a/.github/workflows/weekly-schemathesis.yml b/.github/workflows/weekly-schemathesis.yml index debfa23b..8cf0b297 100644 --- a/.github/workflows/weekly-schemathesis.yml +++ b/.github/workflows/weekly-schemathesis.yml @@ -273,7 +273,14 @@ jobs: # quotes. upload-artifact resolves paths against the workspace # root, not this job's working-directory. if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6 + # #3313: this pin's version comment read `# v6` while the SHA is the + # v4.6.2 commit (it is also the `v4` tag; `v6` is a different commit, + # b7c566a). Verified with `git ls-remote`, not the release page. The + # SHA is what every other upload-artifact pin in this tree uses and + # what this job was last tested against, so the comment is corrected to + # match the code rather than the code bumped to match the comment -- + # changing the major would be a build-logic change, not a pin fix. + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: schemathesis-reports path: | diff --git a/scripts/merge-train.sh b/scripts/merge-train.sh new file mode 100755 index 00000000..f1bf7d57 --- /dev/null +++ b/scripts/merge-train.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# scripts/merge-train.sh — batch-validate N PRs as ONE merged result, locally. +# +# WHY: waiting for each PR's CI after each sibling merge costs O(N^2) CI runs, and +# this repo's self-hosted fleet (7 CI runners) saturates on two concurrent runs — +# the second sits `queued` with no signal. This merges every train member into a +# throwaway worktree cut from the base tip, runs the local gate suite ONCE on the +# final result, and prints the evidence that authorizes `gh pr merge` per member. +# +# READ-ONLY FROM origin. It never pushes, never merges a PR, never touches another +# worktree, and never uses `git stash`. Merging stays a separate, explicit step. +# +# Usage: +# scripts/merge-train.sh [--plan] [...] +# --plan print the planned work and exit 0; no worktree, no network +# +# Exit codes: +# 0 suite green, or red-but-only-inherited (see INHERITED below); evidence printed +# 1 usage error +# 2 suite red on the merged result (real regression or conflict) +# A PR that conflicts with the accumulated result is EJECTED and reported; the +# train continues with the rest, matching upstream behaviour. +# +# Adapted from diegosouzapw/OmniRoute scripts/release/merge-train.sh (v3.8.49 +# merge queue fallback, docs/ops/MERGE_TRAIN.md). Upstream's --fast reduced-coverage +# mode is deliberately NOT ported: the doc gates are the point of the work this +# repo is merging, so parity means the full local suite. +set -uo pipefail + +REPO_ROOT=$(git rev-parse --show-toplevel) +PLAN=0 +while [ $# -gt 0 ]; do + case "$1" in + --plan) PLAN=1; shift ;; + --*) echo "error: unknown flag '$1'" >&2; exit 1 ;; + *) break ;; + esac +done +[ $# -ge 2 ] || { echo "usage: $0 [--plan] [...]" >&2; exit 1; } + +BASE=$1; shift +PRS=("$@") +for N in "${PRS[@]}"; do + case "$N" in + ''|*[!0-9]*) echo "error: PR number '$N' is not numeric" >&2; exit 1 ;; + esac +done + +echo "== merge-train plan ==" +echo "base : $BASE" +echo "prs : ${PRS[*]}" +if [ "$PLAN" = 1 ]; then + echo "(--plan: no worktree created, no network calls)" + exit 0 +fi + +command -v gh >/dev/null || { echo "error: gh not found" >&2; exit 1; } + +# Resolve each PR's head branch. A closed/unknown PR is a usage error, not a skip: +# silently dropping a member would validate a batch the caller did not ask for. +BRANCHES=() +for N in "${PRS[@]}"; do + info=$(gh pr view "$N" --repo Xore/APIARY --json state,headRefName,headRefOid,mergeable \ + --jq '"\(.state)|\(.headRefName)|\(.headRefOid)|\(.mergeable)"' 2>/dev/null) || { + echo "error: cannot read PR #$N" >&2; exit 1; } + IFS='|' read -r state branch oid mergeable <<<"$info" + case "$state" in + OPEN) ;; + *) echo "error: PR #$N is $state, not OPEN — refusing to train it" >&2; exit 1 ;; + esac + echo " #$N $branch ${oid:0:8} ($mergeable)" + BRANCHES+=("$branch") +done + +git -C "$REPO_ROOT" fetch -q origin 2>/dev/null || true +BASE_SHA=$(git -C "$REPO_ROOT" rev-parse "origin/$BASE" 2>/dev/null) || { + echo "error: no origin/$BASE" >&2; exit 1; } + +WT=$(mktemp -d "${TMPDIR:-/tmp}/merge-train-XXXXXX") +# shellcheck disable=SC2064 +trap "rm -rf '$WT'" EXIT +echo +echo "== throwaway worktree: $WT ==" + +git -C "$REPO_ROOT" worktree add -q --detach "$WT" "$BASE_SHA" || { + echo "error: worktree add failed" >&2; exit 1; } + +EJECTED=() +for i in "${!BRANCHES[@]}"; do + b=${BRANCHES[$i]}; n=${PRS[$i]} + if git -C "$WT" merge --no-edit -q "origin/$b" >"$WT/.merge.log" 2>&1; then + echo " merged #$n $b" + else + # Eject, do not abort the train: the caller decides whether to fix or drop it. + echo " EJECTED #$n $b (conflict)" + sed -n '1,20p' "$WT/.merge.log" | sed 's/^/ /' + EJECTED+=("$n") + git -C "$WT" merge --abort 2>/dev/null || git -C "$WT" reset -q --hard "$BASE_SHA" + fi + rm -f "$WT/.merge.log" +done + +# Anchor gate: the train is only meaningful if the base itself passes. A base that +# is already red is reported INHERITED and does not by itself condemn the batch. +echo +echo "== local gate suite on the merged result ==" +declare -a GATES=( + "scripts/check-doc-paths-exist.py" + "scripts/check-docs-reachable.py" + "scripts/check-doc-stale-paths.py" + "scripts/check-public-leaks.py" +) +FAILED=() +for g in "${GATES[@]}"; do + if [ ! -f "$WT/$g" ]; then + # A named gate that is absent is a FAILURE, not a skip: silently skipping a + # renamed gate is how a train goes green without ever having been checked. + echo " FAIL $g (named gate missing at this base — parity with CI is broken)" + FAILED+=("$g") + continue + fi + if out=$(cd "$WT" && python3 "$g" 2>&1); then + echo " PASS $g" + else + echo " FAIL $g" + echo "$out" | tail -15 | sed 's/^/ /' + FAILED+=("$g") + fi +done + +# The link + Mermaid gates are pytest rows in quality.yml, not scripts — parity +# means running the same suite CI runs, not a hand-picked subset of it. +if [ -d "$WT/tests/docs" ]; then + if out=$(cd "$WT" && python3 -m pytest tests/docs/ -q 2>&1); then + echo " PASS tests/docs/ (link, Mermaid and regression gates)" + else + echo " FAIL tests/docs/" + echo "$out" | tail -25 | sed 's/^/ /' + FAILED+=("tests/docs/") + fi +else + echo " FAIL tests/docs/ (missing at this base — parity with CI is broken)" + FAILED+=("tests/docs/") +fi + +echo +if [ ${#FAILED[@]} -eq 0 ]; then + echo "TRAIN_GREEN" + [ ${#EJECTED[@]} -eq 0 ] || echo "EJECTED: ${EJECTED[*]} — not validated, do not merge" + echo "Next: re-check 'state,headRefOid' per PR, then merge one at a time." + exit 0 +fi + +echo "TRAIN_RED" +[ ${#EJECTED[@]} -eq 0 ] || echo "EJECTED: ${EJECTED[*]}" +echo "Failing gates: ${FAILED[*]}" +echo "A red is information. Bisect by halves; do not blanket-rerun CI." +exit 2 diff --git a/scripts/tests/test_3316_image_boot_smoke.py b/scripts/tests/test_3316_image_boot_smoke.py index 38f90c87..24e3b68c 100644 --- a/scripts/tests/test_3316_image_boot_smoke.py +++ b/scripts/tests/test_3316_image_boot_smoke.py @@ -773,7 +773,28 @@ def test_every_other_row_still_builds_exactly_as_before(self) -> None: self.assertIsNotNone(load) self.assertIn("matrix.boot_smoke", load.group(1)) labels = re.search(r"^\s+labels: (.+)$", self.containers, re.M) - self.assertIn("matrix.boot_smoke", labels.group(1)) + self.assertIsNotNone(labels, "the build step lost its labels: input") + # The build-row label is appended by a shell step (#3316), because an + # expression cannot produce the real newline that separates labels -- + # `format('\n...')` emits a literal backslash-n that gets glued onto + # the previous label's value. So the gating moved from the `labels:` + # expression to that step's `if:`. The invariant is unchanged: only a + # boot-smoke row gets the label, and every row still gets the base + # labels the build-push-action reads. + labels_value = labels.group(1) + self.assertIn("steps.metadata.outputs.labels", labels_value) + self.assertRegex( + labels_value, r"\$\{\{\s*steps\.buildrow\.outputs\.labels\s*\|\|", + ) + buildrow = re.search( + r"- name: Append the boot-smoke build-row label\s*\n" + r"\s*id: buildrow\s*\n" + r"\s*if: (.+)$", + self.containers, + re.M, + ) + self.assertIsNotNone(buildrow, "the label-append step is gone") + self.assertIn("matrix.boot_smoke", buildrow.group(1)) def test_every_smoked_row_has_a_step_and_no_other_row_does(self) -> None: steps = set(re.findall(r"- name: \"?Boot-smoke (\S+)", self.containers))