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
27 changes: 21 additions & 6 deletions .github/workflows/upstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,25 @@ jobs:
python3 scripts/update.py "$IMAGE" "$VERSION"
python3 scripts/meta.py "$IMAGE"

changes="$(git status --short)"
if [[ "$changes" != " M images/${IMAGE}/image.toml" ]]; then
echo "Unexpected update result:"
printf '%s\n' "$changes"
manifest="images/${IMAGE}/image.toml"
manifest_changed=false

while IFS= read -r changed_file; do
case "$changed_file" in
"$manifest")
manifest_changed=true
;;
README.md|CONTRIBUTING.md|docs/*.md|images/"$IMAGE"/*.md)
;;
*)
echo "Unexpected updated file: $changed_file" >&2
exit 1
;;
esac
done < <(git diff --name-only)

if [[ "$manifest_changed" != true ]]; then
echo "Manifest was not updated: $manifest" >&2
exit 1
fi

Expand Down Expand Up @@ -136,7 +151,7 @@ jobs:
git config user.email "${bot_id}+${bot}@users.noreply.github.com"
gh auth setup-git
git switch -c "$branch"
git add -- "images/${IMAGE}/image.toml"
git add --update
git commit -m "Update ${IMAGE} to ${VERSION}"

remote_sha="$(
Expand All @@ -156,7 +171,7 @@ jobs:
printf '%s\n\n' \
"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."
"The release asset digests were verified and versioned documentation was refreshed by scripts/update.py."
)"

gh pr create \
Expand Down
17 changes: 15 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@ avoids tools that already have a strong official or community image.

Use the update script with a stable upstream release version:

<!-- tiny-cli-images:version:age:start -->

```sh
python3 scripts/update.py age 1.3.0
```

<!-- tiny-cli-images:version:age:end -->

<!-- tiny-cli-images:version:xh:start -->

```sh
python3 scripts/update.py xh 0.26.2
```

Review the resulting `images/<tool>/image.toml` diff. Never use placeholder
or unverified checksums.
<!-- tiny-cli-images:version:xh:end -->

Review the resulting manifest and documentation diffs. The updater changes the
version and digests in `images/<tool>/image.toml`, then refreshes Markdown
blocks identified by invisible `tiny-cli-images:version` comments. Never use
placeholder or unverified checksums.

## Validation

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,28 @@ Minimal OCI images for useful command-line tools.

## age

<!-- tiny-cli-images:version:age:start -->

```sh
docker run --rm ghcr.io/unitmatrix/age:1.3.0 --version
```

<!-- tiny-cli-images:version:age:end -->

See [the age image documentation](images/age/README.md) for usage and release
details.

## xh

<!-- tiny-cli-images:version:xh:start -->

```sh
docker run --rm ghcr.io/unitmatrix/xh:0.26.2 \
https://example.com
```

<!-- tiny-cli-images:version:xh:end -->

See [the xh image documentation](images/xh/README.md) for usage and release
details.

Expand Down
10 changes: 8 additions & 2 deletions docs/PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ Actions.

`scripts/update.py <tool> <version>` 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/<tool>/image.toml`. Both current upstreams name release archives as
artifacts and valid SHA-256 digests, and updates the relevant values in
`images/<tool>/image.toml`. When the version changes, it also rewrites semantic
versions inside invisible `tiny-cli-images:version:<tool>` Markdown comment
blocks. Both current upstreams name release archives as
`<tool>-v<version>-<target>.tar.gz`, so no more general asset-template system is
needed yet.

Expand All @@ -137,12 +139,16 @@ Images are released independently from tags in this form:
<tool>/v<version>
```

<!-- tiny-cli-images:version:xh:start -->

For example, `xh/v0.26.2` publishes:

```text
ghcr.io/<owner>/xh:0.26.2
```

<!-- tiny-cli-images:version:xh:end -->

Only the full upstream version tag is published. Floating tags such as
`latest` and shortened version tags such as `0.26` are not published. Users can
also pin the image by its immutable OCI digest.
Expand Down
5 changes: 5 additions & 0 deletions images/age/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ static Linux binaries in a minimal `scratch` runtime.

Encrypt a file for a recipient:

<!-- tiny-cli-images:version:age:start -->

```sh
docker run --rm --interactive \
ghcr.io/unitmatrix/age:1.3.0 \
Expand Down Expand Up @@ -45,6 +47,9 @@ docker run --rm \
## Pinning

Each release publishes only its full upstream version tag, such as `1.3.0`.

<!-- tiny-cli-images:version:age:end -->

The image does not publish `latest` or shortened version tags. For immutable
deployments, use the digest shown by the GitHub Release and release workflow:

Expand Down
5 changes: 5 additions & 0 deletions images/xh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Linux musl binary in a minimal `scratch` runtime.

## Usage

<!-- tiny-cli-images:version:xh:start -->

```sh
docker run --rm ghcr.io/unitmatrix/xh:0.26.2 https://example.com
```
Expand All @@ -29,6 +31,9 @@ manager, and its entrypoint is `/xh`.
## Pinning

Each release publishes only its full upstream version tag, such as `0.26.2`.

<!-- tiny-cli-images:version:xh:end -->

The image does not publish `latest` or shortened version tags. For immutable
deployments, use the digest shown by the GitHub Release and release workflow:

Expand Down
95 changes: 92 additions & 3 deletions scripts/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ def read_manifest(path: Path, image: str) -> dict[str, Any]:
if config.get("name") != image:
raise UpdateError(f"{path} does not describe the {image} image")

version = config.get("version")
if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None:
raise UpdateError(f"invalid current version in {path}")

upstream = config.get("upstream")
if not isinstance(upstream, str) or not UPSTREAM_RE.fullmatch(upstream):
raise UpdateError(f"invalid upstream repository in {path}")
Expand Down Expand Up @@ -200,6 +204,83 @@ def render_manifest(path: Path, version: str, digests: dict[str, str]) -> str:
return "".join(output)


def documentation_paths(image: str) -> list[Path]:
paths = {
ROOT / "README.md",
ROOT / "CONTRIBUTING.md",
*(ROOT / "docs").rglob("*.md"),
*(ROOT / "images" / image).rglob("*.md"),
}
return sorted(paths)


def render_documentation(image: str, version: str) -> dict[Path, str]:
image_readme = ROOT / "images" / image / "README.md"
start_marker = f"<!-- tiny-cli-images:version:{image}:start -->"
end_marker = f"<!-- tiny-cli-images:version:{image}:end -->"
version_pattern = re.compile(r"(?<![0-9.])[0-9]+\.[0-9]+\.[0-9]+(?![0-9.])")
updates: dict[Path, str] = {}
marked_paths: set[Path] = set()

for path in documentation_paths(image):
try:
current = path.read_text(encoding="utf-8")
except OSError as error:
raise UpdateError(f"cannot read {path}: {error}") from error

if current.count(start_marker) != current.count(end_marker):
raise UpdateError(f"unbalanced {image} version markers in {path}")

position = 0
output: list[str] = []
blocks = 0

while True:
start = current.find(start_marker, position)
if start == -1:
output.append(current[position:])
break

content_start = start + len(start_marker)
end = current.find(end_marker, content_start)
nested_start = current.find(start_marker, content_start, end)

if end == -1 or nested_start != -1:
raise UpdateError(f"malformed {image} version markers in {path}")

content = current[content_start:end]
rendered_content, replacements = version_pattern.subn(version, content)
if replacements == 0:
raise UpdateError(
f"version marker for {image} contains no version in {path}"
)

output.extend(
(
current[position:content_start],
rendered_content,
end_marker,
)
)
position = end + len(end_marker)
blocks += 1

if blocks == 0:
continue

marked_paths.add(path)
rendered = "".join(output)
if rendered != current:
updates[path] = rendered

if image_readme not in marked_paths:
raise UpdateError(
f"{image_readme} contains no version marker for {image}"
)

return updates


def write_atomic(path: Path, content: str) -> None:
try:
mode = stat.S_IMODE(path.stat().st_mode)
Expand Down Expand Up @@ -245,13 +326,21 @@ def main() -> int:
digests = release_digests(release, config, args.version)
updated = render_manifest(manifest_path, args.version, digests)
current = manifest_path.read_text(encoding="utf-8")
documentation: dict[Path, str] = {}

if config["version"] != args.version:
documentation = render_documentation(args.image, args.version)

if updated == current:
if updated == current and not documentation:
print(f"{args.image} {args.version} is already pinned")
return 0

write_atomic(manifest_path, updated)
print(f"updated {manifest_path.relative_to(ROOT)} to {args.version}")
if updated != current:
write_atomic(manifest_path, updated)
print(f"updated {manifest_path.relative_to(ROOT)} to {args.version}")
for path, content in documentation.items():
write_atomic(path, content)
print(f"updated {path.relative_to(ROOT)} to {args.version}")
for architecture in ARCHITECTURES:
print(f"{architecture}: {digests[architecture]}")
return 0
Expand Down