diff --git a/.github/workflows/upstream.yml b/.github/workflows/upstream.yml index 9047a40..f7f4e03 100644 --- a/.github/workflows/upstream.yml +++ b/.github/workflows/upstream.yml @@ -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 @@ -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="$( @@ -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 \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5eedb9..440d228 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: + + ```sh python3 scripts/update.py age 1.3.0 +``` + + + + + +```sh python3 scripts/update.py xh 0.26.2 ``` -Review the resulting `images//image.toml` diff. Never use placeholder -or unverified checksums. + + +Review the resulting manifest and documentation diffs. The updater changes the +version and digests in `images//image.toml`, then refreshes Markdown +blocks identified by invisible `tiny-cli-images:version` comments. Never use +placeholder or unverified checksums. ## Validation diff --git a/README.md b/README.md index 334875e..9206260 100644 --- a/README.md +++ b/README.md @@ -24,20 +24,28 @@ Minimal OCI images for useful command-line tools. ## 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 docker run --rm ghcr.io/unitmatrix/xh:0.26.2 \ https://example.com ``` + + See [the xh image documentation](images/xh/README.md) for usage and release details. diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 4151a42..5650995 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -113,8 +113,10 @@ Actions. `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 +artifacts and valid SHA-256 digests, and updates the relevant values in +`images//image.toml`. When the version changes, it also rewrites semantic +versions inside invisible `tiny-cli-images:version:` Markdown comment +blocks. Both current upstreams name release archives as `-v-.tar.gz`, so no more general asset-template system is needed yet. @@ -137,12 +139,16 @@ Images are released independently from tags in this form: /v ``` + + For example, `xh/v0.26.2` publishes: ```text ghcr.io//xh:0.26.2 ``` + + 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. diff --git a/images/age/README.md b/images/age/README.md index b0b4e22..cdfb456 100644 --- a/images/age/README.md +++ b/images/age/README.md @@ -7,6 +7,8 @@ static Linux binaries in a minimal `scratch` runtime. Encrypt a file for a recipient: + + ```sh docker run --rm --interactive \ ghcr.io/unitmatrix/age:1.3.0 \ @@ -45,6 +47,9 @@ docker run --rm \ ## 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: diff --git a/images/xh/README.md b/images/xh/README.md index 3bbc386..d519254 100644 --- a/images/xh/README.md +++ b/images/xh/README.md @@ -5,6 +5,8 @@ Linux musl binary in a minimal `scratch` runtime. ## Usage + + ```sh docker run --rm ghcr.io/unitmatrix/xh:0.26.2 https://example.com ``` @@ -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`. + + + The image does not publish `latest` or shortened version tags. For immutable deployments, use the digest shown by the GitHub Release and release workflow: diff --git a/scripts/update.py b/scripts/update.py index e46efe9..9019e1a 100755 --- a/scripts/update.py +++ b/scripts/update.py @@ -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}") @@ -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"" + end_marker = f"" + version_pattern = re.compile(r"(? None: try: mode = stat.S_IMODE(path.stat().st_mode) @@ -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