From fd32f043414125f03a7d93d32097034659d2ef3f Mon Sep 17 00:00:00 2001 From: Aleksandr Cupacenko Date: Sat, 8 Aug 2026 20:41:37 +0300 Subject: [PATCH] Add support for age image and update workflows for multi-image handling --- .github/workflows/ci.yml | 19 ++++---- .github/workflows/upstream.yml | 52 +++++++++++++-------- CONTRIBUTING.md | 11 ++--- README.md | 10 +++++ docs/PROJECT.md | 51 +++++++++++++-------- images/age/Dockerfile | 72 +++++++++++++++++++++++++++++ images/age/README.md | 56 +++++++++++++++++++++++ images/age/image.toml | 16 +++++++ images/age/test.sh | 82 ++++++++++++++++++++++++++++++++++ scripts/update.py | 19 ++++---- 10 files changed, 330 insertions(+), 58 deletions(-) create mode 100644 images/age/Dockerfile create mode 100644 images/age/README.md create mode 100644 images/age/image.toml create mode 100755 images/age/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d588ed..e83503e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,16 @@ permissions: contents: read jobs: - xh: - name: xh / ${{ matrix.arch }} + image: + name: ${{ matrix.image }} / ${{ matrix.arch }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: + image: + - age + - xh arch: - amd64 - arm64 @@ -28,7 +31,7 @@ jobs: - name: Read metadata id: meta - run: python3 scripts/meta.py xh + run: python3 scripts/meta.py "${{ matrix.image }}" - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -40,17 +43,17 @@ jobs: uses: docker/build-push-action@v7 with: context: . - file: images/xh/Dockerfile + file: images/${{ matrix.image }}/Dockerfile platforms: linux/${{ matrix.arch }} load: true - tags: tiny/xh:test + tags: tiny/${{ matrix.image }}:test build-args: | VERSION=${{ steps.meta.outputs.version }} REVISION=${{ github.sha }} SHA256_AMD64=${{ steps.meta.outputs.sha_amd64 }} SHA256_ARM64=${{ steps.meta.outputs.sha_arm64 }} - cache-from: type=gha,scope=xh-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=xh-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.arch }} - name: Test - run: images/xh/test.sh tiny/xh:test + run: images/${{ matrix.image }}/test.sh tiny/${{ matrix.image }}:test diff --git a/.github/workflows/upstream.yml b/.github/workflows/upstream.yml index 3462969..9047a40 100644 --- a/.github/workflows/upstream.yml +++ b/.github/workflows/upstream.yml @@ -9,14 +9,22 @@ on: permissions: contents: read -concurrency: - group: upstream-xh - cancel-in-progress: false - jobs: - xh: + image: + name: ${{ matrix.image }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: + - age + - xh + + concurrency: + group: upstream-${{ matrix.image }} + cancel-in-progress: false + steps: - name: Checkout uses: actions/checkout@v7 @@ -25,23 +33,27 @@ jobs: - name: Current version id: meta - run: python3 scripts/meta.py xh + run: python3 scripts/meta.py "${{ matrix.image }}" - name: Check upstream id: upstream uses: actions/github-script@v9 env: CURRENT_VERSION: ${{ steps.meta.outputs.version }} + IMAGE: ${{ matrix.image }} + UPSTREAM_REPOSITORY: ${{ steps.meta.outputs.upstream }} with: script: | + const [owner, repo] = process.env.UPSTREAM_REPOSITORY.split("/"); const { data: release } = await github.rest.repos.getLatestRelease({ - owner: "ducaale", - repo: "xh" + owner, + repo }); const upstream = release.tag_name.replace(/^v/, ""); const current = process.env.CURRENT_VERSION; + const image = process.env.IMAGE; if (!/^\d+\.\d+\.\d+$/.test(upstream)) { throw new Error(`Unexpected upstream version: ${upstream}`); @@ -51,25 +63,28 @@ jobs: core.setOutput("update", String(upstream !== current)); if (upstream === current) { - console.log(`xh ${current} is current`); + console.log(`${image} ${current} is current`); return; } - console.log(`xh ${upstream} is available; current version is ${current}`); + console.log( + `${image} ${upstream} is available; current version is ${current}` + ); - name: Update manifest if: steps.upstream.outputs.update == 'true' env: GITHUB_TOKEN: ${{ github.token }} + IMAGE: ${{ matrix.image }} VERSION: ${{ steps.upstream.outputs.version }} run: | set -euo pipefail - python3 scripts/update.py xh "$VERSION" - python3 scripts/meta.py xh + python3 scripts/update.py "$IMAGE" "$VERSION" + python3 scripts/meta.py "$IMAGE" changes="$(git status --short)" - if [[ "$changes" != " M images/xh/image.toml" ]]; then + if [[ "$changes" != " M images/${IMAGE}/image.toml" ]]; then echo "Unexpected update result:" printf '%s\n' "$changes" exit 1 @@ -94,11 +109,12 @@ jobs: BASE_BRANCH: ${{ github.event.repository.default_branch }} CURRENT_VERSION: ${{ steps.meta.outputs.version }} GH_TOKEN: ${{ steps.app-token.outputs.token }} + IMAGE: ${{ matrix.image }} VERSION: ${{ steps.upstream.outputs.version }} run: | set -euo pipefail - branch="automation/xh-v${VERSION}" + branch="automation/${IMAGE}-v${VERSION}" existing_pr="$( gh pr list \ --repo "$GITHUB_REPOSITORY" \ @@ -120,8 +136,8 @@ jobs: git config user.email "${bot_id}+${bot}@users.noreply.github.com" gh auth setup-git git switch -c "$branch" - git add -- images/xh/image.toml - git commit -m "Update xh to ${VERSION}" + git add -- "images/${IMAGE}/image.toml" + git commit -m "Update ${IMAGE} to ${VERSION}" remote_sha="$( git ls-remote --heads origin "refs/heads/${branch}" | cut -f1 @@ -138,7 +154,7 @@ jobs: body="$( printf '%s\n\n' \ - "Updates xh from ${CURRENT_VERSION} to upstream release v${VERSION}." + "Updates ${IMAGE} from ${CURRENT_VERSION} to upstream release v${VERSION}." printf '%s\n' \ "The release asset digests were obtained and verified by scripts/update.py." )" @@ -147,5 +163,5 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --base "$BASE_BRANCH" \ --head "$branch" \ - --title "Update xh to ${VERSION}" \ + --title "Update ${IMAGE} to ${VERSION}" \ --body "$body" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecaa44c..d5eedb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,24 +11,25 @@ image-specific behavior under `images//` and reusable orchestration under Do not add another CLI image without prior discussion. The project deliberately avoids tools that already have a strong official or community image. -## Updating xh +## Updating an image Use the update script with a stable upstream release version: ```sh +python3 scripts/update.py age 1.3.0 python3 scripts/update.py xh 0.26.2 ``` -Review the resulting `images/xh/image.toml` diff. Never use placeholder or -unverified checksums. +Review the resulting `images//image.toml` diff. Never use placeholder +or unverified checksums. ## Validation Before submitting a pull request: -1. Run `python3 scripts/meta.py xh`. +1. Run `python3 scripts/meta.py `. 2. Build both `linux/amd64` and `linux/arm64` images. -3. Run `images/xh/test.sh ` for each architecture. +3. Run `images//test.sh ` for each architecture. 4. Confirm that no unrelated files or generated artifacts are included. Pull requests must not publish images. Production publication occurs only from diff --git a/README.md b/README.md index f9d15c5..334875e 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,18 @@ Minimal OCI images for useful command-line tools. | Image | amd64 | arm64 | Runtime | |---|---|---|---| +| age | ✓ | ✓ | scratch | | xh | ✓ | ✓ | scratch | +## age + +```sh +docker run --rm ghcr.io/unitmatrix/age:1.3.0 --version +``` + +See [the age image documentation](images/age/README.md) for usage and release +details. + ## xh ```sh diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 1f8abba..4151a42 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -5,10 +5,10 @@ Tiny Images publishes small, secure OCI images for useful command-line tools that do not already have a strong official or established community image. -The first supported tool is `xh`. Do not add another tool without an explicit -project decision. Common tools whose OCI distribution is already well covered, -including jq, yq, crane, oras, cosign, kubectl, Helm, and Git, are intentionally -out of scope. +The supported tools are `age` and `xh`. Do not add another tool without an +explicit project decision. Common tools whose OCI distribution is already well +covered, including jq, yq, crane, oras, cosign, kubectl, Helm, and Git, are +intentionally out of scope. ## Repository model @@ -36,6 +36,11 @@ duplication. ├── docs/ │ └── PROJECT.md ├── images/ +│ ├── age/ +│ │ ├── image.toml +│ │ ├── Dockerfile +│ │ ├── README.md +│ │ └── test.sh │ └── xh/ │ ├── image.toml │ ├── Dockerfile @@ -92,29 +97,37 @@ ENTRYPOINT=["/xh"] No shell or package manager belongs in the final image. +## age image + +Upstream is [FiloSottile/age](https://github.com/FiloSottile/age). The image +consumes the upstream static Linux release archives for `amd64` and `arm64`. +It includes the `age`, `age-keygen`, `age-inspect`, and +`age-plugin-batchpass` binaries shipped in those archives, with `/age` as the +entrypoint. The final image uses `scratch` and runs as UID/GID `65532:65532`. + ## Metadata and updates `scripts/meta.py` reads `images//image.toml` and exposes the name, version, upstream repository, platform targets, and checksums to GitHub Actions. -`scripts/update.py xh ` queries the GitHub Releases API, rejects -missing, draft, or prerelease releases, requires both expected musl artifacts -and valid SHA-256 digests, and updates only the relevant values in -`images/xh/image.toml`. - -The initial updater may contain xh-specific release knowledge. A complex asset -template system is intentionally deferred until more images reveal common -requirements. +`scripts/update.py ` queries the GitHub Releases API, rejects +missing, draft, or prerelease releases, requires both expected platform +artifacts and valid SHA-256 digests, and updates only the relevant values in +`images//image.toml`. Both current upstreams name release archives as +`-v-.tar.gz`, so no more general asset-template system is +needed yet. ## Continuous integration -Pull requests that affect images, scripts, or workflows build and exercise both -`linux/amd64` and `linux/arm64`, using QEMU where necessary. Builds verify -upstream checksums and run deterministic smoke tests without pushing images. +Pull requests that affect images, scripts, or workflows build and exercise each +image on both `linux/amd64` and `linux/arm64`, using QEMU where necessary. +Builds verify upstream checksums and run deterministic smoke tests without +pushing images. -Smoke tests cover at least `xh --version` and `xh --help`. Network integration -tests should remain separate where practical. +Smoke tests cover at least ` --version` and ` --help`, plus +deterministic image-specific behavior. Network integration tests should remain +separate where practical. ## Releases @@ -150,8 +163,8 @@ should ultimately be pinned to full commit SHAs. ## Upstream detection -The scheduled upstream workflow detects new stable xh releases but never -publishes them directly. The intended flow is: +The scheduled upstream workflow detects new stable releases for every supported +image but never publishes them directly. The intended flow is: ```text upstream release diff --git a/images/age/Dockerfile b/images/age/Dockerfile new file mode 100644 index 0000000..a83e9ed --- /dev/null +++ b/images/age/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 + +FROM alpine:3.24.1 AS fetch + +ARG VERSION +ARG TARGETARCH +ARG SHA256_AMD64 +ARG SHA256_ARM64 + +RUN apk add --no-cache \ + ca-certificates \ + curl + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) \ + target="linux-amd64"; \ + expected="${SHA256_AMD64}" \ + ;; \ + arm64) \ + target="linux-arm64"; \ + expected="${SHA256_ARM64}" \ + ;; \ + *) \ + echo "Unsupported architecture: ${TARGETARCH}" >&2; \ + exit 1 \ + ;; \ + esac; \ + archive="age-v${VERSION}-${target}.tar.gz"; \ + url="https://github.com/FiloSottile/age/releases/download/v${VERSION}/${archive}"; \ + curl --fail --silent --show-error --location \ + "${url}" \ + --output /tmp/age.tar.gz; \ + echo "${expected} /tmp/age.tar.gz" | sha256sum -c -; \ + mkdir -p /tmp/extract; \ + tar -xzf /tmp/age.tar.gz -C /tmp/extract; \ + root="/tmp/extract/age"; \ + mkdir -p \ + /rootfs/licenses/age \ + /rootfs/tmp \ + /rootfs/work; \ + for binary in age age-inspect age-keygen age-plugin-batchpass; do \ + cp "${root}/${binary}" "/rootfs/${binary}"; \ + chmod 0555 "/rootfs/${binary}"; \ + done; \ + cp "${root}/LICENSE" /rootfs/licenses/age/LICENSE; \ + chmod 1777 /rootfs/tmp; \ + chown 65532:65532 /rootfs/work + + +FROM scratch + +ARG VERSION +ARG REVISION + +COPY --from=fetch /rootfs / + +LABEL org.opencontainers.image.title="age" +LABEL org.opencontainers.image.description="Minimal OCI image for age" +LABEL org.opencontainers.image.version="${VERSION}" +LABEL org.opencontainers.image.revision="${REVISION}" +LABEL org.opencontainers.image.source="https://github.com/FiloSottile/age" +LABEL org.opencontainers.image.licenses="BSD-3-Clause" + +USER 65532:65532 + +ENV HOME=/tmp +ENV PATH=/ + +WORKDIR /work + +ENTRYPOINT ["/age"] diff --git a/images/age/README.md b/images/age/README.md new file mode 100644 index 0000000..b0b4e22 --- /dev/null +++ b/images/age/README.md @@ -0,0 +1,56 @@ +# age OCI image + +This image packages the upstream [FiloSottile/age](https://github.com/FiloSottile/age) +static Linux binaries in a minimal `scratch` runtime. + +## Usage + +Encrypt a file for a recipient: + +```sh +docker run --rm --interactive \ + ghcr.io/unitmatrix/age:1.3.0 \ + --encrypt \ + --recipient age1... \ + < document.txt \ + > document.txt.age +``` + +Decrypt with an identity mounted under `/work`: + +```sh +docker run --rm --interactive \ + --volume "$PWD:/work:ro" \ + ghcr.io/unitmatrix/age:1.3.0 \ + --decrypt \ + --identity /work/key.txt \ + < document.txt.age +``` + +The image runs as UID/GID `65532:65532`. Its entrypoint is `/age`; the +upstream `age-keygen`, `age-inspect`, and `age-plugin-batchpass` companion +binaries are also available at the filesystem root. For example: + +```sh +docker run --rm \ + --entrypoint /age-keygen \ + ghcr.io/unitmatrix/age:1.3.0 +``` + +## Platforms + +- `linux/amd64` +- `linux/arm64` + +## Pinning + +Each release publishes only its full upstream version tag, such as `1.3.0`. +The image does not publish `latest` or shortened version tags. For immutable +deployments, use the digest shown by the GitHub Release and release workflow: + +```text +ghcr.io/unitmatrix/age@sha256: +``` + +The upstream version and verified artifact checksums are committed in +[`image.toml`](image.toml). diff --git a/images/age/image.toml b/images/age/image.toml new file mode 100644 index 0000000..718be68 --- /dev/null +++ b/images/age/image.toml @@ -0,0 +1,16 @@ +name = "age" +description = "Simple, modern, and secure file encryption" +version = "1.3.0" + +upstream = "FiloSottile/age" +license = "BSD-3-Clause" + +architectures = ["amd64", "arm64"] + +[platform.amd64] +target = "linux-amd64" +sha256 = "2635f17e469b8c829055151dcbf77effe1fbefe182cb7b308aee5d0e5d923aae" + +[platform.arm64] +target = "linux-arm64" +sha256 = "5c78a4de6aa89e86045e4c05fec5773f3c419a5c209cd7cb381fec0f5e8fa936" diff --git a/images/age/test.sh b/images/age/test.sh new file mode 100755 index 0000000..04ed94e --- /dev/null +++ b/images/age/test.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE="${1:?image required}" + +echo "Testing ${IMAGE}" + +test "$(docker image inspect --format '{{.Config.User}}' "${IMAGE}")" = "65532:65532" +test "$(docker image inspect --format '{{.Config.WorkingDir}}' "${IMAGE}")" = "/work" +test "$(docker image inspect --format '{{json .Config.Entrypoint}}' "${IMAGE}")" = '["/age"]' + +docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${IMAGE}" \ + | grep -Fxq 'HOME=/tmp' +docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${IMAGE}" \ + | grep -Fxq 'PATH=/' + +docker run --rm "${IMAGE}" --version +docker run --rm "${IMAGE}" --help >/dev/null +docker run --rm --entrypoint /age-keygen "${IMAGE}" --version +docker run --rm --entrypoint /age-inspect "${IMAGE}" --version + +temporary="$(mktemp -d)" +container="" + +cleanup() { + if [[ -n "${container}" ]]; then + docker rm --force "${container}" >/dev/null 2>&1 || true + fi + rm -rf "${temporary}" +} + +trap cleanup EXIT + +docker run --rm --entrypoint /age-keygen "${IMAGE}" \ + >"${temporary}/key.txt" +recipient="$(sed -n 's/^# public key: //p' "${temporary}/key.txt")" +test -n "${recipient}" + +printf '%s' 'tiny age round trip' \ + | docker run --rm --interactive \ + "${IMAGE}" \ + --encrypt \ + --recipient "${recipient}" \ + >"${temporary}/encrypted.age" + +chmod 0755 "${temporary}" +chmod 0644 "${temporary}/key.txt" "${temporary}/encrypted.age" + +output="$( + docker run --rm --interactive \ + --volume "${temporary}:/work:ro" \ + "${IMAGE}" \ + --decrypt \ + --identity /work/key.txt \ + /work/encrypted.age +)" + +test "${output}" = "tiny age round trip" + +container="$(docker create "${IMAGE}")" +docker export --output "${temporary}/rootfs.tar" "${container}" +contents="$(tar -tf "${temporary}/rootfs.tar" | sed -e 's#^\./##' -e 's#/$##')" + +for expected in \ + age \ + age-inspect \ + age-keygen \ + age-plugin-batchpass \ + licenses/age/LICENSE \ + tmp \ + work +do + grep -Fxq "${expected}" <<<"${contents}" +done + +if grep -Eq '(^|/)bin/(ba|da|a|z)?sh$' <<<"${contents}"; then + echo "unexpected shell found in image" >&2 + exit 1 +fi + +echo "OK" diff --git a/scripts/update.py b/scripts/update.py index 4f2fec6..e46efe9 100755 --- a/scripts/update.py +++ b/scripts/update.py @@ -17,9 +17,11 @@ ROOT = Path(__file__).resolve().parents[1] ARCHITECTURES = ("amd64", "arm64") +IMAGE_RE = re.compile(r"[a-z0-9][a-z0-9-]*") VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") SHA256_RE = re.compile(r"sha256:([0-9a-f]{64})") UPSTREAM_RE = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") +TARGET_RE = re.compile(r"[A-Za-z0-9_.-]+") SECTION_RE = re.compile(r"^\s*\[([^]]+)]\s*(?:#.*)?(?:\r?\n)?$") ASSIGNMENT_RE = re.compile( r'^(\s*)([A-Za-z0-9_]+)(\s*=\s*)"([^"\r\n]*)"([^\r\n]*)(\r?\n)?$' @@ -30,15 +32,15 @@ class UpdateError(Exception): """An expected release or manifest invariant was not satisfied.""" -def read_manifest(path: Path) -> dict[str, Any]: +def read_manifest(path: Path, image: str) -> dict[str, Any]: try: with path.open("rb") as manifest_file: config = tomllib.load(manifest_file) except (OSError, tomllib.TOMLDecodeError) as error: raise UpdateError(f"cannot read {path}: {error}") from error - if config.get("name") != "xh": - raise UpdateError(f"{path} does not describe the xh image") + if config.get("name") != image: + raise UpdateError(f"{path} does not describe the {image} image") upstream = config.get("upstream") if not isinstance(upstream, str) or not UPSTREAM_RE.fullmatch(upstream): @@ -56,7 +58,8 @@ def read_manifest(path: Path) -> dict[str, Any]: for architecture in ARCHITECTURES: platform = platforms.get(architecture) - if not isinstance(platform, dict) or not isinstance(platform.get("target"), str): + target = platform.get("target") if isinstance(platform, dict) else None + if not isinstance(target, str) or TARGET_RE.fullmatch(target) is None: raise UpdateError(f"missing target for platform.{architecture} in {path}") return config @@ -223,7 +226,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Update a pinned CLI image from a stable upstream release." ) - parser.add_argument("image", help="image name (currently only xh)") + parser.add_argument("image", help="image name") parser.add_argument("version", help="stable upstream version, without a v prefix") return parser.parse_args() @@ -231,13 +234,13 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - if args.image != "xh": - raise UpdateError(f"unsupported image: {args.image}") + if IMAGE_RE.fullmatch(args.image) is None: + raise UpdateError(f"invalid image name: {args.image}") if VERSION_RE.fullmatch(args.version) is None: raise UpdateError("version must use the form X.Y.Z without a v prefix") manifest_path = ROOT / "images" / args.image / "image.toml" - config = read_manifest(manifest_path) + config = read_manifest(manifest_path, args.image) release = fetch_release(config["upstream"], args.version) digests = release_digests(release, config, args.version) updated = render_manifest(manifest_path, args.version, digests)