Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .github/workflows/dockercli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: docker-cli

# Builds the Windows arm64 docker.exe that upstream does not publish (#450).
#
# download.docker.com's static tree has exactly one directory, x86_64/, and
# docker/cli publishes no release assets at all. So `skrog cli install` on
# Windows on ARM laid down compose, buildx and the credential helper -- all of
# which DO have arm64 builds -- and skipped the one command anybody types.
#
# amd64 is NOT built here. It stays Docker's own published binary, because
# those bytes are verifiable by anyone against the digest pinned in
# internal/dockercli/manifest.json without reference to Skrog, and building
# them ourselves would put every user behind our build instead of only the
# arm64 users who have no alternative. third_party/docker-cli/build.sh carries
# the full reasoning, including why code signing is not part of it.
#
# The asymmetry ends by itself: when upstream publishes a Windows arm64
# docker.exe, the manifest points at it and this workflow is deleted.

on:
push:
branches: [main]
paths: ['third_party/docker-cli/**', '.github/workflows/dockercli.yml']
pull_request:
paths: ['third_party/docker-cli/**', '.github/workflows/dockercli.yml']
# A CLI release (tag: dockercli-vX.Y.Z) rebuilds and attaches its assets.
# App and rootfs releases are other namespaces and skip the publish job.
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: build docker.exe (windows/arm64)
# Cross-compiled, and that is not a compromise here the way it would be
# for the engine: docker/cli is pure Go with CGO off and everything
# vendored, so an x86-64 runner produces the same binary an arm64 one
# would. The engine needs native runners because it is C-linked and
# boot-tested; this is `go build` with GOARCH set.
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

