Skip to content

🌱 ci: stop persisting checkout credentials in PR-tree workflows #28696

🌱 ci: stop persisting checkout credentials in PR-tree workflows

🌱 ci: stop persisting checkout credentials in PR-tree workflows #28696

Workflow file for this run

name: v2 CI
on:
workflow_call:
inputs:
integration_candidate:
description: Run an installed immutable Integration-aware candidate with isolated read-only technical jobs.
type: boolean
default: false
merge_group:
types: [checks_requested]
push:
branches: [v4, v5, v6]
# bin/** is in scope because the gh wrapper is a SECURITY boundary (it is the
# merge gate behind the proxy hard-deny) and previously matched no path
# filter at all β€” a change to it triggered no CI whatsoever.
#
# README.md and docs/** are in scope for the same reason (#4477): the
# default Docker Compose quick start lives in README.md and docs/, and the
# gate that asserts it works is below. Without these entries that gate would
# not run on the one change that can break it β€” which is how the quick start
# shipped broken twice over. src/docs/** already triggers this workflow via
# src/**, so this is the same rule applied to the docs outside src/.
# Protected-branch monitor/gate workflow changes must run required CI so branch protection can merge the PR.
# release PRs touch only these; the required contexts must report β€” #10795
paths: ['CHANGELOG.md', 'changelog.d/**', 'src/**', 'bin/**', 'config/**', 'README.md', 'docs/**', '.github/scripts/**', '.github/workflows/**', '.github/integration-candidate.template.yml']
pull_request:
branches: [v4, v5, v6]
# Required contexts from this workflow must appear on every PR head. Keep
# the trigger unfiltered and let required-context-scope below skip the
# expensive build for out-of-scope diffs.
schedule:
# Nightly full run: harmless today, and preserves a safety net if the
# project later decides to skip duplicate post-merge push runs.
- cron: '17 6 * * *'
permissions:
contents: read
# Cancel superseded PR runs only (#4623). A new push to a PR makes the
# in-flight run's verdict irrelevant, so cancelling it is pure runner-minute
# savings. Push-to-v4 (post-merge) runs must NEVER be cancelable: they feed
# the docker publish gate and the coverage/monitoring surface β€” cancelling
# one on a merge burst would silently skip a merged commit. Keying the
# non-PR group on run_id gives every such run its own group, so they can
# neither cancel each other nor queue behind one another.
concurrency:
group: ${{ inputs.integration_candidate && 'integration-ci' || github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ !inputs.integration_candidate && github.event_name == 'pull_request' }}
# A slow cache-segment download must never cost more than a recompile
# (see v2-tests.yml β€” observed 16-minute restore steps on some nodes).
env:
ACTIONS_CACHE_SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
# Build-cache isolation for the self-hosted pool (see
# .github/scripts/go-cache-guard.sh). `job` gives each job its own GOCACHE
# under RUNNER_TEMP instead of the cephfs volume every runner pod shares;
# GOMODCACHE stays shared. Set the repo variable HIVE_GO_BUILD_CACHE=shared
# to go back to the shared build cache without a code change.
GO_CACHE_GUARD_BUILD_CACHE: ${{ vars.HIVE_GO_BUILD_CACHE || 'job' }}
jobs:
required-context-scope:
name: required context scope
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 10
outputs:
run-ci: ${{ steps.scope.outputs.run-ci }}
steps:
- name: Decide whether build-and-test needs the real CI lane
id: scope
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "${{ inputs.integration_candidate }}" = true ] &&
{ [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [[ "$GITHUB_REF" != refs/tags/hive-integration/* ]]; }; then
echo "::error::Integration candidates require an explicitly dispatched immutable candidate tag"
exit 1
fi
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "run-ci=true" >> "$GITHUB_OUTPUT"
exit 0
fi
: > changed-files.txt
page=1
api_root="${GITHUB_API_URL:-https://api.github.com}"
while :; do
curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" -H "Accept: application/vnd.github+json" "${api_root}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${page}" -o changed-files-page.json
python3 - <<'PYFILES' >> changed-files.txt
import json
with open('changed-files-page.json', encoding='utf-8') as f:
for item in json.load(f):
print(item['filename'])
PYFILES
count=$(python3 - <<'PYCOUNT'
import json
with open('changed-files-page.json', encoding='utf-8') as f:
print(len(json.load(f)))
PYCOUNT
)
[ "$count" -lt 100 ] && break
page=$((page + 1))
done
rm -f changed-files-page.json
run=false
while IFS= read -r path; do
case "$path" in
CHANGELOG.md|changelog.d/*|src/*|bin/*|config/*|README.md|docs/*|.github/scripts/*|.github/workflows/*|.github/integration-candidate.template.yml)
run=true
;;
esac
done < changed-files.txt
echo "run-ci=${run}" >> "$GITHUB_OUTPUT"
if [ "$run" = true ]; then
echo "v2 build-and-test will run."
else
echo "No v2 CI paths changed; build-and-test will be reported as skipped/success."
fi
build-go:
needs: required-context-scope
# release-gate/* is created only by tagged-release.yml and carries only CHANGELOG.md/changelog.d changes; report the required context immediately (#10863).
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-ci == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
# Required on v5: keep on the self-hosted pool so a GitHub-hosted runner
# outage does not make otherwise-mergeable PRs impossible to merge.
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 50
defaults:
run:
working-directory: src
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
- name: Ensure PyYAML on self-hosted runners
working-directory: .
run: |
python3 -c "import yaml" 2>/dev/null && exit 0
# A stale or unreachable third-party apt index (e.g. a PPA 503) must not block the install.
sudo apt-get update -qq || true
sudo apt-get install -y -qq python3-yaml && exit 0
# Noble (Python 3.12) refuses --user installs under PEP 668 without --break-system-packages.
python3 -m pip install --user -q --break-system-packages pyyaml \
|| { curl -sS https://bootstrap.pypa.io/get-pip.py | python3 - --user -q --break-system-packages && python3 -m pip install --user -q --break-system-packages pyyaml; }
- name: Check readonly candidate CI generation
working-directory: .
run: |
python3 .github/scripts/generate-integration-ci.py --check
python3 .github/scripts/test-generate-integration-ci.py
# Populate the shared node-local Go tool cache under the runner pods'
# node-wide lock before setup-go, so parallel jobs on a cold cache (a
# src/go.mod bump) stop clobbering each other's rm -rf + copy (#11175,
# #10234). Never fails the job.
- name: Warm shared Go tool cache (serialize the cold-cache population)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-toolcache-guard.sh"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
cache: false
go-version-file: src/go.mod
- name: Guard shared Go cache (fall back when the runner volume is full)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-cache-guard.sh"
# Fill GOMODCACHE up front with bounded retries so a transient
# proxy.golang.org stream reset (INTERNAL_ERROR, #14976) cannot fail the
# job's first module-fetching step; see .github/actions/go-mod-download-retry.
- name: Download Go modules (retry transient proxy resets)
uses: ./.github/actions/go-mod-download-retry
# Warm compile cache (#7480 follow-up): the warm-build-cache job on every
# v4 push compiles the whole tree (race + norace) and saves GOCACHE keyed
# by SHA; PR jobs restore the nearest ancestor via the prefix restore-key,
# turning the ~2-minute per-job -race recompile into an incremental one.
# Restore-only in PR jobs: balance/miss costs seconds, never correctness.
- name: Restore warm Go build cache
if: ${{ !contains(vars.HIVE_RUNNER_LABELS, 'hive-runners') }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/go-build
key: hive-go-build-${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}
restore-keys: |
hive-go-build-${{ runner.os }}-${{ runner.arch }}-
- name: Build
run: go build ./cmd/hive/
# The crux binary builds twice (#14138): the root-module host
# (./cmd/crux, Hive's hive.yaml loader) and the nested module's own
# ./crux/cmd/crux, which must build with GOWORK=off so a checkout of
# src/crux alone (or a downstream repo consuming the module) can
# `go build ./cmd/crux` without the workspace or the root module.
- name: Build crux (root host and standalone module)
run: |
go build -o /dev/null ./cmd/crux
(cd crux && GOWORK=off go build -o /dev/null ./cmd/crux)
# Real embedded JetStream only: no live broker, cluster or deployment.
# Keep the standalone module boundary honest as well as the Hive adapter.
# Normal v2-tests shards also collect coverage for these packages.
- name: Durable Crux lane and Hive participant integration
run: |
(cd crux && GOWORK=off go test -race -timeout 3m ./lanes ./api ./serve)
GOWORK=off go test -race -timeout 3m ./pkg/laneparticipant
# crux/scripts/smoke.sh (`make -C crux smoke` locally; the self-hosted
# runners have no make) starts `crux serve` on a private unix socket
# with an empty runtime, drives it with `crux --server … ps` and GET
# /v1/health, then stops it. No agents or tmux involved; it fails the
# PR if the server stops starting, listening or shutting down cleanly.
- name: Crux module smoke (crux serve over a unix socket)
working-directory: src/crux
run: GOWORK=off bash scripts/smoke.sh
- name: CI install tool apt cache tests
working-directory: .
run: bash .github/scripts/test-ci-install-tool-apt-cache.sh
- name: Go cache guard tests
working-directory: .
run: bash .github/scripts/test-go-cache-guard.sh
- name: Go tool cache guard tests
working-directory: .
run: bash .github/scripts/test-go-toolcache-guard.sh
- name: Runner cache prune tests
working-directory: .
run: python3 .github/scripts/test-runner-cache-prune.py
- name: Patch coverage script tests
working-directory: .
run: bash .github/scripts/test-patch-coverage.sh
# Runner-infra classifier, rerun-once guard and rate alert (#9664):
# hermetic, real 2026-09-29 log excerpts as fixtures, about a second.
- name: CI infra classifier tests
working-directory: .
run: python3 .github/scripts/test-ci-infra.py
- name: Vet
run: go vet ./... ./crux/...
# The model list-price table behind /api/cost is refreshed weekly by
# price-table-refresh.yml (which also re-dates it after 30 quiet days).
# If that stops happening the Cost panel silently drifts from reality,
# so a table older than 60 days fails here: merge the open
# bot/price-table-refresh PR, or run `go run ./internal/tools/pricetable`.
- name: Price table staleness guard
run: go run ./internal/tools/pricetable --check-age 60
contract-tests:
needs: required-context-scope
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-ci == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
working-directory: src
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
- name: Ensure PyYAML on self-hosted runners
run: |
python3 -c "import yaml" 2>/dev/null && exit 0
sudo apt-get update -qq || true
sudo apt-get install -y -qq python3-yaml && exit 0
python3 -m pip install --user -q --break-system-packages pyyaml \
|| { curl -sS https://bootstrap.pypa.io/get-pip.py | python3 - --user -q --break-system-packages && python3 -m pip install --user -q --break-system-packages pyyaml; }
# The entrypoint decides which file a hive boots its config from. The
# hive.yaml.bak -> hive.yaml.runtime migration must keep reading the
# legacy name, or hives that have not saved since upgrading lose their
# boot config silently. This executes the real resolver, so a regression
# fails the PR rather than a deploy.
- name: Entrypoint runtime-config migration tests
run: bash deploy/test_entrypoint_runtime_config.sh
# #3760: the hive binary ships with NO file capability (a file cap EPERMs
# on exec wherever the bounding set lacks NET_ADMIN); the entrypoint grants
# NET_ADMIN as a bounding-set-gated AMBIENT cap instead. This unit-tests the
# CapBnd bit-check (bit 12 / 0x1000) with positive+negative controls and
# asserts the entrypoint keeps the ambient-cap grant + gosu fallback.
- name: Entrypoint NET_ADMIN ambient-cap tests (#3760)
run: bash deploy/test_entrypoint_netadmin_ambient.sh
# #4368: the entrypoint warns when the overlay's github.key_file names a
# PEM that is not on disk β€” and used to look only at /data/, while the
# provisioning template seeded /secrets/gh-app-key.pem for every App-using
# hive and created that Secret entry only for hives provisioned WITH an
# inline key. Four hives shipped naming a file that would never exist,
# under the one prefix the check skipped, so the detector written for
# exactly that symptom stayed silent and the fault first surfaced as
# github_auth: fail. EXECUTES the real merge block extracted from the
# shipped entrypoint, so a regression fails the PR rather than a hive.
- name: Entrypoint dangling key_file tests (#4368)
env:
# #5388: ubuntu-latest GUARANTEES python3 with PyYAML (actions/runner-images
# ships both preinstalled), so this suite's "python3/PyYAML unavailable"
# skip cannot be true here. If it fires, the precondition moved and the
# suite stopped asserting β€” that must be red, not a silent exit 0.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash deploy/test_entrypoint_dangling_keyfile.sh
# #6489: `Open a terminal` 503ed forever on a standalone, non-hub-provisioned
# docker-compose spoke β€” terminalassert.SigningKey() (hub.TerminalSigningKey)
# had no lane left once HIVE_TERMINAL_KEY and HIVE_HUB_SECRET+HIVE_ID were
# both absent (audit N3 deliberately removed the fleet-uniform fallbacks
# that used to sit there). hive_ensure_terminal_signing_key now auto-
# provisions a persisted per-instance key and exports it as
# HIVE_TERMINAL_KEY before either the Go binary or the Node proxy starts, so
# both processes agree on it from their first request. EXECUTES the real
# function extracted from the shipped entrypoint, so a regression fails the
# PR rather than a standalone hive.
- name: Entrypoint standalone terminal-key fallback tests (#6489)
run: bash deploy/test_entrypoint_terminal_key_fallback.sh
# #5369: the `chown -R dev:node /data` in the entrypoint's root phase is
# guarded on `[ "$DATA_OWNER" != "1001" ]`, and the Dockerfile already
# does that chown at BUILD time β€” so the guard is false on every normal
# boot and the chown never runs. Anything the root phase creates under
# /data afterwards stays root:root and is unreadable to the uid the hive
# process drops to. #5360 was one instance; this asserts the class.
#
# The guard must SURVIVE (it prevents a multi-minute recursive chown over
# an NFS PVC), so this pins the targeted sweep that replaces it, checks
# the pre-drop readability assertion, and mechanically enforces the
# maintenance rule: it extracts the root phase and fails if any mkdir
# under /data is neither swept nor chowned at its site, so the path list
# cannot go stale the first time someone adds a write.
- name: Entrypoint /data ownership invariant (#5369)
run: bash deploy/test_entrypoint_data_ownership.sh
# #5525: when /data is a PVC mounted over the image's prepared directory,
# recursive UID isolation must run in the background and gate agent launch
# on per-UID/revision markers instead of blocking startup on chown.
- name: Entrypoint UID-isolation migration (#5525)
run: bash deploy/test_entrypoint_uid_isolation.sh
# #9592: agent UIDs are stable across restarts and roster changes. The
# persisted /data map wins, first boot adopts existing PVC owners, and
# the guard repairs mismatched per-agent state before launch.
- name: Entrypoint stable agent UID allocation (#9592)
run: bash deploy/test_entrypoint_uid_stable.sh
# #13166: replicas: copies exist only in memory, so the entrypoint must
# derive <agent>-2 .. <agent>-N from the config to give each one a user,
# private token group and readable token cache.
- name: Entrypoint replica agent names (#13166)
run: bash deploy/test_entrypoint_replica_names.sh
# #6287: the token-access audit log (GET /api/token-access) was
# dev:node 0664 while every agent UID is in group node, so the audited
# agents could truncate or forge their own trail. Fixed on v5 by #6303
# and backported here by #7523. Pins the invariant on the shipped
# entrypoint and wrappers: the log is dev-owned 0600, the wrappers write
# only to the drop-box spool, and no chmod loosens it.
- name: Entrypoint token-access audit log not agent-writable (#6287)
run: bash deploy/test_entrypoint_token_access_audit.sh
# #5370: the arm64 lane probed the PUBLISHED image, so on a PR it
# validated code already on v4 rather than the change proposed β€” a PR
# fixing a startup bug stayed red, one introducing a startup bug went
# green. #5342 merged green that way and #5368, its fix, was red. The
# lane now builds the PR's own image and probes that. This pins the
# wiring: the build exists, it does not swallow failures, the probe is
# told the image is local, and the push path's missing-manifest failure
# (#4336) is untouched.
- name: arm64 lane probes PR code, not the published image (#5370)
run: bash deploy/test_arm64_lane_probes_pr_code.sh
# PR Verifier treated the VERIFICATION as advisory but the GHCR login and
# image pull as fatal, so a registry-side change failed the check on every
# open PR at once, before any PR content was examined (#6085: 11
# consecutive runs red on `manifest unknown` after the org move). This
# pins the relative invariant -- registry access is never treated more
# harshly than the verification it enables -- and it is a static check
# because pr-verifier.yml runs on pull_request_target, so a PR that fixes
# it executes the BASE branch's copy and cannot show itself passing.
- name: PR Verifier reports on the PR, not on registry reachability (#6085)
run: bash deploy/test_pr_verifier_advisory.sh
# Supply-chain pins regress SILENTLY. The PI_VERSION pin (#3443) landed on
# v2, and a sync/v2-into-v4-* merge resolved the conflict in favour of the
# older side and restored `ARG PI_VERSION=latest` β€” the fix commit is an
# ancestor of v4, yet the pin was gone. Audit findings F14 and F18 both
# died this way. This asserts the INVARIANT (nothing floats) rather than
# any behaviour, so a merge that drops a pin fails the PR instead of
# quietly shipping a build that trusts whatever npm/Docker Hub serves.
- name: Supply-chain pin guard
run: bash deploy/test_supply_chain_pins.sh
# #4804: HEAD-only tagging made every completed build in a merge burst
# exit green without publishing anything. Exercise the real publisher
# against a fake registry and pin both halves of the replacement policy:
# immutable SHA tags are unconditional, while moving tags are monotonic.
- name: Docker release-tag publication policy (#4804)
run: bash scripts/test-publish-image-tags.sh
- name: Stable soak promotion policy (#5974)
run: bash scripts/test-promote-stable.sh
# #6588: PRs that resolve somebody else's issue credit the filer with a
# Co-authored-by trailer, and scripts/issue-coauthor.sh builds it. A
# wrong trailer is worse than none β€” GitHub silently ignores an address
# it cannot resolve, so the credit looks recorded while nothing was
# credited. Drives the script against a stubbed API (no network, no
# token) over every shape that matters, and pins the invariant that
# co-authoring is attribution and never sign-off: a co-authored commit
# must still pass DCO, and a commit carrying ONLY a co-author trailer
# must still fail it.
- name: Issue co-author trailer helper (#6588)
run: bash scripts/test-issue-coauthor.sh
# #9746: an open issue closes when its reporter or a maintainer confirms
# the fix in a comment (/fixed, or plain language on a likely-done
# issue). Drives the grammar, negation guard and authorisation against a
# stubbed API so a false-positive close cannot land unnoticed.
- name: Issue confirm-fixed command (#9746)
run: bash scripts/test-issue-confirm-fixed.sh
# #5142: v4.0.1 failed to publish twice in a row inside tagged-release.yml's
# final push β€” once on GH006 propagation lag (branch protection had not
# yet ingested the just-earned gate check), once on a non-fast-forward
# when a second merge landed mid-run. The push step now retries the
# first and defers green on the second (the successor run releases the
# newer merge; retagging here would publish images built from a
# different tree). Extracts the push step from tagged-release.yml and drives
# every branch of that state machine with a stubbed git.
- name: Release push retry/deferral state machine (#5142)
run: bash scripts/test-release-push-retry.sh
# #6419: docker.yml's `gate` job calls this helper to refuse a dispatched
# `release_sha` that is well-formed hex but not actually reachable from
# the branch being built β€” otherwise any `actions: write` principal could
# publish an unreviewed commit under a release line's moving tags. Drive
# the helper against real fixture git histories rather than trusting the
# workflow's inline call to be correct.
- name: release_sha ancestry guard (#6419)
run: bash scripts/test-check-release-sha-ancestry.sh
# #5675: changelog entries land as changelog.d/ fragments and
# tagged-release.yml folds them into CHANGELOG.md's Unreleased section
# via scripts/compile-changelog.sh before deriving the release. A
# compiler bug here silently drops or miscategorizes entries β€” which
# changes the derived semver bump β€” so this drives the real script over
# fixture fragments and asserts the exact compiled output, the
# empty/absent-dir byte-identical no-op the release loop's idempotency
# rides on, loud all-or-nothing failure on a bad category prefix, and
# the end-to-end property that a compiled added- fragment flips
# derive-release-version.sh from no-release to a minor release.
- name: Changelog fragment compiler properties (#5675)
run: bash scripts/test-compile-changelog.sh
# #5934: a hand-rescued v4.8.0 tag had no GitHub Release, and v4.9.0's
# dated changelog heading was empty while generated release notes still
# listed shipped PRs. The tagged release workflow now uses the tagged
# CHANGELOG.md section as its canonical Release body and falls back to
# generated notes only when that section is unexpectedly empty/missing,
# so tags do not become ledger entries with unusable bodies.
- name: Release notes extraction fallback (#5934)
run: bash scripts/test-extract-release-notes.sh
- name: Release notes size cap under GitHub's body limit
run: bash scripts/test-release-notes-cap.sh
# #4206: the standalone stack's image references live in exactly one
# place, src/deploy/standalone-images.sh, so the Docker Compose assets
# and the Podman assets that land later cannot drift onto different
# images. Also blocks the retired v2-latest tag, which resolves to a
# different digest than the v4 channels, from coming back into any
# deployment asset. Pure string analysis: no registry is contacted.
- name: Standalone image-reference source of truth (#4206)
run: bash deploy/test_standalone_image_refs.sh
# #3865: watchtower bind-mounted /var/run/docker.sock β€” the full daemon
# API, including /exec, which reads the GitHub App private key out of the
# hive container with no escalation at all. The socket now sits behind a
# deny-by-default docker-socket-proxy on an internal-only network. Every
# property of that containment is one plausible edit from reverting (a
# bind mount added "to debug", a published 2375, EXEC=1 to unblock some
# other tool, the proxy attached to the network the hive runs on) and none
# of those break anything at runtime β€” so they need a gate, not a review.
- name: Docker-socket containment guard (#3865)
env:
# #5388: ubuntu-latest GUARANTEES python3 with PyYAML (actions/runner-images
# ships both preinstalled), so this suite's "python3/PyYAML unavailable"
# skip cannot be true here. If it fires, the precondition moved and the
# suite stopped asserting β€” that must be red, not a silent exit 0.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash deploy/test_watchtower_socket_contract.sh
# #4204: the socket guard above covers the auto-update profile, and the
# pin guard covers build inputs β€” but nothing asserted anything about the
# two services an operator actually runs. Each invariant here is
# invisible at runtime when it breaks: a stack that publishes 7681 still
# serves the dashboard, it just also serves an unauthenticated root shell
# into the credential-holding container; /data on a bind mount still runs
# and just loses agent state on the next recreate. This is also the
# snapshot the Podman runtime work (#4188) has to keep passing.
- name: Standalone service deployment contract (#4204)
env:
# #5388: ubuntu-latest GUARANTEES python3 with PyYAML (actions/runner-images
# ships both preinstalled), so this suite's "python3/PyYAML unavailable"
# skip cannot be true here. If it fires, the precondition moved and the
# suite stopped asserting β€” that must be red, not a silent exit 0.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash deploy/test_standalone_service_contract.sh
# #4375: the guard above parses docker-compose.yaml and therefore cannot
# see a Quadlet unit at all. This is the same published-port boundary for
# src/deploy/quadlet/ β€” the gateway publishes 3001 and only 3001, nothing
# publishes 7681 on either side, hive and the gateway share one network,
# and the network leaves DisableDNS/Internal/IPv6 alone (an IPv6-enabled
# network is a measured egress bypass, since the forced-proxy gate has no
# ip6tables rules). The generator gate in quadlet-gate.yml checks that
# these units PARSE; this checks what they MEAN, which the generator
# exits 0 on. Pure text analysis: podman is not required.
- name: Quadlet published-port boundary (#4375)
run: bash deploy/test_quadlet_port_boundary.sh
# #4367: the Quadlet unit's HealthCmd= probes a port that lives in the
# OPERATOR's hive.yaml, and nothing at install time notices when the two
# disagree β€” the config parses, the generator exits 0, the container
# starts and is genuinely healthy on the other port, and Notify=healthy
# correctly holds the unit in `activating` until TimeoutStartSec=300
# expires and --rm deletes the evidence. Five minutes and no artifact for
# one wrong digit. Same shape as the guards above: invisible when it
# breaks, and no dry-run can catch it. Also asserts the install steps name
# files the repo actually TRACKS, which is the bug itself: the doc said
# `cp src/hive.yaml`, a gitignored path that exists only in a checkout
# where someone already followed the README.
- name: Quadlet config-coupling contract (#4367)
run: bash deploy/test_quadlet_config_contract.sh
# #4477: every guard above asserts what the deployment DESCRIPTIONS say.
# None of them read the instructions an operator is actually given, and
# the default quick start was broken twice over on its own terms: it wrote
# `.env` at the repo root, which `-f src/docker-compose.yaml` never reads
# (that command makes src/ the project directory), and it never set
# HIVE_DASHBOARD_TOKEN, which the dashboard's auth proxy refuses to start
# without. Both faults are silent β€” the PAT lands in a gitignored file
# nothing reads, and :3001 serves 503 from an upstream that never bound.
# This reads the required set out of deploy/quadlet/hive.env.example
# rather than restating it, and DISCOVERS the documents carrying a recipe
# rather than listing them, so a fourth copy of the quick start is gated
# the day it is written.
- name: Documented Compose quick start contract (#4477)
env:
# #5388: actions/checkout GUARANTEES a real git checkout, so this
# suite's "not a git checkout" skip β€” which would silently retire the
# assertion that src/.env is gitignored, on a path the quick start
# tells operators to put a PAT in β€” cannot legitimately fire here.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash deploy/test_compose_quickstart_contract.sh
# #4404: every guard above looks at ONE runtime. test_standalone_service_
# contract.sh parses only docker-compose.yaml and says so in its own header
# ("This does NOT claim Docker/Podman parity"); the two Quadlet guards parse
# only the units. A property can hold in both and still DIFFER between them,
# which is what #4188 asked for: "a change to one runtime must fail CI if
# the other runtime's contract was unintentionally left stale". This
# compares the two deployment descriptions directly β€” images, environment,
# mounts and modes, published vs exposed ports, capabilities, health checks,
# persistence, network exposure, restart policy, labels β€” and fails on any
# divergence not in its exception table, where each entry carries a stated
# reason. Entries are swept in reverse too: an exception that no longer
# matches a real divergence is itself a failure, so the table cannot rot.
- name: Docker/Podman contract parity (#4404)
env:
# #5388: ubuntu-latest GUARANTEES python3 with PyYAML (actions/runner-images
# ships both preinstalled), so this suite's "python3/PyYAML unavailable"
# skip cannot be true here. If it fires, the precondition moved and the
# suite stopped asserting β€” that must be red, not a silent exit 0.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash deploy/test_standalone_runtime_parity.sh
# bin/gh-wrapper.sh is the merge gate standing behind the proxy hard-deny:
# if its argument parsing is wrong, a prompt-injected agent merges
# unvetted code. It had NO tests and matched no CI path filter. This
# EXECUTES the wrapper against a stub gh and asserts on exit codes β€”
# grepping the script text (as the entrypoint tests do) cannot catch a
# parser bug, which is exactly how the audit's N7 bypass survived.
- name: gh-wrapper gate tests
working-directory: .
run: bash bin/test_gh_wrapper_gates.sh
- name: go-wrapper gate tests
working-directory: .
run: bash bin/go-wrapper.test.sh
# bin/enumerate-actionable.sh writes the actionable.json that
# merge-gate.sh, kick-agents.sh and the dashboard's contribute lane
# (src/pkg/dashboard/contribute_ws.go) consume instead of running their
# own gh queries. Every hold/blocked/draft/ADOPTERS exclusion and the
# external-issue SHA hold/unhold lifecycle β€” which WRITES labels and
# comments to GitHub β€” live in that one script. It has been patched by
# fix PRs (#4772 most recently) and had NO tests. This EXECUTES a
# path-rewritten copy against a stub gh (canned REST/GraphQL fixtures,
# real jq for --jq) and asserts on the written JSON and on which GitHub
# calls were made, with positive controls so an enumerator that emits
# nothing cannot pass. Runs in ~2s, no network.
- name: enumerate-actionable contract tests
working-directory: .
run: bash bin/test_enumerate_actionable.sh
# bin/issue-classifier.sh is the pre-kick stage that stamps every
# actionable issue with complexity_tier / model_recommendation /
# is_tracker / lane / cluster_key β€” fields kick-agents.sh inlines into
# every kick and agents are told to trust rather than re-derive. The
# suite (#7810) EXECUTES a path-rewritten copy against fixture documents
# and a harness-owned classification: config; 40 assertions, ~2s.
- name: issue-classifier contract tests (tiers, lanes, clustering)
working-directory: .
run: bash bin/test_issue_classifier.sh
# bin/hive-gh.sh is the compact `gh --json` projection front for the
# common read verbs (#12127). Its one job is to shrink what lands in an
# agent's context WITHOUT hiding a field a merge/authorization gate
# reads (labels, author, head SHA, mergeable state, review decision,
# check conclusions) and WITHOUT closing the explicit full-fetch path.
# This EXECUTES the script against a stub gh that applies the real jq
# filters and asserts on the argv and the reshaped payload.
- name: hive-gh compact projection contract tests
working-directory: .
run: bash bin/test_hive_gh.sh
# bin/kick-governor.sh (923 lines, called from bin/hive.sh, run every 15
# min via kick-governor.timer) is the adaptive kick decision engine that
# reads enumerate-actionable's actionable.json: it measures the backlog,
# ladders every agent's cadence and model through SURGE/BUSY/QUIET/IDLE,
# downgrades models under token-budget pressure, and decides who gets
# kicked (or paused) this tick. It had no tests. This EXECUTES a
# path-rewritten copy against fixture actionable.json/tokens.json state
# and asserts on the state files it writes and which kicks fire, with
# positive controls beside every pause/exclusion so a governor that
# kicks nothing (or ignores every pause) cannot pass.
- name: kick-governor contract tests
working-directory: .
run: bash bin/test_kick_governor.sh
# bin/merge-gate.sh reads actionable.json (enumerate-actionable.sh's
# output) and writes merge-eligible.json β€” the ONE file agents are told
# to trust before running `gh pr merge`. Every CI-check/mergeable/hold-
# label/author gate that decides "safe to merge" lives in this one
# script, called from conflict-sweeper.sh, gh-wrapper.sh and kick-
# agents.sh, and it had NO tests. This EXECUTES the gate against a stub
# gh (canned `pr checks` TSV and `pr view --json` fixtures) and asserts
# on the written JSON's eligible/not_ready split and block_reasons, with
# a positive control beside every exclusion so a gate that admits
# everything cannot pass. Runs in ~1s, no network.
- name: merge-gate contract tests
working-directory: .
run: bash bin/test_merge_gate.sh
# bin/gh-rate-check.sh scans agent tmux panes for GitHub API rate-limit
# text and decides which agents to PAUSE and for how long β€” called from
# kick-agents.sh every cycle. A regression here either stops pausing
# agents during a real rate limit (burns the fleet's shared quota
# faster) or pauses agents on a false positive (the script's own header
# warns that Claude/Copilot CLI usage-limit text looks similar but is a
# DIFFERENT limit). It had NO tests. This EXECUTES the script against
# stub tmux/gh/curl and asserts on the governor pause flags, the
# pullback state file and the ntfy calls it makes, with positive and
# negative controls on the CLI-usage-limit exclusion pattern. Runs in
# ~2s, no network (the ntfy curl call is stubbed).
- name: gh-rate-check contract tests
working-directory: .
run: bash bin/test_gh_rate_check.sh
# #5925: the dibs domain cutover is scheduled around a Let's Encrypt quota
# window β€” a step that fails AFTER issuance costs a week, so the preflight
# that checks the plan's assumptions beforehand is itself load-bearing. A
# check that silently passes when it should fail is worse than no check,
# because it is the thing someone trusts before spending the window. This
# EXECUTES the preflight against a stub kubectl and stub dig and asserts
# its exit codes and report, including that an unreachable cluster never
# reads as a pass. No cluster, no network.
- name: dibs cutover preflight contract (#5925)
working-directory: .
run: bash bin/test_dibs_cutover_preflight.sh
# #5925: the verifier is what someone reads before deciding the cutover
# is finished and the old certificate can be retired. Every failure mode
# it exists to catch β€” a self-signed certificate, a certificate missing
# the new SAN, an inert redirect, a redirect that drops the path and
# query, a hub on a foreign registrable domain leaving dibs signed out β€”
# still answers HTTP 200, so a verifier that passes when one of those is
# true is worse than no verifier at all. This EXECUTES it against stub
# openssl/curl/dig/kubectl and asserts its exit codes and report,
# including that an open dual-host window never reads as finished. No
# cluster, no network.
- name: dibs cutover verification contract (#5925)
working-directory: .
run: bash bin/test_dibs_cutover_verify.sh
# #6430: the anomaly detector fired only on increases (an error event
# more than 2x its 7-day baseline). A domain migration collapses real
# pages/events instead β€” not an error event β€” so nothing in the old
# code path could have flagged it, and nothing would flag a recurrence.
# This EXECUTES bin/ga4_anomaly_lib.py's compute_anomalies() directly β€”
# no google-analytics-data library, no service account key, no network β€”
# and asserts both arms: the pre-existing spike trigger is unchanged,
# and a real page/event falling below the drop floor is now reported.
- name: GA4 anomaly detector drop-arm contract (#6430)
working-directory: .
run: bash bin/test_ga4_anomaly_detector.sh
# #4289: git-credential-hive.sh is the fetch/clone AND push credential
# source for every agent. Its mode block used to exit 1 in ADVISORY/
# ISSUES_ONLY/NO_GITHUB modes, cutting off every git READ for advisory
# agents. This EXECUTES the helper and asserts `get` returns the scoped
# (contents:read-only) credential in those modes while the H3 refusals
# (no per-agent token, non-https) stay fail-closed.
- name: git-credential-hive helper tests (#4289)
working-directory: .
run: bash bin/test_git_credential_hive.sh
# N14 (#3842): kick-agents.sh (the native/systemd-install path β€” no Go
# AgentManager, no per-agent scoped token) used to instruct every kicked
# agent to `cat` the shared, fleet-wide, full-privilege App token cache
# and prefix gh calls with it β€” putting a live credential into
# agent-controlled text, one prompt injection from exfiltration. Source-
# asserts the instruction is gone and agent-launch.sh authenticates gh's
# own credential store instead, so a future edit can't reintroduce it
# quietly.
- name: gh-auth native-install guard (#3842)
working-directory: .
run: bash bin/test_gh_auth_native_no_cat.sh
# #4045: agent CLI backends (observed live: Copilot CLI) re-export their
# own live GitHub credential as GITHUB_TOKEN into every tool shell they
# spawn β€” after all launch-path scrubbing β€” letting a wrapper-denied
# agent write to repos via raw curl, outside every gh-wrapper control.
# This EXECUTES shells the way a backend spawns them (token in the spawn
# env) and asserts the BASH_ENV scrub leaves them token-less, with
# positive controls that the probe can see a leak and that the wrapper
# still authenticates from the per-agent cache.
- name: agent shell token scrub (#4045)
working-directory: .
run: bash bin/test_agent_env_scrub.sh
# #4205: standalone deployment selection is explicit and fail-closed.
# Docker remains the default; selecting Podman must never fall back to
# Docker because the Podman binary is missing or a command fails.
- name: Standalone runtime selection contract (#4205)
working-directory: .
run: bash bin/test_hive_standalone_runtime.sh
# #4207: Podman preflight must diagnose engine/version, the connection it
# is actually talking to, root mode, and cgroup version BEFORE Hive
# invokes a lifecycle implementation β€” and must run only when Podman is
# explicitly selected, so Docker's default path is untouched. The stub
# engine lets the whole matrix (old versions, rootful, cgroup v1, remote,
# missing binary) run on a host with no Podman installed.
- name: Podman preflight contract (#4207)
working-directory: .
run: bash bin/test_hive_podman_preflight.sh
# #4210: rootless Podman/Buildah share one store with the operator's
# unrelated containers, Distroboxes, and builds, so Hive cleanup must
# select only Hive-labelled resources and the broad store-wide prune,
# reset, and remove-all forms are rejected outright. See
# src/docs/podman-ownership-cleanup.md. The test analyses arguments only:
# it contacts no engine and deletes nothing.
- name: Podman ownership and cleanup contract (#4210)
working-directory: .
run: bash bin/test_hive_podman_cleanup.sh
# #4326: #4210 defined the ownership contract but nothing performed a
# teardown through it, and a cleanup path that is defined but not
# implemented is the dangerous state β€” the guard only protects
# operations that go through it. This drives the teardown against a fake
# Podman whose store holds Hive's resources next to a Distrobox and an
# unrelated development container, asserts the unowned ones survive, and
# replays every command the teardown issued back through the #4210 guard.
- name: Podman teardown through the ownership contract (#4326)
working-directory: .
run: bash bin/test_hive_podman_teardown.sh
# #4209: SELinux labeling, configuration/secrets readability, and host
# port conflicts are the operator-facing Podman failures that surface
# only after `podman run` has started making changes. Every input is
# mocked, so the enforcing/permissive/disabled matrix runs on a runner
# with no SELinux and no Podman. The suite also asserts the remediation
# never proposes disabling SELinux or widening a secret, and that the
# deployment fixture is byte-identical after every run.
- name: Podman SELinux, mount, secret, and port preflight (#4209)
working-directory: .
run: bash bin/test_hive_podman_preflight_host.sh
# #4208's suite, wired here because its two siblings above already were
# and it was left out (#4363). Subordinate UID/GID delegation is the
# rootless prerequisite operators get wrong most often, and the suite
# asserts the remediation never edits /etc/subuid or /etc/subgid itself.
- name: Podman subordinate-ID, graphroot, and network preflight (#4208)
working-directory: .
run: bash bin/test_hive_podman_preflight_ids.sh
# #4377: the Quadlet lifecycle contract. A clean `systemctl stop` used to
# leave the unit `failed` (the entrypoint exits 143 on SIGTERM), and
# `SuccessExitStatus=143` only works paired with `Restart=always` β€” split
# them and an externally removed container stays down reporting success.
# The suite also pins that boot wiring is read from the generator output
# and lingering rather than from `is-enabled`, which reports `generated`
# for these units no matter what. Every input is mocked, so this runs on
# a runner with no Podman and no lingering user.
- name: Podman Quadlet lifecycle probe (#4377)
working-directory: .
run: bash bin/test_hive_podman_lifecycle_probe.sh
# #4490: the SELinux release qualification (#4337) read labels with bare
# `stat -c '%C'`, and under uutils coreutils β€” the default on the
# Fedora-atomic hosts that lane targets β€” that prints "unsupported for
# this operating system" to stdout with exit 0, so a healthy enforcing
# host was reported FAIL (2), ledger row included. The reader is now
# resolved by probing (src/deploy/selinux_label_reader.sh, shared with
# the AVC probe), and no reader means NOT QUALIFIABLE, never a verdict.
# The defect is in how the label is READ, so it reproduces on a runner
# with no SELinux at all β€” which is why this half can be CI while the
# qualification itself cannot (#4211).
- name: SELinux label reader is resolved, never assumed (#4490)
working-directory: .
run: bash src/deploy/test_selinux_label_reader.sh
# #4470: the one-command Podman installer, which is the only script in
# the family that WRITES to the host β€” so the suite is about what it
# cannot be allowed to do. It pins the two traps that justified scripting
# it: dashboard.port is READ out of the unit's HealthCmd rather than
# hardcoded (#4367 β€” driven with a unit probing an arbitrary port, which a
# script carrying a literal 3002 would fail), and the secrets command
# matches the root mode (#4359 β€” podman unshare rootless, chgrp rootful).
# It also holds that a second run cannot cost an operator their edited
# config or their keys, that the preflights are invoked with
# HIVE_DEPLOY_RUNTIME=podman (without it they exit 0 having checked
# nothing), and that "healthy" is never claimed on a unit still
# activating or a silent gateway. apt-get, dnf, git and docker sit on the
# test PATH as tripwires: this installer installs no packages and clones
# nothing. Every input is mocked, so it needs no podman, no systemd, and
# no privileges.
- name: Podman one-command installer (#4470)
working-directory: .
run: bash bin/test_hive_podman_setup.sh
- name: hivectl contributor bootstrap (#8200)
working-directory: .
run: bash bin/test_hivectl_bootstrap.sh
# #4378: the deliberate manual update and rollback. The unit ships a
# FLOATING tag, so the pin that makes a rollback possible lives in a
# Quadlet drop-in; the suite holds the two properties that make it a
# rollback rather than a hope β€” that the pin written is always a digest
# (and the manifest LIST digest, not the per-architecture one `podman
# image inspect` hands back), and that `rollback` skips the entry that
# just failed instead of walking back into it. #4493 added the third:
# neither `rollback` nor `pin` may claim "serving" until the GATEWAY
# answers on its published port from the host β€” a failed update strands
# hive-gateway.service inactive (Requires= stopped it, and a restart of
# hive.service only cycles dependents that are active), so the suite
# holds that rollback starts the gateway and that a dead published port
# is an exit-78 finding, never a PASS. Every input is mocked, so
# this pulls nothing and restarts nothing.
- name: Podman Quadlet update and rollback (#4378)
working-directory: .
run: bash bin/test_hive_podman_update.sh
# The gh wrapper's AUTHOR GATE, distinct from the mode/allowlist/merge
# gates covered above. 33 executable cases from #3072 and #3096: a
# spoofed HIVE_AGENT, identity-lookup failure, the marker trust boundary,
# and a cached-env spoof. This is a security boundary that was running
# only when someone remembered to run it (#4363) β€” a regression in it
# would have landed green.
- name: gh-wrapper author-gate tests (#3072, #3096)
working-directory: .
run: bash bin/gh-wrapper.test.sh
- name: repo-toolchain manifest tests (#7925)
working-directory: .
run: bash bin/repo-toolchain.test.sh
- name: hive-open-issue helper tests
working-directory: .
run: bash bin/test_hive_open_issue.sh
- name: hive-open-pr helper tests
working-directory: .
run: bash bin/test_hive_open_pr.sh
- name: hive-review helper tests
working-directory: .
run: bash bin/test_hive_review.sh
- name: Shared CI baseline classifier tests
working-directory: .
run: bash bin/test_hive_baseline_check.sh
# The guard that keeps the three steps above from being the last time
# anyone notices: it fails when a bin/ suite exists and nothing runs it.
# Each omission it catches is individually invisible, because nothing
# fails when a test file is simply never named.
- name: Every bin/ test suite is wired into something (#4363)
working-directory: .
run: bash bin/test_bin_suites_wired.sh
runtime-tests:
needs: required-context-scope
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-ci == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
working-directory: src
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
- name: Ensure PyYAML on self-hosted runners
run: |
python3 -c "import yaml" 2>/dev/null && exit 0
sudo apt-get update -qq || true
sudo apt-get install -y -qq python3-yaml && exit 0
python3 -m pip install --user -q --break-system-packages pyyaml \
|| { curl -sS https://bootstrap.pypa.io/get-pip.py | python3 - --user -q --break-system-packages && python3 -m pip install --user -q --break-system-packages pyyaml; }
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Guard shared node tool cache (repair a half-written entry)
# setup-node reports "Found in cache" for a node/<ver>/x64 entry whose
# .complete marker exists but bin/npm does not (#10505, #10518). The
# canary repairs this too, but its cron is starved to a ~5h cadence, so
# heal the node from the hot path: drop the marker (never the directory,
# a sibling job may be mid-run) so the next job re-extracts.
if: ${{ always() }}
run: |
set -u
command -v npm >/dev/null 2>&1 && exit 0
for marker in "${RUNNER_TOOL_CACHE:-/nonexistent}"/node/*/x64.complete; do
[ -e "$marker" ] || continue
dir="${marker%.complete}"
[ -x "$dir/bin/npm" ] && continue
echo "::warning::node tool cache $dir has a .complete marker but no executable bin/npm; removing the marker so the next job on $RUNNER_NAME rebuilds it"
rm -f "$marker"
done
echo "::error::npm is not on PATH after actions/setup-node on $RUNNER_NAME (half-written shared tool cache, see #10518); marker removed, re-run this job"
exit 1
- name: Proxy Tests
run: cd proxy && npm ci && npm test
# src/pkg/dashboard/*.test.js are node:test helper suites that the
# dashboard/ npm test does not cover (#13566).
- name: Dashboard helper tests
working-directory: .
run: node --test src/pkg/dashboard/*.test.js
# The Commons web page script (src/pkg/hivectl/commands/hives_web_tree.js)
# runs in a vm against a fake DOM; keyboard-focus regressions live here.
- name: Commons web page tests
working-directory: .
run: node --test src/pkg/hivectl/commands/*.test.js
- name: Integration candidate materializer contracts
working-directory: .
run: node --test .github/scripts/integration-*.test.cjs
# bin/contributor-relay.js used to carry the source under a misleading
# .sh name (kubestellar/hive#6429), which needed a copy trick for
# node --check. It is a real .js file now; check it and its lib/ module
# directly. bin/contributor-relay.sh is kept only as a thin POSIX-sh
# compat wrapper for deployments that still invoke the old path;
# bash -n validates it here.
- name: Contributor Relay Syntax + Tests
working-directory: .
run: |
bash bin/contributor-agent.test.sh
node --check bin/contributor-relay.js
node --check bin/lib/pane-classifier.js
bash -n bin/contributor-relay.sh
node bin/contributor-relay.test.js
node --test bin/mlx-backend.test.js
# bin/vibe-kanban-mirror.js (#10641): report-only hive β†’ vibe-kanban
# mirror, exercised against a fake MCP stdio server.
- name: vibe-kanban mirror tests
working-directory: .
run: |
node --check bin/vibe-kanban-mirror.js
node bin/vibe-kanban-mirror.test.js
# backend-smoke.yml's failure-issue script runs only on a scheduled
# failure, in production, and its output is an issue somebody acts on β€”
# so nothing ever exercised it, and it spent a day announcing that the
# shipped CLIs were broken when the runner simply had no podman (#5883).
# This extracts that script from the workflow and runs it against
# fixtures. Placed after setup-node above, which it needs.
- name: Backend smoke failure-issue attribution
working-directory: .
run: bash bin/test_backend_smoke_attribution.sh
# The other half of "this lane says the true thing" (#6086): it must pin
# THIS repository's contributor image, not upstream's. The lane exists to
# run the checkout's suite against the CLIs the image ships, and a
# hard-coded org made a fork's lane exercise upstream's binaries and stay
# green regardless of its own. Extracts the selection step and runs it
# against a stubbed podman, because the cases that matter -- lowercasing
# an uppercase owner, and the fallback being loud -- a grep cannot see.
- name: Backend smoke pins this repository's contributor image
working-directory: .
run: bash bin/test_backend_smoke_image_owner.sh
# Keyless subset of the backend smoke: backend-list/Dockerfile-pin drift
# checks plus the stub wire-contract scenarios (real relay, fake hub, a
# stub CLI binary β€” no credentials, no network beyond localhost). The
# live scenarios skip here by design; they run with real vendor CLIs on
# a schedule in backend-smoke.yml, which is deliberately NOT merge-gating.
- name: Backend smoke (keyless subset)
working-directory: .
run: bash bin/test_backend_smoke.sh
# `just contribute-k8s` now emits a runnable contributor workload (#2549):
# a headless Deployment (#2660) with a status-file probe. This runs the
# real recipe and asserts the workload it produces, so a regression (a
# dropped headless mode, a broken probe, a token leak into stdout) fails
# the PR. Requires `just`; the test SKIPs cleanly if it is ever absent.
# Direct pinned download instead of extractions/setup-just: the action
# lists releases via the GitHub API at RUN TIME, and that lookup broke
# fleet-wide on 2026-08-17 ("no release for just matching version
# specifier", even with an explicit version) during a GitHub API
# incident β€” failing build-and-test on every branch/PR after all tests
# had passed. The releases CDN stayed up throughout; a pinned URL with a
# checksum has no API dependency at all. Bump the version and hash
# together, deliberately.
# The CDN itself still 504s transiently (#17357), hence the curl
# --retry flags; sha256sum still gates whatever is fetched.
- name: Install just
run: |
set -euo pipefail
JUST_VERSION=1.58.0
JUST_SHA256=4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d
curl -fsSL --retry 5 --retry-delay 10 --retry-all-errors -o /tmp/just.tgz "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz"
echo "${JUST_SHA256} /tmp/just.tgz" | sha256sum -c -
tar -xzf /tmp/just.tgz -C /tmp just
sudo install -m 0755 /tmp/just /usr/local/bin/just
just --version
- name: Contributor K8s Workload Generation
working-directory: .
env:
# #5388: the "Install just" step above GUARANTEES the binary, and
# ubuntu-latest guarantees python3+PyYAML, so neither of this suite's
# skips can legitimately fire here. Before this, a failed install
# silently turned the whole suite into an exit-0 no-op.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash src/deploy/test_contribute_k8s_workload.sh
# #4399: the browser terminal must say when it is NOT showing live output.
# The fix is a tmux FORMAT STRING, and a malformed one does not fail β€” it
# renders as garbage or leaves the literal `#{?...}` on screen. So this
# sets it on a scratch tmux server and reads back what tmux actually
# renders, in both the live and scrolled-back states, and reproduces the
# reported "halt" (a copy-mode pane stops following output, and survives
# closing the browser). Skips cleanly where tmux is absent.
- name: Terminal scrollback legibility (#4399)
working-directory: .
run: bash src/deploy/test_terminal_scrollback_status.sh
# #4593: the dashboard's per-agent terminal link carries the session as
# ?arg=hive-<agent>, which ttyd only forwards when started with -a
# (--url-arg). The credential branch added in #2178 REPLACED the variable
# that carried -a, so every hive with a dashboard token β€” i.e. effectively
# all of them β€” got a browser terminal that could not attach. A grep for
# "-a" passes on that bug, so this runs the real flag assembly out of
# entrypoint.sh with ttyd stubbed and asserts on the resulting argv, once
# per credential configuration. Skips the end-to-end half where tmux is
# absent.
- name: ttyd keeps --url-arg when credentialed (#4593)
working-directory: .
run: bash src/deploy/test_ttyd_url_arg.sh
# The browser terminal's attach helper falls back to the DEFAULT tmux
# socket when agents share one UID. It assumed that socket lived under
# dev's passwd uid (/tmp/tmux-1001), but under OpenShift's restricted
# SCC the container runs as an arbitrary uid with no passwd entry, so
# every session was alive on /tmp/tmux-<arbitrary>/default while the
# dashboard polled /tmp/tmux-1001/default for 900s. This stubs `id`,
# starts real tmux servers at the paths each uid layout would use, and
# asserts the helper attaches to the right one. Skips where tmux is
# absent.
- name: ttyd finds the default tmux socket under an arbitrary container UID
working-directory: .
run: bash src/deploy/test_ttyd_default_socket_uid.sh
# #5435: hive-discord.service ran `node bot.js` with
# WorkingDirectory=/tmp/hive/discord, resolving the executed code out of a
# world-writable parent that is cleared on every reboot β€” a local user who
# created the path first got code execution as the service user holding the
# Discord bot token. The unit now refuses to start via hive-checkout-guard.sh.
# A grep for "ExecStartPre" would pass against a guard that never rejects
# anything (the obvious inline `find ... -print -quit` exits 0 even when it
# matches nothing), so this EXECUTES the guard against real directory trees
# and asserts on its exit status, once per attack shape.
- name: hive-discord.service does not execute planted code (#5435)
working-directory: .
run: bash src/deploy/test_hive_discord_unit_contract.sh
# #5483: hive-snapshot.service is the same class as #5435 β€” it ran
# ExecStart=/tmp/hive/dashboard/publish-snapshot.sh, resolving the executed
# script out of the same world-writable, reboot-cleared parent. Lower
# severity (Type=oneshot on a timer, no EnvironmentFile) but the script
# reads a GitHub App token and pushes to kubestellar/docs. The same guard
# generalizes unmodified. Registered separately because these deploy tests
# are wired INDIVIDUALLY here β€” an unregistered test silently never runs.
- name: hive-snapshot.service does not execute planted code (#5483)
working-directory: .
run: bash src/deploy/test_hive_snapshot_unit_contract.sh
# #5936: hive-deploy.service must use the verified-snapshot copy path so
# root-installed files never come straight from the mutable /tmp/hive
# worktree.
- name: hive-deploy.service does not root-install planted code (#5936)
working-directory: .
run: bash src/deploy/test_hive_deploy_unit_contract.sh
# #4440: the changelog reminder promises it "always succeeds" and "never
# blocks a merge", and it did neither on a fork PR β€” GITHUB_TOKEN is
# read-only there, the comment POST 403s, and `bash -e` made that a red
# check on every external contribution. This EXTRACTS the real step script
# out of the workflow and runs it against a stub gh that 403s, because the
# bug was an exit status and a grep over the YAML would pass on a script
# that still dies on the first non-zero command.
- name: Changelog reminder never blocks a merge (#4440)
working-directory: .
env:
# #5388: ubuntu-latest GUARANTEES python3 with PyYAML (actions/runner-images
# ships both preinstalled), so this suite's "python3/PyYAML unavailable"
# skip cannot be true here. If it fires, the precondition moved and the
# suite stopped asserting β€” that must be red, not a silent exit 0.
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash src/deploy/test_changelog_reminder.sh
# #4408: `just contribute-move` relocates an already-registered
# contributor identity to a new machine. The properties that matter are
# not visible in the Justfile text β€” that the positional HIVE_HUB /
# HIVE_REGISTRATION_TOKEN / CONTRIBUTOR_ID lists come out aligned and in
# the same order is a property of the loop, and bin/contributor-relay.js
# pairs those lists by index (it refuses to start on a length mismatch and
# sends the wrong token to the wrong hive on a transposed one). Getting the
# partial-failure path wrong is worse: a reissued token cannot be
# reprinted, so dropping it locks the contributor out of that hive. This
# runs the real recipe against a fake hub with a stubbed gh and a
# throwaway HOME β€” no network, no real credential.
- name: Contributor identity relocation (#4408)
working-directory: .
run: bash src/deploy/test_contribute_move.sh
# #5145: container mode resolves docker OR podman, but both hints printed
# from INSIDE the container hardcoded docker β€” a container cannot see its
# own launcher. On a podman run the recipe's host-side hint and the
# entrypoint's status line disagreed about the same container in one
# screen of output, and the one an operator pasted was the wrong one.
# This renders both hints from their shipped sources and requires them to
# be the same command; a grep for the literal would pass on a hint that
# names the variable and still renders wrongly.
- name: In-container attach hints name the real runtime (#5145)
working-directory: .
run: bash src/deploy/test_attach_hint_runtime.sh
- name: Isolated contributor Compose contract (#9188)
working-directory: .
run: |
# hive-runners lack python3-venv (ensurepip); the job's first step
# already guarantees PyYAML for the system python3, so fall back to it.
if python3 -m venv "$RUNNER_TEMP/isolated-contributor-tests" 2>/dev/null; then
"$RUNNER_TEMP/isolated-contributor-tests/bin/pip" install PyYAML==6.0.2
PY="$RUNNER_TEMP/isolated-contributor-tests/bin/python"
else
python3 -c "import yaml"
PY=python3
fi
"$PY" src/deploy/test_isolated_contributor_compose.py
# #6485: the default contributor container had no resource ceiling, so a
# healthy coding task could consume enough RAM and zram for systemd-oomd
# to kill it (or another process in the contributor's desktop session).
# Run the real recipe against stub Docker/Podman CLIs: this verifies the
# effective argv, operator overrides, and OOM/SIGKILL diagnostics rather
# than merely finding resource-related strings in the Justfile.
- name: Contributor containers enforce resource limits (#6485)
working-directory: .
env:
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash src/deploy/test_contributor_container_resources.sh
# #7836: container mode staged the contributor's ENTIRE ~/.claude β€” every
# transcript on the machine, the prompt history, paste cache, memory and
# CLAUDE.md β€” into the sandbox where third-party test suites run for
# real. Run the real recipe against a populated fake ~/.claude and a stub
# runtime, and assert what the container actually receives: the
# credential and settings files, nothing else. A source grep would pass
# on an allowlist that a later `cp -a` quietly undid.
- name: Contributor containers stage only the Claude credential and settings (#7836)
working-directory: .
env:
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash src/deploy/test_contributor_claude_staging.sh
# #5388 item 3: the guard on the skip discipline itself.
#
# test_lib.sh only helps if the suites that source it ACTUALLY go fatal
# under HIVE_TEST_REQUIRE_BEHAVIOURAL=1. This does not grep for adoption β€”
# a grep passes on a suite that sources the lib and never reaches the
# call. It RUNS each registered suite in a sandbox PATH with the tool its
# behavioural block needs genuinely absent, and requires exit 0 without
# the flag and exit 1 with it. The suite's own exit status is the
# assertion, so this cannot green while proving nothing.
#
# Deliberately NOT given HIVE_TEST_REQUIRE_BEHAVIOURAL itself: its method
# is running children with the flag off and on, so it unsets it.
- name: A skip is fatal where the caller guarantees the precondition (#5388)
working-directory: .
run: bash src/deploy/test_skip_discipline.sh
# #5388: this suite existed but was wired into NO workflow, so it had
# never run in CI β€” the same silent-non-execution failure the issue is
# about, one level out. Registering it here; git is guaranteed by
# actions/checkout, so its "git not available" skip is fatal.
- name: Entrypoint system gitconfig contract
working-directory: .
env:
HIVE_TEST_REQUIRE_BEHAVIOURAL: '1'
run: bash src/deploy/test_entrypoint_system_gitconfig.sh
# #5529: this suite was referenced by NO workflow at all β€” not v2-ci.yml,
# not any specialised lane β€” so it had never run in CI. It passes 8/0, so
# nothing was broken; the point is that the Copilot token-persistence
# symlinks it guards were not being checked either. Those symlinks are a
# recurring incident surface: a lost ~/.copilot -> /data/home/.copilot
# link drops the CLI's stored token on every container restart, which
# presents as an agent that silently stops authenticating rather than as
# anything that fails loudly.
#
# It reads entrypoint.sh and bin/agent-launch.sh from the checkout, so it
# needs no runtime and belongs in this lane rather than a container one.
- name: Entrypoint Copilot token symlink contract (#5529)
working-directory: .
run: bash src/deploy/test_entrypoint_symlinks.sh
# #5730: the entrypoint's permission guards keep ONE operator login
# serving the whole Claude fleet by reopening the shared credential the
# CLI rewrites 0600 on every token refresh. They run under `set -e` in
# backgrounded subshells, so a single non-zero command in a guard body
# ended the guard permanently and silently β€” measured live, with five of
# six agents dropping to a login prompt hours after boot.
#
# This suite EXTRACTS the shipped guard functions from entrypoint.sh and
# executes them, rather than grepping: the pre-fix code read perfectly
# well, so only running it catches the regression. No runtime or image
# needed, which is why it belongs in this lane.
- name: Entrypoint permission-guard survivability (#5730)
working-directory: .
run: bash src/deploy/test_entrypoint_perm_guard.sh
# #5646: the hivectl distribution route for image-based hosts rests on
# src/Dockerfile (which stows the binary in the image as cargo) and
# bin/hive-podman-setup.sh (which extracts it onto the host) agreeing on
# one path β€” and nothing at runtime checks that they do. A rename on
# either side fails only on an operator's machine, after a green CI run.
# Pure string analysis over the checkout; no image is built.
- name: hivectl extraction route contract (#5646)
working-directory: .
run: bash src/deploy/test_hivectl_extraction_contract.sh
# #6003: on hosts missing a non-optional netfilter extension,
# entrypoint.sh must capture the iptables stderr, flush any partial
# HIVE_PROXY chain, and then take the explicit advisory/fail-closed path
# instead of letting set -e crash on the raw iptables exit code.
- name: Entrypoint iptables append failure handling (#6003)
working-directory: .
run: bash src/deploy/test_entrypoint_iptables_gate.sh
- name: Entrypoint egress ruleset result checks (#6003)
working-directory: .
run: bash src/deploy/test_entrypoint_egress_ruleset.sh
# The step BEFORE the chain is built: a node missing xt_REDIRECT or
# xt_mark must be diagnosed by name and exit with the documented
# preflight code 77, leaving no partial HIVE_PROXY chain behind.
- name: Entrypoint netfilter extension preflight (#6003)
working-directory: .
run: bash src/deploy/test_entrypoint_xt_module_preflight.sh
# The guard that keeps the omission above from recurring: every
# src/deploy/test_*.sh must be named by at least one workflow. This is
# the src/deploy/ sibling of bin/test_bin_suites_wired.sh (#4363).
#
# It is registered here, which is also what makes it pass its own check β€”
# exempting itself would be the hole it exists to close.
- name: Every src/deploy/ test suite is wired into a workflow (#5529)
working-directory: .
run: bash src/deploy/test_deploy_suites_wired.sh
# Runner labels must degrade to a GitHub-hosted runner where no fleet
# exists (#6083). A fork that inherits a bare [self-hosted, hive] gets
# jobs that queue forever, cannot be cancelled, and wedge the workflow's
# concurrency group behind them β€” silently, because a job that never
# starts reports nothing. Checked here rather than remembered: the
# fork-guarded spelling was added deliberately and three jobs in the same
# files were still left bare.
- name: Runner labels are fork-portable (#6083)
working-directory: .
run: bash src/deploy/test_runner_labels_portable.sh
- name: Build aggregate preserves scope fallback and rejects skipped lanes
working-directory: .
run: bash src/deploy/test_ci_build_lane_aggregate.sh
# Preserve the protected check name: every original step still gates it.
build-and-test:
needs: [required-context-scope, build-go, contract-tests, runtime-tests]
if: ${{ always() && !cancelled() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-ci == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- name: Require every build and contract lane
env:
SCOPE_RESULT: ${{ needs.required-context-scope.result }}
GO_RESULT: ${{ needs.build-go.result }}
CONTRACT_RESULT: ${{ needs.contract-tests.result }}
RUNTIME_RESULT: ${{ needs.runtime-tests.result }}
run: |
for result in "$GO_RESULT" "$CONTRACT_RESULT" "$RUNTIME_RESULT"; do
if [ "$result" != success ]; then
echo "::error::build-and-test requires successful Go, contract and runtime lanes (scope=$SCOPE_RESULT go=$GO_RESULT contracts=$CONTRACT_RESULT runtime=$RUNTIME_RESULT)"
exit 1
fi
done
create-issue-on-failure:
if: "!inputs.integration_candidate && failure() && github.event_name == 'push' && github.ref == 'refs/heads/v2'"
runs-on: ${{ fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') }}
timeout-minutes: 10
needs: build-and-test
permissions:
contents: read
issues: write
steps:
- name: Create issue for test failure
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const sha = context.sha.substring(0, 7);
const title = `[CI] v2 test failure on ${sha}`;
// Avoid duplicates: check for an open issue with the same title prefix
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'ci-failure',
per_page: 10,
});
const dup = existing.data.find(i => i.title.startsWith('[CI] v2 test failure'));
if (dup) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: dup.number,
body: `Another failure on \`${sha}\`: ${runUrl}`,
});
return;
}
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body: `## CI Failure\n\n- **Commit:** \`${context.sha}\`\n- **Run:** ${runUrl}\n- **Branch:** v2\n\nTests or coverage check failed on push. See the [workflow run](${runUrl}) for details.`,
labels: ['ci-failure', 'kind/bug'],
});
# Deliberately NO `needs: build-and-test` here or on overlayfs-exec-guard.
# Neither job consumes anything build-and-test produces β€” each compiles the
# PR's own tree from scratch inside src/Dockerfile β€” so chaining them behind
# the test gate bought no correctness and put the gate's wall time PLUS a
# second trip through the shared runner queue on every PR's time-to-green
# (measured 2026-09-01 across three PR runs: ~2.75 min of build-and-test,
# then 10-11 min queued before either image job started, then 3.5-4.5 min
# of build). Running all three from t=0 makes the guard (~4.5 min) the
# ceiling instead of the sum. Cost of the trade: on the ~2% of runs where
# build-and-test is red, a compile error also fails the in-Dockerfile
# `go build` within ~1 min, and a test-only failure lets these two run to
# completion (~8 wasted runner-minutes). nightly-release still requires
# BOTH build-and-test and docker, so the v2 nightly gate is unchanged.
# Builds every Dockerfile in the repo so a PR cannot break an image that is
# only discovered on the base branch. docker.yml builds nothing for feature
# branches any more (it used to build all six platform images per push,
# ~20 hosted-runner minutes, and that queue contention was the long pole on
# every PR) β€” this matrix is the sole pre-merge image compile gate. amd64
# only: the arm64 legs exist to publish, not to prove the Dockerfile builds.
# The hub/contributor legs run on pull_request only; on a push to a release
# line docker.yml builds and publishes them anyway.
docker:
name: docker (${{ matrix.name }})
needs: required-context-scope
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-ci == 'true') }}
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- name: hive
file: src/Dockerfile
smoke: "true"
- name: hub
file: src/Dockerfile.hub
smoke: "false"
- name: contributor
file: src/Dockerfile.contributor
smoke: "false"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
# Dockerfile.hub does `COPY .git` to stamp the build with a git
# describe; a shallow clone makes that describe wrong, not absent.
fetch-depth: ${{ matrix.name == 'hub' && 0 || 1 }}
- name: Skip non-hive images on push
if: "!inputs.integration_candidate && github.event_name != 'pull_request' && matrix.name != 'hive'"
run: echo "skip=true" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
if: env.skip != 'true'
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: network=host
- name: Resolve base image registry (mirror.gcr.io with docker.io fallback)
if: env.skip != 'true'
uses: ./.github/actions/docker-base-registry
- name: Build Docker image
if: env.skip != 'true'
run: docker buildx build --load --network host --build-arg BASE_REGISTRY="$BASE_REGISTRY" -f "${{ matrix.file }}" -t "hive-ci-${{ matrix.name }}:test" .
- name: Smoke test
if: env.skip != 'true' && matrix.smoke == 'true'
shell: bash
run: |
set -euo pipefail
name="hive-v2-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cleanup() {
docker rm -f -v "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# #3760: the binary carries NO file capability anymore, so it must exec
# under docker's DEFAULT cap set (no --cap-add) β€” a self-hosted spoke
# has exactly that bounding set. NET_ADMIN, when present, is raised by
# the entrypoint as an ambient cap; the raw `--version` here needs none.
# The old `--help || true` form could not fail and asserted nothing.
timeout --signal=TERM --kill-after=10s 60s \
docker run --rm --name "$name" --network none hive-ci-hive:test --version
# #3760 REGRESSION GUARD β€” overlayfs/containerd in-place exec.
#
# The `docker` smoke above builds with plain `docker build` and runs under
# docker/runc. That combination does NOT reproduce #3760: the crash-loop is a
# BuildKit metacopy redirect that only manifests when (a) the image is pushed
# in the OCI-index/attestation form the real pipeline produces AND (b) it is
# run on the overlayfs snapshotter (containerd/k3s, rootless podman). This job
# exercises exactly that path: build with buildx like docker.yml does, export
# an OCI archive, import into CONTAINERD, and assert that
# `/usr/local/bin/hive --version` execs AT ITS IMAGE PATH (no copy, caps
# dropped) under the containerd overlayfs snapshotter β€” the runtime the
# reporter confirmed fails. If the metacopy redirect ever returns (e.g. a
# future re-enable of provenance/SBOM or removal of the in-stage rewrite), the
# binary EPERMs here and the PR is blocked.
overlayfs-exec-guard:
# Hosted on purpose: this asserts GitHub-hosted runner containerd/overlayfs
# semantics (systemd-managed containerd plus the default overlayfs snapshotter).
needs: required-context-scope
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-ci == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 10
# No `needs: build-and-test` β€” see the note above the `docker` job.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# Build a single-arch image the SAME way docker.yml builds the shipped
# image β€” buildx (BuildKit), attestations OFF (provenance:false/sbom:false
# as in the #3760 fix) β€” and export it as an OCI archive so containerd
# imports the real BuildKit layer layout (not the docker daemon's).
#
# cache-from: read docker.yml's `tmux-linux-amd64` gha scope, the same
# one its own amd64 build reads. That scope only ever receives
# `--target tmux-builder` exports (docker.yml's warm step, run on every
# PR and every v4 push), and tmux-builder's inputs are a pinned base
# image and a pinned tarball β€” so no layer carrying /usr/local/bin/hive
# can be served from it and the stale-COPY hazard the stale-binary guard
# below exists for cannot arise here. Every other stage is still built
# from scratch on THIS commit (there is no cache-to and no other scope),
# so the assertions below still test the PR's own binary. Read-only: a
# miss, a renamed scope, or a cache-service hiccup just recompiles tmux
# as before. Measured 2026-09-01: the runtime stage blocks on
# tmux-builder (~51s) at its third layer while its own apt layer takes
# ~20s, so a hit removes ~30s from a ~3.5 min build; a whole-image
# cache-to would instead ADD the 130-225s export docker.yml measured.
- name: Resolve runner ref name
id: ref_name
run: |
set -euo pipefail
: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
echo "value=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
- name: Resolve base image registry (mirror.gcr.io with docker.io fallback)
uses: ./.github/actions/docker-base-registry
- name: Build image via buildx (OCI archive, no attestations)
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/Dockerfile
platforms: linux/amd64
provenance: false
sbom: false
outputs: type=oci,oci-mediatypes=true,dest=/tmp/hive-oci.tar,tar=true
cache-from: type=gha,scope=tmux-linux-amd64
build-args: |
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
GIT_HASH=${{ github.sha }}
GIT_BRANCH=${{ steps.ref_name.outputs.value }}
- name: Run under containerd/overlayfs and assert in-place exec
shell: bash
run: |
set -euo pipefail
# containerd ships on GitHub ubuntu runners; ensure it is up and using
# the overlayfs snapshotter (its default) β€” that is what makes the
# metacopy redirect observable.
sudo systemctl start containerd 2>/dev/null || sudo containerd >/tmp/containerd.log 2>&1 &
for i in $(seq 1 30); do
if sudo ctr version >/dev/null 2>&1; then break; fi
sleep 1
done
sudo ctr version
# Confirm the overlayfs snapshotter is present (the failing runtime).
sudo ctr plugins ls | grep -i overlayfs || {
echo "overlayfs snapshotter not available on this runner" >&2; exit 1; }
IMG="hive-3760-guard:test"
# Import the OCI archive BuildKit produced into containerd. Resolve
# the linux/amd64 platform (the archive is single-arch, but pin it so
# the run below has a concrete manifest to unpack).
sudo ctr images import --platform linux/amd64 --index-name "$IMG" /tmp/hive-oci.tar \
|| sudo ctr images import --index-name "$IMG" /tmp/hive-oci.tar
sudo ctr images ls | grep "$IMG"
# The assertion: exec /usr/local/bin/hive AT ITS IMAGE PATH, no copy,
# under containerd's overlayfs snapshotter. --net-host keeps the run
# simple; we deliberately do NOT add NET_ADMIN β€” a self-hosted spoke
# has the docker/containerd default bounding set. Override the
# entrypoint so we test the raw binary exec (the #3760 symptom is a
# bare EPERM at execve of the binary, not an entrypoint failure).
set +e
out=$(sudo timeout 90s ctr run --rm --snapshotter overlayfs \
--net-host \
"$IMG" hive-3760-inplace \
/usr/local/bin/hive --version 2>&1)
rc=$?
set -e
echo "----- hive --version (in-place, containerd/overlayfs) -----"
echo "$out"
echo "exit=$rc"
if [ $rc -ne 0 ]; then
echo "::error::#3760 regression: /usr/local/bin/hive failed to exec IN PLACE under containerd/overlayfs (rc=$rc). This is the BuildKit metacopy-redirect crash-loop β€” a plain build-side attestation/OCI-index change or a removed in-stage binary rewrite has reintroduced it." >&2
exit 1
fi
case "$out" in
*"Operation not permitted"*|*EPERM*)
echo "::error::#3760 regression: hive execve returned Operation not permitted at its image path under containerd/overlayfs." >&2
exit 1 ;;
esac
# STALE-BINARY GUARD. The exec check above passes even for an OLD hive
# binary β€” a stale binary works fine, it just reports the WRONG commit.
# A branch-persistent GHA cache once let BuildKit serve a cached
# final-stage `COPY --from=builder /hive` layer, so the manifest digest
# advanced but /usr/local/bin/hive was an old build (reported commit
# 4d9c11c while HEAD had the #3760 fix). Assert the built binary's
# embedded commit matches THIS build's commit so a stale-layer cache hit
# is caught directly, not just an exec failure. `hive --version` prints
# `hive 3.0.0 (commit <short7>, branch <branch>)`; main.gitShort is the
# 7-char short SHA stamped from GIT_HASH=${{ github.sha }} at build.
EXPECTED_SHORT="${GITHUB_SHA::7}"
REPORTED_COMMIT=$(printf '%s\n' "$out" | sed -n 's/.*commit \([0-9a-f][0-9a-f]*\).*/\1/p' | head -n1)
echo "expected commit=$EXPECTED_SHORT reported commit=$REPORTED_COMMIT"
if [ -z "$REPORTED_COMMIT" ]; then
echo "::error::stale-binary guard: could not parse commit from 'hive --version' output β€” version stamping is broken or the output format changed." >&2
exit 1
fi
if [ "$REPORTED_COMMIT" != "$EXPECTED_SHORT" ]; then
echo "::error::stale-binary guard: shipped hive reports commit '$REPORTED_COMMIT' but this build is '$EXPECTED_SHORT'. The binary is STALE β€” a cached final-stage COPY --from=builder layer shipped an old /usr/local/bin/hive (the bug that let #3760's fix miss the published image). Check no-cache-filters covers the runtime stage in docker.yml." >&2
exit 1
fi
echo "OK: hive execs in-place under containerd/overlayfs (no copy, no NET_ADMIN) AND reports the current commit ($REPORTED_COMMIT)."
nightly-release:
if: "!inputs.integration_candidate && github.event_name == 'push' && github.ref == 'refs/heads/v2'"
runs-on: ${{ fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') }}
timeout-minutes: 60
needs: [build-and-test, docker]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to GHCR
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Resolve base image registry (mirror.gcr.io with docker.io fallback)
uses: ./.github/actions/docker-base-registry
- name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
# #3760: no default provenance/SBOM attestations β†’ plain image
# manifests, not an OCI index (which is what ships the overlayfs
# metacopy redirect that makes /usr/local/bin/hive non-exec-able at
# its image path under containerd/rootless-podman). See docker.yml.
provenance: false
sbom: false
tags: |
ghcr.io/${{ github.repository }}:nightly
ghcr.io/${{ github.repository }}:nightly-${{ github.sha }}
build-args: |
BASE_REGISTRY=${{ env.BASE_REGISTRY }}
GIT_HASH=${{ github.sha }}
GIT_SHORT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max