feat: add freebuff kit #275
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: 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' }} |