name: ci on: push: branches: [main] # Drafts and docs-only changes run lint-test only (see "Image-relevant # changes"); marking a PR ready runs the image build. To # build a draft anyway (a pre-merge CHR test image), dispatch this workflow # on its branch: that pushes :sha- and runs arm64 too. pull_request: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: inputs: t3code_ver: description: "Upstream T3 Code version (stable only, no nightlies)" required: false default: "0.1.8" permissions: contents: read packages: write env: T3CODE_VER: ${{ github.event.inputs.t3code_ver || '0.1.8' }} jobs: lint-test: runs-on: ubuntu-24.04 outputs: image: ${{ steps.changes.outputs.image }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 # Does this change reach the image? Docs (nothing the Dockerfile COPYs # or `build` runs) skip `build`, and so `arm64` and `promote`; lint-test # still runs, since its tests read README.md. A PR diffs its merge # commit against the base side; a push diffs against `before`. - name: Image-relevant changes id: changes env: BEFORE: ${{ github.event.before }} run: | case "$GITHUB_EVENT_NAME" in pull_request) base="HEAD^1" ;; push) base="$BEFORE"; git fetch --no-tags --depth=1 origin "$base" || base="" ;; *) base="" ;; esac if [ -z "$base" ] || ! changed="$(git diff --name-only "$base" HEAD)" || [ -z "$changed" ]; then echo "image=true" >> "$GITHUB_OUTPUT" exit 0 fi code="$(printf '%s\n' "$changed" | grep -Ev '(\.md$|^docs/|^\.agents/|^\.claude/|^LICENSE$|^\.coderabbit\.yaml$)' || true)" if [ -n "$code" ]; then echo "image=true"; else echo "image=false"; fi >> "$GITHUB_OUTPUT" printf 'changed:\n%s\n' "$changed" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.4.4" - run: bun install - run: bun run check - name: Install pinned hadolint, ShellCheck and actionlint env: HADOLINT_VER: "2.15.1" HADOLINT_SHA256: c7187db94eeeeca956519a6af171adc31453941a1e777961f6e680f697c8c507 SHELLCHECK_VER: "0.11.0" SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 ACTIONLINT_VER: "1.7.12" ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 run: | mkdir -p "$RUNNER_TEMP/bin" cd "$RUNNER_TEMP" curl -fsSL --retry 3 -o bin/hadolint "https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VER}/hadolint-linux-x86_64" echo "${HADOLINT_SHA256} bin/hadolint" | sha256sum -c - chmod +x bin/hadolint sc="shellcheck-v${SHELLCHECK_VER}.linux.x86_64.tar.xz" curl -fsSL --retry 3 -O "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VER}/${sc}" echo "${SHELLCHECK_SHA256} ${sc}" | sha256sum -c - tar -xJf "$sc" --strip-components=1 -C bin "shellcheck-v${SHELLCHECK_VER}/shellcheck" al="actionlint_${ACTIONLINT_VER}_linux_amd64.tar.gz" curl -fsSL --retry 3 -O "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VER}/${al}" echo "${ACTIONLINT_SHA256} ${al}" | sha256sum -c - tar -xzf "$al" -C bin actionlint echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - run: make lint-shell # Runs ShellCheck (installed above) on every `run:` script too. - run: make lint-actions - run: bun run test # The T3CODE_VER this run builds (the pin, or a dispatch's input) is a # stable upstream release with both Linux archives, and its harness # catalog matches lib/harnesses.ts. A dispatch may build a nightly # on purpose (CI accepts any T3CODE_VER): it gets the annotation but # still builds. - name: Freshness (pinned T3 release) continue-on-error: ${{ github.event_name == 'workflow_dispatch' }} run: bun scripts/freshness.ts pinned --version "$T3CODE_VER" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} build: if: needs.lint-test.outputs.image == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-24.04 needs: lint-test outputs: # The pushed manifest list: arm64 tests and promote retags exactly this, # even if another run (say, a dispatch with another T3CODE_VER at the # same commit) later moves :sha-. digest: ${{ steps.image.outputs.digest }} ver: ${{ steps.tags.outputs.ver }} minor: ${{ steps.tags.outputs.minor }} major: ${{ steps.tags.outputs.major }} build: ${{ steps.tags.outputs.build }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 if: github.event_name != 'pull_request' with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # build: this run's identity, -app.. It # goes into the OCI version label of every pushed candidate, and # promote also tags it (immutable, unlike :0.1.7). Run numbers count # PR and branch runs too, so promoted builds have gaps. A re-run keeps # its run number but rebuilds to a new digest, so attempts after the # first append . (0.1.7-app.133.2): a pushed tag never moves. # T3CODE_VER can be a dispatch input, so it must look like a version # before it reaches tags, outputs or shell. - name: Derive stability tags id: tags run: | VER="${T3CODE_VER}" if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::T3CODE_VER is not a version: $VER" exit 1 fi MINOR="${VER%.*}" MAJOR="${MINOR%.*}" BUILD="$VER-app.$GITHUB_RUN_NUMBER" if [ "$GITHUB_RUN_ATTEMPT" != 1 ]; then BUILD="$BUILD.$GITHUB_RUN_ATTEMPT"; fi { echo "ver=$VER" echo "minor=$MINOR" echo "major=$MAJOR" echo "build=$BUILD" } >> "$GITHUB_OUTPUT" - name: Load native image for replacement test uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: linux/amd64 load: true build-args: T3CODE_VER=${{ steps.tags.outputs.ver }} tags: app-t3code:test - name: Install test runtime uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.4.4" # Starts T3 with T3APP_TOOL_GROUPS=all (waits for every payload to # install and runs the native group tools unprivileged), then with an # unknown group, then offline: dev, the native groups, the image default # (no --env) and none. - name: Test container replacement run: bun test test/container-replacement.test.ts env: APP_T3CODE_DOCKER_IMAGE: app-t3code:test APP_T3CODE_T3VER: ${{ steps.tags.outputs.ver }} # Main / dispatch push only the candidate tag. The release tags (the # /app YAML pins :0.1) move in `promote`, after the arm64 image has run # natively; PRs build both arches without pushing. - name: Build (PR) or push candidate (main / dispatch) id: image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} build-args: T3CODE_VER=${{ steps.tags.outputs.ver }} tags: ghcr.io/tikoci/app-t3code:sha-${{ github.sha }} labels: | org.opencontainers.image.version=${{ steps.tags.outputs.build }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - name: Export inventories from the cached image builds uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . target: inventory platforms: linux/amd64,linux/arm64 build-args: T3CODE_VER=${{ steps.tags.outputs.ver }} outputs: type=local,dest=image-inventory # The metadata reaches the script as text, not through `env:`: since # build-push-action v7 each platform's provenance carries the whole # GitHub event payload (~200 KB on a PR), and one environment variable # over 128 KB fails the step ("Argument list too long"). The quoted # heredoc expands nothing, and its delimiter cannot be a line of JSON. - name: Record build identity env: IMAGE_DIGEST: ${{ steps.image.outputs.digest }} run: | cat > image-inventory/build-metadata.json <<'BUILD_METADATA_JSON' ${{ steps.image.outputs.metadata }} BUILD_METADATA_JSON jq -e 'has("buildx.build.ref")' image-inventory/build-metadata.json printf '%s\n' "$IMAGE_DIGEST" > image-inventory/image-digest.txt printf '%s\n' "$GITHUB_SHA" > image-inventory/source-commit.txt - name: Record runtime tool-group status with every group selected run: | docker run --rm --network none --env T3APP_TOOL_GROUPS=all \ --entrypoint t3app app-t3code:test tools status --json \ > image-inventory/tool-groups-runtime-linux_amd64.json jq -e '(.selector.selected | length) == (.groups | length)' image-inventory/tool-groups-runtime-linux_amd64.json jq -r '.groups[] | "\(.name)\t\(.state)"' image-inventory/tool-groups-runtime-linux_amd64.json - name: Record actual GitHub runner packages run: bun scripts/runner-inventory.ts image-inventory/runner - name: Upload image package and tool inventories uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: image-inventory-${{ github.sha }} path: image-inventory/ if-no-files-found: error retention-days: 90 # The candidate's arm64 image on a native arm64 host: the same replacement # test as amd64 above (payload locks, extraction, wrappers, version probes, # flock), without QEMU user-mode. Not on PRs: it gates publication only. arm64: if: github.event_name != 'pull_request' runs-on: ubuntu-24.04-arm needs: build steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: "1.4.4" - name: Pull the candidate by digest (arm64 variant) run: | test -n "${{ needs.build.outputs.digest }}" || { echo "::error::build pushed no digest"; exit 1; } docker pull --platform linux/arm64 "ghcr.io/tikoci/app-t3code@${{ needs.build.outputs.digest }}" - name: Test container replacement (native arm64) run: bun test test/container-replacement.test.ts env: APP_T3CODE_DOCKER_IMAGE: ghcr.io/tikoci/app-t3code@${{ needs.build.outputs.digest }} APP_T3CODE_T3VER: ${{ needs.build.outputs.ver }} - name: Record runtime tool-group status with every group selected (arm64) run: | docker run --rm --network none --env T3APP_TOOL_GROUPS=all \ --entrypoint t3app "ghcr.io/tikoci/app-t3code@${{ needs.build.outputs.digest }}" tools status --json \ > tool-groups-runtime-linux_arm64.json jq -e '(.selector.selected | length) == (.groups | length)' tool-groups-runtime-linux_arm64.json jq -r '.groups[] | "\(.name)\t\(.state)"' tool-groups-runtime-linux_arm64.json - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: tool-groups-runtime-arm64-${{ github.sha }} path: tool-groups-runtime-linux_arm64.json retention-days: 90 # Move the release tags onto the tested candidate, by digest (same # manifest list, no rebuild). main only: a dispatch on another branch stops # at the tested :sha- candidate (a pre-merge CHR test image). # Serialized, but GitHub's concurrency queue is not FIFO, so each run also # checks it is still main's head: an older run that finishes late skips # instead of moving the tags back (the newer run promotes its own build). promote: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 needs: [build, arm64] concurrency: group: promote-${{ github.ref }} cancel-in-progress: false steps: - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Check this commit is still main's head id: head env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | head="$(gh api "repos/$REPO/commits/main" --jq .sha)" if [ "$head" = "$GITHUB_SHA" ]; then echo "current=true" >> "$GITHUB_OUTPUT" else echo "current=false" >> "$GITHUB_OUTPUT" echo "::notice::main moved on to $head; not promoting $GITHUB_SHA over it" fi - name: Tag the candidate as the release if: steps.head.outputs.current == 'true' env: DIGEST: ${{ needs.build.outputs.digest }} BUILD: ${{ needs.build.outputs.build }} VER: ${{ needs.build.outputs.ver }} MINOR: ${{ needs.build.outputs.minor }} MAJOR: ${{ needs.build.outputs.major }} run: | repo=ghcr.io/tikoci/app-t3code test -n "$DIGEST" || { echo "::error::build pushed no digest"; exit 1; } docker buildx imagetools create \ -t "$repo:$BUILD" \ -t "$repo:$VER" \ -t "$repo:$MINOR" \ -t "$repo:$MAJOR" \ -t "$repo:latest" \ "$repo@$DIGEST" docker buildx imagetools inspect "$repo:$MINOR"