- name: shellcheck
run: shellcheck -x -P SCRIPTDIR third_party/docker-cli/*.sh

- name: build
run: ./third_party/docker-cli/build.sh "$GITHUB_WORKSPACE/out"

# build.sh already fails on a non-ARM64 PE header. This repeats the
# check outside the script so a bug in the script's own parsing cannot
# pass its own test -- the binary is going to machines nobody here has,
# and an x86-64 exe named arm64 would only be discovered by the one
# person least able to diagnose it.
- name: it really is an ARM64 binary
run: |
exe=$(ls out/docker-*-windows-arm64.exe)
machine=$(python3 - "$exe" <<'PY'
import struct, sys
with open(sys.argv[1], 'rb') as f:
f.seek(0x3c)
off = struct.unpack('<I', f.read(4))[0]
f.seek(off)
assert f.read(4) == b'PE\0\0', 'not a PE file'
print('%04x' % struct.unpack('<H', f.read(2))[0])
PY
)
echo "PE machine type: 0x$machine"
[ "$machine" = "aa64" ] || { echo "expected 0xaa64 (ARM64)" >&2; exit 1; }

- name: report
run: |
{
echo '### docker CLI (windows/arm64)'
echo ''
echo '```'
cat out/*.commit.txt
cat out/*.sha256
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@v7
with:
name: docker-cli-windows-arm64
path: out/
retention-days: 14

publish:
name: attach to release
needs: build
# Only docker CLI releases. Without this check every app and rootfs
# release would also get a docker.exe attached, because the release event
# does not distinguish what is being released.
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'dockercli-')
runs-on: ubuntu-latest
permissions:
contents: write
# See release.yml: id-token for the OIDC exchange cosign and the
# attestation both need, attestations to record the provenance (#147).
id-token: write
attestations: write
steps:
- uses: actions/download-artifact@v8
with:
name: docker-cli-windows-arm64
path: out

# This is the part that makes a self-built CLI defensible. The amd64
# binary is Docker's and anyone can re-download it to check the pinned
# digest; this one exists nowhere else, so its provenance has to come
# from here -- tied to this workflow, this repository and this commit.
- uses: actions/attest-build-provenance@v4
with:
subject-path: 'out/docker-*-windows-arm64.exe'

- uses: sigstore/cosign-installer@v3
- name: sign the checksum
run: |
shopt -s nullglob
sumfiles=(out/*.sha256)
if [ ${#sumfiles[@]} -eq 0 ]; then
echo "no .sha256 to sign; the build produced nothing" >&2
exit 1
fi
for sums in "${sumfiles[@]}"; do
cosign sign-blob --yes --bundle "$sums.cosign.bundle" "$sums"
cosign verify-blob \
--bundle "$sums.cosign.bundle" \
--certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$sums"
done

# --repo is required: this job downloads artifacts without checking out
# the code, so gh has no git remote to infer the repository from.
- name: upload release assets
# TAG/REPO via env, not spliced into the command (#87): this job holds
# contents: write.
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
run: gh release upload "$TAG" out/* --clobber --repo "$REPO"
16 changes: 12 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ name: release

# Builds and attaches the skrog binaries when an app release is published.
#
# Rootfs releases are a separate namespace (tag prefix rootfs-) handled by
# rootfs.yml; this workflow skips them so the two never cross-contaminate.
# There are three tag namespaces in this repository -- vX.Y.Z (the app),
# rootfs-vX.Y.Z-N (the engine image) and dockercli-vX.Y.Z (the Windows arm64
# docker CLI). Each has its own workflow, and each must ignore the others'
# tags or a release ends up carrying assets from a different product.
#
# The guards below are written as "is this an app version" rather than "is
# this not a rootfs", which is how they used to read. That was an open list:
# adding dockercli- would silently have attached skrog.exe to a docker CLI
# release, because nothing said no. A closed test cannot be broken by the
# next namespace.
#
# Artifacts are signed with cosign keyless (Sigstore, GitHub OIDC) and carry
# SLSA build provenance (#147), so a download can be tied to this workflow, this
Expand Down Expand Up @@ -43,7 +51,7 @@ permissions:
jobs:
build:
name: build binaries
if: github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'rootfs-')
if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
Expand Down Expand Up @@ -281,7 +289,7 @@ jobs:
publish:
name: attach to release
needs: build
if: github.event_name == 'release' && !startsWith(github.event.release.tag_name, 'rootfs-')
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v')
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/rootfs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ jobs:
# Same invocation as scripts/lint.ps1 and the pre-commit hook: -x follows
# `source=` directives, -P SCRIPTDIR resolves them next to each script
# (versions.env sits beside build.sh, not in the working directory).
# Every .sh in the tree, matching what scripts/lint.ps1 does locally.
# A hardcoded list here means a new script is unchecked in CI until
# someone remembers this line, and the one person who would notice is
# the one who did not add it.
- name: shellcheck
run: shellcheck -x -P SCRIPTDIR guest/rootfs/*.sh spike/a/*.sh
run: shellcheck -x -P SCRIPTDIR $(git ls-files '*.sh')

build:
# One rootfs per architecture, each compiled NATIVELY (#388).
Expand All @@ -37,6 +41,13 @@ jobs:
# GitHub's ubuntu-24.04-arm runners are real arm64 and free for public
# repositories, so there is no reason to emulate.
name: build rootfs (${{ matrix.arch }})
# Not on someone else's release. This job runs ~6 minutes per
# architecture, and the release event does not say what is being
# released -- so without this it rebuilt the engine twice for every app
# tag, and would now do it for every docker CLI tag too. The publish job
# was always guarded, so nothing wrong was ever uploaded; this is waste,
# not a correctness fix.
if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'rootfs-')
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
strategy:
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@ useful than saying where the real one is.

### Added

- **Windows on ARM: a `docker` CLI, built here because upstream ships none**
([#450](https://github.com/wslkit/skrog/issues/450)). `skrog cli install` on
arm64 laid down compose, buildx and the credential helper — all of which
have real arm64 builds — and skipped the one command anybody types.
`download.docker.com`'s static tree has exactly one directory, `x86_64/`,
and `docker/cli` attaches no release assets.

So Skrog builds it: `docker/cli` at a commit-pinned tag, in the same pinned
Go toolchain as the engine, with SLSA provenance and a cosign-signed
checksum, published as a `dockercli-v*` release of this repository. The
build asserts the PE machine type is really `0xaa64` before anything is
attached — a cross-compile that quietly produced an x86-64 binary would
pass every other check and fail only on a user's ARM machine.

**amd64 still comes from Docker**, deliberately. Those bytes are verifiable
by anyone against the pinned digest with no reference to Skrog, and building
them too would put every user behind our build rather than only the arm64
users who have no alternative. It is not about code signing: Docker's
published Windows CLI is not Authenticode-signed either. `docs/docker-cli.md`
has the table and the reasoning, and the asymmetry ends when upstream ships
an arm64 build. This amends the CLI-repackaging line in PLAN §09.

The arm64 binary reports its version and upstream commit, and deliberately
does **not** claim `Docker Engine - Community` — that is Docker's build
string for Docker's builds.

- **Windows on ARM: `skrog install` works**
([#388](https://github.com/wslkit/skrog/issues/388)). `skrog.exe` has
shipped an arm64 build for months while the engine was amd64-only, so
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ The `wsl` package hides every `wsl.exe` invocation behind an interface so unit t

| Risk | Severity | Mitigation |
|---|---|---|
| Docker trademark | launch-blocking | No "docker" or whale in name/logo/package IDs (hence Skrog); nominative references only; ship CLI binaries unmodified under Apache-2.0; engine binaries built from source in CI, not repackaged from Docker's distribution channels. Legal sanity check before the repo goes public. |
| Docker trademark | launch-blocking | No "docker" or whale in name/logo/package IDs (hence Skrog); nominative references only; ship CLI binaries unmodified under Apache-2.0; engine binaries built from source in CI, not repackaged from Docker's distribution channels. Legal sanity check before the repo goes public. **Amended 2026-09-21 (#450):** one CLI binary is now *built* rather than shipped unmodified — the Windows arm64 `docker.exe`, because upstream publishes none and `skrog cli install` on ARM was skipping the one command anybody types. Building from Apache-2.0 source in our own CI is the *engine* treatment, which this same line already sanctions, and it is the opposite of the repackaging the trademark concern is about: nothing is redistributed from Docker's channels, and the binary carries no Docker build string (`PlatformName` is deliberately left unset). amd64 stays Docker's own published bytes. The asymmetry is documented in `docs/docker-cli.md` and ends when upstream ships an arm64 build. |
| Microsoft's wslc absorbs the niche | strategic | wslc (GA expected fall 2026) mimics the CLI and runs a real Moby engine, but exposes no Docker endpoint — dockerd listens on a guest unix socket with no `-H`, and nothing reaches it from Windows — so compose, Testcontainers and socket-mounting tools cannot use it, and the endpoint request (microsoft/WSL#40976) sits unanswered with no milestone. Note the gap is a missing listener, not a missing engine: Microsoft narrows the surface deliberately, because their registry allowlist and plugin hooks are enforced in `wslcsession.exe` *in front of* dockerd. Ship fast and own the "real Docker API" framing. The pre-thought pivot is now measured rather than hypothetical (#316): Skrog's glue layer (doctor, service lifecycle, fleet policy, path-translating pipe) can sit on top of a wslc session — reached today over vsock (#320), or over Microsoft's own endpoint if #40976 ships. |
| ~~Session-0 / no-login WSL~~ — **settled, and negative** | resolved | Spike B (#3) measured it: WSL2 will not create its utility VM outside an interactive session. LocalSystem is refused by name; a dedicated service account fails in HCS with `ERROR_LOGON_TYPE_NOT_GRANTED` holding Service + Batch + Interactive rights and local admin. No longer a risk but a documented constraint (§03, §06). Cost of finding out: one day, in week one, exactly as intended. |
| Auto-logon forbidden by policy | medium | Unattended runners need a logged-on session, and auto-logon stores a password in LSA secrets. Fleets that ban it cannot run Skrog unattended — nor any WSL-based engine, so no competitor wins those machines either. Mitigation: state it in the docs, name it in `doctor`, and never automate credential storage on the user's behalf. Theoretical escape (out of scope): run the engine in a plain Hyper-V VM instead of WSL. |
Expand Down
62 changes: 54 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
# Releasing

Two independent release streams, deliberately kept apart:
Three independent release streams, deliberately kept apart:

| stream | tag | what it publishes | workflow |
|---|---|---|---|
| **rootfs** | `rootfs-vX.Y.Z` | one engine tarball **per architecture**, each with its `.sha256` and an SPDX SBOM | `rootfs.yml` |
| **rootfs** | `rootfs-vX.Y.Z-N` | one engine tarball **per architecture**, each with its `.sha256` and an SPDX SBOM | `rootfs.yml` |
| **app** | `vX.Y.Z` | `skrog.exe` for amd64 and arm64, zipped, plus `SHA256SUMS` | `release.yml` |
| **docker CLI** | `dockercli-vX.Y.Z` | the Windows **arm64** `docker.exe` upstream does not publish (#450), with its `.sha256` | `dockercli.yml` |

They are separate because the engine and the app version independently: an engine
security patch should not require an app release, and vice versa (PLAN §04,
`skrog engine upgrade`). Both workflows check the tag prefix, so a rootfs
release never gets app binaries attached and an app release never gets a rootfs.
They are separate because they version independently: an engine security patch
should not require an app release, a docker CLI bump is upstream's schedule
rather than ours, and vice versa (PLAN §04, `skrog engine upgrade`).

**Each workflow tests for its own prefix, positively.** They used to exclude
each other by name — "not `rootfs-`" — which is an open list: adding a third
namespace would silently have attached `skrog.exe` to a docker CLI release
because nothing said no. The guards now ask "is this mine", which the next
namespace cannot break.

Deciding *what* to bump — which moby tag, whether containerd/runc/buildkit move
with it, where each checksum comes from — is a separate job from cutting the
release. That is [docs/bumping-upstream.md](docs/bumping-upstream.md); this file
covers publishing once the versions are settled.

## `git describe` sees both streams
## `git describe` sees every stream

The two tag namespaces share one repository, and rootfs releases are cut far
The tag namespaces share one repository, and rootfs releases are cut far
more often than app releases — so `git describe --tags` usually resolves to a
**rootfs** tag, not an app version:

Expand Down Expand Up @@ -124,6 +130,46 @@ Until step 2 lands, `skrog install` refuses with a message telling the user to
pass `--rootfs-url` and `--rootfs-sha256` explicitly. That is intentional: there
is no code path that installs an unverified rootfs.

## Cutting a docker CLI release

Only needed on Windows arm64, and only until upstream publishes one
([#450](https://github.com/wslkit/skrog/issues/450)). The shape mirrors the
rootfs stream, one step shorter because there is a single artifact.

1. **Pin the version.** `third_party/docker-cli/versions.env` carries
`DOCKER_CLI_VERSION`, `DOCKER_CLI_TAG` and `DOCKER_CLI_SHA` — the tag's
dereferenced commit, which `build.sh` refuses to build without matching:

```
git ls-remote https://github.com/docker/cli 'refs/tags/vX.Y.Z^{}'
```

It must equal the `docker` component's `version` in
`internal/dockercli/manifest.json`; a test asserts that, because the same
binary is installed on both architectures and two versions is a support
trap.

2. **Cut the release.** Tag `dockercli-v<version>` — matching
`DOCKER_CLI_VERSION`, no revision suffix, because the artifact is one
binary from one upstream commit and there is nothing else in it to revise.
Publishing triggers `dockercli.yml`, which builds, asserts the PE header is
really ARM64, attests, signs the checksum and attaches both files.

**`--latest=false`, as always:**

```
gh release edit dockercli-vX.Y.Z --prerelease=false --latest=false
```

3. **Point the manifest at it.** Put the published `.sha256` and URL into
`internal/dockercli/manifest.json` under
`components[docker].arch.arm64`. Merge as a normal PR.

amd64 is **not** touched — it stays Docker's own published zip. That
asymmetry is deliberate and argued in `docs/docker-cli.md`; a test fails if
the amd64 URL ever stops pointing at `download.docker.com`, so changing it
is a decision rather than a slip.

## Dry runs

Both workflows can be exercised without publishing:
Expand Down
Loading
Loading