Repository navigation
π± ci: stop persisting checkout credentials in PR-tree workflows #28696
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |