Skip to content

feat: add freebuff kit #275

feat: add freebuff kit

feat: add freebuff kit #275

name: Build and publish kits
# Publishes what a kit needs to be consumable: the base image its sandbox boots
# from (if it builds one), and the kit artifact itself.
#
# image builds <kit>/Dockerfile and pushes <kit>-image — only for a kit
# that ships one; most kits are mixins layered onto someone else's
# image and have nothing to build here
# artifact packages the spec plus files/ and pushes <kit>-kit — every kit
# being built this run, image or not
#
# The Hub repository pages are NOT set here; see hub-overview.yml.
#
# Kits are DISCOVERED, not listed: any directory with a `spec.yaml` is picked
# up automatically, so adding a kit needs no change to this workflow. That
# mirrors tck.yml, and it is the reason there is no top-level `paths:` filter —
# all filtering happens in detect-changes, which also decides which kits a
# given event should rebuild. Whether a discovered kit ALSO builds an image is
# decided per-kit in publish-one-kit.yml, by whether it has a `Dockerfile` —
# not by anything decided here, so a kit gaining or losing one needs no change
# to this workflow either.
#
# Conventions a discovered kit must follow:
#
# * The build context is the kit directory, with `Dockerfile` at its root.
# * `sandbox.image` in the kit's spec is the source of truth for what gets
# published — the image name is NOT derived from the directory name, because
# the two legitimately differ (the `kiro` kit publishes `kiro-image`, leaving
# `kiro-kit` for the kit artifact). `verify-spec-refs` gates the build on that
# image living in the namespace CI can push to.
# * `com.docker.sandboxes.start-docker=true` may only be set on an image that
# actually ships a Docker engine; the build asserts this.
# * `com.docker.sandboxes.flavor` must be the kit's own name. sbx reads it as
# the image's agent, and an image that does not set it inherits whatever
# its base sets — today's bases all carry their own flavor, so forgetting
# it usually reports the wrong agent rather than none; the build asserts
# this too.
#
# Shared coordinates (registry, namespace, rolling tag, platforms) come from
# repository/organisation variables with documented fallbacks, so retargeting is
# a settings change rather than a workflow edit. Anything per-kit — the base
# image in particular — belongs in that kit's Dockerfile, not here.
#
# A kit's `sandbox.image` cannot be parameterised — specs are consumed literally,
# with no env interpolation (see spec/types.go). Rather than duplicate the name
# here and risk drift, this workflow READS it from the spec and publishes exactly
# that. `verify-spec-refs` is the guard on that trust: it refuses to build a kit
# whose declared image sits outside the namespace CI authenticates to.
on:
push:
branches: [main]
pull_request:
# Nightly, because these images track moving upstreams rather than pinned
# versions: agents are typically installed from a `latest` channel and bases
# are floating tags, so a rebuild is the only way a new agent release reaches
# users of the kit — and it surfaces upstream breakage in CI rather than in
# someone's sandbox.
schedule:
- cron: "0 5 * * *"
workflow_dispatch: {}
permissions:
contents: read
# Required to mint the GitHub OIDC token that is exchanged for a short-lived
# Docker Hub token. An explicit permissions block narrows the repo default, so
# omitting this fails the exchange rather than falling back to it.
id-token: write
env:
REGISTRY: ${{ vars.REGISTRY || 'docker.io' }}
IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE || 'sbx' }}
IMAGE_TAG_LATEST: ${{ vars.IMAGE_TAG_LATEST || 'latest' }}
PLATFORMS: ${{ vars.PLATFORMS || 'linux/amd64,linux/arm64' }}
jobs:
detect-changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
# Kits to rebuild for this event.
kits: ${{ steps.collect.outputs.kits }}
# Every discovered kit, regardless of what changed — the drift check
# validates all specs, not just the ones touched.
all: ${{ steps.collect.outputs.all }}
# Which of this run's kits have their own Dockerfile — decides per-kit
# whether publish-one-kit.yml's `image` job runs at all.
dockerfile-kits: ${{ steps.collect.outputs.dockerfile-kits }}
# The immutable tag for kit artifacts. Computed here because a job that
# `uses:` a reusable workflow cannot run steps, so it has nowhere to call
# `date` — and the tag scheme is documented as <YYYYMMDD>-<sha>.
dated: ${{ steps.collect.outputs.dated }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Discover kits
id: discover
# By spec.yaml, not Dockerfile: whether a kit ALSO builds an image is
# decided per-kit in publish-one-kit.yml, not here — see the header
# comment. A kit's own Dockerfile presence has no bearing on whether
# it's a candidate for artifact publishing.
run: |
set -euo pipefail
kits=$(scripts/discover-kits.sh | tr '\n' ' ')
kits="${kits% }"
echo "Discovered kits:${kits:+ }${kits:-(none)}"
echo "kits=${kits}" >> "$GITHUB_OUTPUT"
- name: Generate filters
id: filter-setup
# One filter per discovered kit, matching the whole kit directory minus
# docs and test fixtures that never affect what gets published. Written
# as exclusions rather than an allowlist of sources on purpose: an
# allowlist silently stops matching once a Dockerfile starts COPYing a
# new file, leaving a published image stale until the next nightly.
# spec.yaml is deliberately NOT excluded — the drift check reads it.
#
# Two separate filter documents, not one, because the two "Filter ...
# changes" steps below need two DIFFERENT predicate-quantifier settings
# (dorny/paths-filter applies one quantifier to every filter in a
# single invocation, so kit-filters and shared-filters can't share a
# step): kit-filters mixes a positive pattern with negations per kit
# and needs "every" (see that step's comment for why); shared-filters
# lists several alternative exact paths with no negations, meant as
# "OR", and must stay at the default "some" — "every" would require a
# single file to match all of them at once, which is impossible.
run: |
set -euo pipefail
# Writes a $GITHUB_OUTPUT multiline value and echoes it to the log,
# shared by both filter documents below so they can't drift apart.
emit_output() {
echo "$1<<EOF" >> "$GITHUB_OUTPUT"
echo "$2" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "$2"
}
kit_filters=""
for kit in ${{ steps.discover.outputs.kits }}; do
kit_filters=$(printf '%s\n%s:\n - "%s/**"\n - "!%s/README.md"\n - "!%s/README.image.md"\n - "!%s/testdata/**"' \
"$kit_filters" "$kit" "$kit" "$kit" "$kit" "$kit")
done
emit_output "kit-filters" "$kit_filters"
# Shared inputs rebuild everything. The publish path is listed too:
# without it, a change to publish-artifact.yml or any of the scripts it
# calls produces no artifacts, so the publish job does not run at all
# — not even as a dry run — and the PR that changes the publishing
# logic is the one PR that never exercises it.
shared_filters=$(printf 'shared:\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \
' - ".github/workflows/build-and-publish-kits.yml"' \
' - ".github/workflows/publish-artifact.yml"' \
' - ".github/workflows/publish-one-kit.yml"' \
' - ".github/workflows/release-kit.yml"' \
' - "scripts/check-image-ref.sh"' \
' - "scripts/check-release-tag.sh"' \
' - "scripts/install-sbx.sh"' \
' - "scripts/publish-artifact.sh"')
emit_output "shared-filters" "$shared_filters"
- name: Filter kit changes
id: filter-kits
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
filters: ${{ steps.filter-setup.outputs.kit-filters }}
# Local git diff rather than the API, so fork PRs work with a
# restricted GITHUB_TOKEN.
token: ""
# Every per-kit filter mixes a positive pattern with negations
# (kit/**, !kit/README.md, ...). Under the default predicate-quantifier
# ("some" — a file matches the filter if ANY listed pattern's own
# per-pattern predicate returns true), a negated pattern's predicate
# returns true for almost every file that ISN'T that exact excluded
# path — so `.some()` over [positive, !a, !b, !c] is true for nearly
# anything, not just the intended "under kit/, except a/b/c". That
# silently made EVERY kit's filter true on EVERY run regardless of
# what actually changed (a PR touching only one new kit's files
# reported all ~28 kits as changed) — confirmed by inspecting a real
# run's per-filter "Matching files" log, where e.g. the "pi" filter
# matched files under an unrelated new copilot/ directory.
# "every" instead requires ALL listed patterns' predicates to be true
# for a given file — including the negated ones, whose own predicate
# is already "does NOT match this path" — which is exactly "matches
# kit/** AND is not README.md AND is not README.image.md AND is not
# under testdata/", the behavior every filter here was written
# assuming. (v4.0.2's newer "some-with-excludes" quantifier would be
# the more legible name for this, but it isn't implemented at this
# pinned commit — confirmed by reading src/filter.ts there directly.)
#
# Scoped to this step only — see "Generate filters" above for why
# "shared" can't use the same quantifier.
predicate-quantifier: every
- name: Filter shared changes
id: filter-shared
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
filters: ${{ steps.filter-setup.outputs.shared-filters }}
# Local git diff rather than the API, so fork PRs work with a
# restricted GITHUB_TOKEN.
token: ""
- name: Collect kits to build
id: collect
run: |
set -euo pipefail
all=$(printf '%s\n' ${{ steps.discover.outputs.kits }} | jq -R . | jq -sc .)
echo "all=${all}" >> "$GITHUB_OUTPUT"
case "${{ github.event_name }}" in
schedule|workflow_dispatch)
# Nothing "changed"; the point is to rebuild against moving
# upstreams, so rebuild everything.
kits="${all}"
;;
*)
if [ "${{ steps.filter-shared.outputs.shared }}" = "true" ]; then
# This workflow or the drift check changed — rebuild everything.
kits="${all}"
else
kits='${{ steps.filter-kits.outputs.changes }}'
fi
;;
esac
echo "kits=${kits:-[]}" >> "$GITHUB_OUTPUT"
echo "Building: ${kits}"
# Which of this run's kits also build their own image — decides
# per-kit, in publish-one-kit.yml, whether its `image` job runs at
# all. Computed here (not per-matrix-leg) because it needs the
# checkout this job already has; a job's own `if:` can't see repo
# content checked out by one of its own later steps.
#
# `|| continue`, not `&&`: under `set -e`, a while loop's exit status
# is its last iteration's — if the LAST kit in the list has no
# Dockerfile, `[ -f ... ] && printf` would fail on that iteration,
# and pipefail would then fail the whole assignment, killing this
# step before it reaches the outputs below. `|| continue` always
# exits 0 on a false test, regardless of which iteration it's on.
dockerfile_kits=$(echo "${kits:-[]}" | jq -r '.[]' | while read -r k; do
[ -f "$k/Dockerfile" ] || continue
printf '%s\n' "$k"
done | jq -R . | jq -sc .)
echo "dockerfile-kits=${dockerfile_kits:-[]}" >> "$GITHUB_OUTPUT"
echo "Kits with their own image: ${dockerfile_kits}"
# Date first so lexicographic order is chronological: tag listings
# (`oras repo tags`, Hub's tag page, `docker image ls`) sort as
# strings, and a hash-first tag sorts randomly. Date-first also makes
# `20260811*` a prefix query for one day's builds — which is why no
# bare <date> tag is published: it would be mutable within the day,
# the same flaw that rules out a bare <sha>.
echo "dated=$(date -u +%Y%m%d)-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
# Gates the build. Checks every kit that ships a Dockerfile — not just the ones
# being rebuilt — and asserts its declared image is one this pipeline may
# publish. Kits are found by scanning for Dockerfiles rather than from a list,
# so there is no per-kit entry to leave stale (a stale one would silently skip
# the check rather than fail).
verify-spec-refs:
needs: detect-changes
if: needs.detect-changes.outputs.all != '[]'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify kit specs reference published images
run: |
set -euo pipefail
./scripts/check-image-ref.sh "${REGISTRY}/${IMAGE_NAMESPACE}" "${IMAGE_TAG_LATEST}"
# One invocation per kit, each its own job graph: image → artifact → overview.
# A matrix of three separate jobs would instead make every kit's artifact wait
# for every kit's image, and let one kit's failure block another kit's publish.
kit:
needs: [detect-changes, verify-spec-refs]
if: >-
needs.detect-changes.outputs.kits != '[]' &&
needs.detect-changes.outputs.kits != 'null' &&
needs.detect-changes.outputs.kits != ''
strategy:
fail-fast: false
matrix:
kit: ${{ fromJson(needs.detect-changes.outputs.kits) }}
permissions:
contents: read
id-token: write
uses: ./.github/workflows/publish-one-kit.yml
secrets: inherit
with:
kit: ${{ matrix.kit }}
dated: ${{ needs.detect-changes.outputs.dated }}
has-image: ${{ contains(fromJson(needs.detect-changes.outputs.dockerfile-kits), matrix.kit) }}
# Every kit being built publishes its artifact — no allow-list, discovery
# is by spec.yaml alone (see the header comment). No `schedule` either — a
# kit's content is a pure function of the commit, so a nightly re-push
# would only mint a new digest for identical bytes.
publish-artifact: ${{ github.event_name != 'schedule' }}