From 7ac2e8fb203c977581757eede65e2f15a19479c9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:07:40 -0700 Subject: [PATCH 01/13] feat: add version resolution script with tests Resolves user-provided version input (latest, 1.2.3, v1.2.3) into a concrete release tag. Uses CURL_CMD env var for testability. --- scripts/resolve-version.sh | 24 ++++++++++++++++++++++++ tests/resolve-version.bats | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100755 scripts/resolve-version.sh create mode 100644 tests/resolve-version.bats diff --git a/scripts/resolve-version.sh b/scripts/resolve-version.sh new file mode 100755 index 0000000..932a226 --- /dev/null +++ b/scripts/resolve-version.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-}" +REPO="harmont-dev/harmont-cli" +CURL_CMD="${CURL_CMD:-curl}" + +if [[ -z "$VERSION" ]]; then + echo "::error::version input is required" >&2 + exit 1 +fi + +if [[ "$VERSION" == "latest" ]]; then + tag=$($CURL_CMD -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) + if [[ -z "$tag" ]]; then + echo "::error::failed to resolve latest version from GitHub" >&2 + exit 1 + fi + echo "$tag" +elif [[ "$VERSION" =~ ^v ]]; then + echo "$VERSION" +else + echo "v${VERSION}" +fi diff --git a/tests/resolve-version.bats b/tests/resolve-version.bats new file mode 100644 index 0000000..3d6fa04 --- /dev/null +++ b/tests/resolve-version.bats @@ -0,0 +1,35 @@ +#!/usr/bin/env bats + +setup() { + export RESOLVE="$BATS_TEST_DIRNAME/../scripts/resolve-version.sh" +} + +@test "passes through explicit semver unchanged" { + run bash "$RESOLVE" "1.2.3" + [ "$status" -eq 0 ] + [ "$output" = "v1.2.3" ] +} + +@test "passes through v-prefixed version unchanged" { + run bash "$RESOLVE" "v1.2.3" + [ "$status" -eq 0 ] + [ "$output" = "v1.2.3" ] +} + +@test "fails on empty input" { + run bash "$RESOLVE" "" + [ "$status" -ne 0 ] +} + +@test "latest resolves via GitHub API" { + # Mock: override curl with a function that returns a fake tag + mock_curl() { + echo '{"tag_name": "v0.5.0"}' + } + export -f mock_curl + + # Use CURL_CMD override for testability + CURL_CMD=mock_curl run bash "$RESOLVE" "latest" + [ "$status" -eq 0 ] + [ "$output" = "v0.5.0" ] +} From 5f65496bf910103e1bdc11a068ab4f670265756d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:09:37 -0700 Subject: [PATCH 02/13] feat: add hm install script with platform detection and fallback chain --- scripts/install-hm.sh | 112 ++++++++++++++++++++++++++++++++++++++++++ tests/install-hm.bats | 45 +++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100755 scripts/install-hm.sh create mode 100644 tests/install-hm.bats diff --git a/scripts/install-hm.sh b/scripts/install-hm.sh new file mode 100755 index 0000000..11148c7 --- /dev/null +++ b/scripts/install-hm.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="harmont-dev/harmont-cli" + +detect_platform() { + local os="${OS:-$(uname -s)}" + local arch="${ARCH:-$(uname -m)}" + + local os_part arch_part + + case "$os" in + Linux) os_part="unknown-linux-gnu" ;; + Darwin) os_part="apple-darwin" ;; + *) + echo "::error::Unsupported OS: $os" >&2 + return 1 + ;; + esac + + case "$arch" in + x86_64) arch_part="x86_64" ;; + aarch64|arm64) arch_part="aarch64" ;; + *) + echo "::error::Unsupported architecture: $arch" >&2 + return 1 + ;; + esac + + echo "${arch_part}-${os_part}" +} + +register_path() { + local dir="$1" + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$dir" >> "$GITHUB_PATH" + fi + export PATH="$dir:$PATH" +} + +try_github_release() { + local platform="$1" + local url="https://github.com/${REPO}/releases/download/${VERSION}/hm-${platform}" + + echo "::group::Downloading hm ${VERSION} for ${platform}" + mkdir -p "$INSTALL_DIR" + + if curl -fsSL --retry 3 -o "${INSTALL_DIR}/hm" "$url"; then + chmod +x "${INSTALL_DIR}/hm" + echo "::endgroup::" + return 0 + fi + + echo "::endgroup::" + echo "::warning::No prebuilt binary at ${url}, trying fallback" + return 1 +} + +try_cargo_binstall() { + if ! command -v cargo-binstall &>/dev/null; then + return 1 + fi + + echo "::group::Installing hm via cargo-binstall" + local tag_version="${VERSION#v}" + cargo binstall --no-confirm --version "$tag_version" harmont-cli + echo "::endgroup::" +} + +try_cargo_install() { + if ! command -v cargo &>/dev/null; then + echo "::error::No prebuilt binary found and cargo is not available" >&2 + return 1 + fi + + echo "::group::Installing hm via cargo install (this may take a few minutes)" + local tag_version="${VERSION#v}" + cargo install --version "$tag_version" harmont-cli + echo "::endgroup::" +} + +main() { + local VERSION="${1:?version tag required (e.g. v0.5.0)}" + local INSTALL_DIR="${2:-${RUNNER_TOOL_CACHE:-/tmp}/hm/bin}" + + local platform + platform="$(detect_platform)" + + if try_github_release "$platform"; then + register_path "$INSTALL_DIR" + elif try_cargo_binstall; then + true + elif try_cargo_install; then + true + else + echo "::error::All installation methods failed" >&2 + exit 1 + fi + + echo "::group::Verify installation" + hm --version + echo "::endgroup::" + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "hm-version=$(hm --version)" >> "$GITHUB_OUTPUT" + fi +} + +# Allow sourcing for tests without running main +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/tests/install-hm.bats b/tests/install-hm.bats new file mode 100644 index 0000000..9617215 --- /dev/null +++ b/tests/install-hm.bats @@ -0,0 +1,45 @@ +#!/usr/bin/env bats + +setup() { + export INSTALL="$BATS_TEST_DIRNAME/../scripts/install-hm.sh" + export TMPDIR + TMPDIR="$(mktemp -d)" + export GITHUB_PATH="$TMPDIR/github_path" + touch "$GITHUB_PATH" + export GITHUB_OUTPUT="$TMPDIR/github_output" + touch "$GITHUB_OUTPUT" +} + +teardown() { + rm -rf "$TMPDIR" +} + +@test "detects linux x86_64 platform" { + source "$INSTALL" + OS="Linux" ARCH="x86_64" run detect_platform + [ "$status" -eq 0 ] + [[ "$output" == *"x86_64"* ]] + [[ "$output" == *"linux"* ]] +} + +@test "detects darwin arm64 platform" { + source "$INSTALL" + OS="Darwin" ARCH="arm64" run detect_platform + [ "$status" -eq 0 ] + [[ "$output" == *"aarch64"* ]] || [[ "$output" == *"arm64"* ]] + [[ "$output" == *"darwin"* ]] || [[ "$output" == *"apple"* ]] +} + +@test "fails on unsupported platform" { + source "$INSTALL" + OS="Windows_NT" ARCH="x86_64" run detect_platform + [ "$status" -ne 0 ] +} + +@test "adds install dir to GITHUB_PATH" { + source "$INSTALL" + INSTALL_DIR="$TMPDIR/bin" + mkdir -p "$INSTALL_DIR" + register_path "$INSTALL_DIR" + grep -q "$INSTALL_DIR" "$GITHUB_PATH" +} From b158b0eabb44ab734c61a14392bf1a71293b549e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:11:23 -0700 Subject: [PATCH 03/13] feat: add setup sub-action for hm CLI installation --- setup/action.yml | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 setup/action.yml diff --git a/setup/action.yml b/setup/action.yml new file mode 100644 index 0000000..4aba4a4 --- /dev/null +++ b/setup/action.yml @@ -0,0 +1,63 @@ +name: Setup Harmont +description: Install the hm CLI and optionally the harmont Python DSL + +inputs: + version: + description: > + hm version to install. Use 'latest' for the most recent release, + or pin to a specific version (e.g. '0.5.0' or 'v0.5.0'). + required: false + default: latest + install-python-dsl: + description: > + Install the harmont Python DSL package. Set to 'true' if your + pipelines are written in Python (.harmont/*.py). + required: false + default: 'false' + python-dsl-version: + description: > + Version of the harmont PyPI package. Only used when + install-python-dsl is 'true'. + required: false + default: '' + token: + description: > + GitHub token for downloading release assets and API rate limits. + Defaults to the automatic GITHUB_TOKEN. + required: false + default: ${{ github.token }} + +outputs: + hm-version: + description: Installed hm version string + value: ${{ steps.install.outputs.hm-version }} + +runs: + using: composite + steps: + - name: Resolve version + id: version + shell: bash + run: | + tag=$("${{ github.action_path }}/../scripts/resolve-version.sh" "${{ inputs.version }}") + echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ inputs.token }} + + - name: Install hm + id: install + shell: bash + run: | + "${{ github.action_path }}/../scripts/install-hm.sh" "${{ steps.version.outputs.tag }}" + + - name: Install Python DSL + if: inputs.install-python-dsl == 'true' + shell: bash + run: | + pip_spec="harmont" + if [[ -n "${{ inputs.python-dsl-version }}" ]]; then + pip_spec="harmont==${{ inputs.python-dsl-version }}" + fi + echo "::group::Installing harmont Python DSL" + pip install "$pip_spec" + echo "::endgroup::" From 3eb33a19edd9b28bc06fdf7e6250c2a60c155e09 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:11:30 -0700 Subject: [PATCH 04/13] feat: add cache-restore sub-action wrapping hm cache restore --- cache-restore/action.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 cache-restore/action.yml diff --git a/cache-restore/action.yml b/cache-restore/action.yml new file mode 100644 index 0000000..28f89fa --- /dev/null +++ b/cache-restore/action.yml @@ -0,0 +1,42 @@ +name: Restore Harmont Cache +description: > + Restore Docker image cache for harmont pipelines. + Uses content-addressed GHA cache with prefix matching + to always get the most recent cache entry. + +inputs: + cache-key-prefix: + description: > + Prefix for the cache key. Bump this to force a full cache rebuild. + required: false + default: harmont-v1 + working-directory: + description: Directory where .harmont-cache/ lives (usually repo root) + required: false + default: . + +outputs: + cache-hit: + description: Whether a cache entry was restored + value: ${{ steps.restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Restore GHA cache + id: restore + uses: actions/cache/restore@v4 + with: + path: ${{ inputs.working-directory }}/.harmont-cache/ + key: ${{ inputs.cache-key-prefix }}-will-never-match + restore-keys: | + ${{ inputs.cache-key-prefix }}- + + - name: Load Docker images from cache + if: steps.restore.outputs.cache-hit != '' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + echo "::group::Loading cached Docker images" + hm cache restore .harmont-cache/ + echo "::endgroup::" From 58ee526cb840599ed0910512807d0fb5b9a8adb6 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:11:33 -0700 Subject: [PATCH 05/13] feat: add cache-save sub-action with content-addressed keys --- cache-save/action.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 cache-save/action.yml diff --git a/cache-save/action.yml b/cache-save/action.yml new file mode 100644 index 0000000..ed687a8 --- /dev/null +++ b/cache-save/action.yml @@ -0,0 +1,36 @@ +name: Save Harmont Cache +description: > + Save Docker image cache after a harmont pipeline run. + Exports images via 'hm cache save' and uploads to GHA cache + with a content-addressed key. Use with 'if: always()' to save + cache even when the pipeline fails. + +inputs: + cache-key-prefix: + description: > + Must match the prefix used in cache-restore. Bump to force rebuild. + required: false + default: harmont-v1 + working-directory: + description: Directory where .harmont-cache/ lives (usually repo root) + required: false + default: . + +runs: + using: composite + steps: + - name: Export Docker images to cache dir + id: manifest + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + echo "::group::Saving Docker images" + hash=$(hm cache save .harmont-cache/) + echo "cache-key=${{ inputs.cache-key-prefix }}-${hash}" >> "$GITHUB_OUTPUT" + echo "::endgroup::" + + - name: Upload cache + uses: actions/cache/save@v4 + with: + path: ${{ inputs.working-directory }}/.harmont-cache/ + key: ${{ steps.manifest.outputs.cache-key }} From 7a23ab9dbe73bda70e7c0bada40746164dbf9dce Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:12:36 -0700 Subject: [PATCH 06/13] feat: add all-in-one root action composing setup + cache + run --- action.yml | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 action.yml diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..7def670 --- /dev/null +++ b/action.yml @@ -0,0 +1,109 @@ +name: Harmont +description: > + Run a harmont pipeline in GitHub Actions with automatic Docker + image caching. One step to go from zero to running pipelines. + +branding: + icon: terminal + color: purple + +inputs: + pipeline: + description: > + Pipeline slug to run (e.g. 'ci'). If omitted and the repo has + only one pipeline, harmont auto-selects it. + required: false + default: '' + version: + description: hm CLI version ('latest' or semver like '0.5.0') + required: false + default: latest + working-directory: + description: > + Path to the repo root where .harmont/ pipelines live. + required: false + default: . + parallelism: + description: > + Max concurrent pipeline chains. Defaults to host CPU count. + required: false + default: '' + cache: + description: Enable Docker image caching between runs + required: false + default: 'true' + cache-key-prefix: + description: Cache key prefix. Bump to force full cache rebuild. + required: false + default: harmont-v1 + install-python-dsl: + description: Install the harmont Python DSL from PyPI + required: false + default: 'true' + python-dsl-version: + description: Pinned version of harmont PyPI package + required: false + default: '' + extra-args: + description: Additional arguments passed to 'hm run' + required: false + default: '' + token: + description: GitHub token for API access + required: false + default: ${{ github.token }} + +outputs: + hm-version: + description: Installed hm CLI version + value: ${{ steps.setup.outputs.hm-version }} + +runs: + using: composite + steps: + # --- Setup --- + - name: Setup Harmont + id: setup + uses: ./setup + with: + version: ${{ inputs.version }} + install-python-dsl: ${{ inputs.install-python-dsl }} + python-dsl-version: ${{ inputs.python-dsl-version }} + token: ${{ inputs.token }} + + # --- Cache Restore --- + - name: Restore Docker cache + if: inputs.cache == 'true' + uses: ./cache-restore + with: + cache-key-prefix: ${{ inputs.cache-key-prefix }} + working-directory: ${{ inputs.working-directory }} + + # --- Run Pipeline --- + - name: Run harmont pipeline + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + HM_NONINTERACTIVE: '1' + run: | + args=() + if [[ -n "${{ inputs.pipeline }}" ]]; then + args+=("${{ inputs.pipeline }}") + fi + if [[ -n "${{ inputs.parallelism }}" ]]; then + args+=("--parallelism" "${{ inputs.parallelism }}") + fi + if [[ -n "${{ inputs.extra-args }}" ]]; then + # Word-split extra-args intentionally + read -ra extra <<< "${{ inputs.extra-args }}" + args+=("${extra[@]}") + fi + hm run "${args[@]}" + + # --- Cache Save --- + - name: Save Docker cache + if: always() && inputs.cache == 'true' + uses: ./cache-save + with: + cache-key-prefix: ${{ inputs.cache-key-prefix }} + working-directory: ${{ inputs.working-directory }} From 2d39e90db533486489e88b0850eb2f6a0a32f7c2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:13:25 -0700 Subject: [PATCH 07/13] feat: add integration test workflow with fixture pipeline --- .github/workflows/test-action.yml | 75 +++++++++++++++++++++++++++++++ tests/fixtures/.harmont/hello.py | 6 +++ 2 files changed, 81 insertions(+) create mode 100644 .github/workflows/test-action.yml create mode 100644 tests/fixtures/.harmont/hello.py diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000..06765e9 --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,75 @@ +name: Test Action + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + all-in-one: + name: All-in-one action + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run harmont via all-in-one action + uses: ./ + with: + pipeline: hello + working-directory: tests/fixtures + cache: 'true' + + granular: + name: Granular sub-actions + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup hm + uses: ./setup + with: + version: latest + install-python-dsl: 'true' + + - name: Restore cache + uses: ./cache-restore + + - name: Run pipeline manually + working-directory: tests/fixtures + env: + HM_NONINTERACTIVE: '1' + run: hm run hello + + - name: Save cache + if: always() + uses: ./cache-save + + setup-only: + name: Setup only (verify install) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup hm + id: setup + uses: ./setup + with: + version: latest + + - name: Verify hm is on PATH + run: | + hm --version + echo "Installed version: ${{ steps.setup.outputs.hm-version }}" diff --git a/tests/fixtures/.harmont/hello.py b/tests/fixtures/.harmont/hello.py new file mode 100644 index 0000000..5d302a9 --- /dev/null +++ b/tests/fixtures/.harmont/hello.py @@ -0,0 +1,6 @@ +import harmont as hm + + +@hm.pipeline("hello") +def hello() -> hm.Step: + return hm.sh("echo 'hello from harmont action test'", label="greet") From d6836ba246d8cf962a0efa128c59721df05d4e5c Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:13:37 -0700 Subject: [PATCH 08/13] chore: add YAML validation to CI --- .github/workflows/ci.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a082de3..c75aa72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,19 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install bats - run: | - sudo apt-get update && sudo apt-get install -y bats + run: sudo apt-get update && sudo apt-get install -y bats - name: Run tests run: bats tests/*.bats + + yaml-lint: + name: Validate action YAML + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate all action.yml files + run: | + for f in action.yml setup/action.yml cache-restore/action.yml cache-save/action.yml; do + echo "Validating $f..." + python3 -c "import yaml; yaml.safe_load(open('$f'))" + done + echo "All action YAML files valid." From accfdeef3fcd0f512bbab01c3dfb40884567978d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:16:43 -0700 Subject: [PATCH 09/13] fix: harden against script injection and improve cache/auth reliability - Pass user inputs via env vars instead of inline ${{ }} interpolation in run blocks (prevents script injection in action.yml and setup/action.yml) - Check cache directory contents instead of cache-hit output for prefix matches in cache-restore (prefix restores don't set cache-hit=true) - Add GITHUB_TOKEN auth header to GitHub API calls in resolve-version.sh (avoids 60 req/hr unauthenticated rate limit on shared runners) --- action.yml | 16 +++++++++------- cache-restore/action.yml | 11 +++++++---- scripts/resolve-version.sh | 7 ++++++- setup/action.yml | 17 +++++++++++------ 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/action.yml b/action.yml index 7def670..6756c29 100644 --- a/action.yml +++ b/action.yml @@ -85,17 +85,19 @@ runs: working-directory: ${{ inputs.working-directory }} env: HM_NONINTERACTIVE: '1' + INPUT_PIPELINE: ${{ inputs.pipeline }} + INPUT_PARALLELISM: ${{ inputs.parallelism }} + INPUT_EXTRA_ARGS: ${{ inputs.extra-args }} run: | args=() - if [[ -n "${{ inputs.pipeline }}" ]]; then - args+=("${{ inputs.pipeline }}") + if [[ -n "$INPUT_PIPELINE" ]]; then + args+=("$INPUT_PIPELINE") fi - if [[ -n "${{ inputs.parallelism }}" ]]; then - args+=("--parallelism" "${{ inputs.parallelism }}") + if [[ -n "$INPUT_PARALLELISM" ]]; then + args+=("--parallelism" "$INPUT_PARALLELISM") fi - if [[ -n "${{ inputs.extra-args }}" ]]; then - # Word-split extra-args intentionally - read -ra extra <<< "${{ inputs.extra-args }}" + if [[ -n "$INPUT_EXTRA_ARGS" ]]; then + read -ra extra <<< "$INPUT_EXTRA_ARGS" args+=("${extra[@]}") fi hm run "${args[@]}" diff --git a/cache-restore/action.yml b/cache-restore/action.yml index 28f89fa..d1008c1 100644 --- a/cache-restore/action.yml +++ b/cache-restore/action.yml @@ -33,10 +33,13 @@ runs: ${{ inputs.cache-key-prefix }}- - name: Load Docker images from cache - if: steps.restore.outputs.cache-hit != '' shell: bash working-directory: ${{ inputs.working-directory }} run: | - echo "::group::Loading cached Docker images" - hm cache restore .harmont-cache/ - echo "::endgroup::" + if [ -d .harmont-cache ] && [ "$(ls -A .harmont-cache 2>/dev/null)" ]; then + echo "::group::Loading cached Docker images" + hm cache restore .harmont-cache/ + echo "::endgroup::" + else + echo "No cached images found, skipping restore" + fi diff --git a/scripts/resolve-version.sh b/scripts/resolve-version.sh index 932a226..b72296d 100755 --- a/scripts/resolve-version.sh +++ b/scripts/resolve-version.sh @@ -11,7 +11,12 @@ if [[ -z "$VERSION" ]]; then fi if [[ "$VERSION" == "latest" ]]; then - tag=$($CURL_CMD -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) + auth_args=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + auth_args+=(-H "Authorization: token ${GITHUB_TOKEN}") + fi + response=$($CURL_CMD -fsSL "${auth_args[@]}" "https://api.github.com/repos/${REPO}/releases/latest") + tag=$(echo "$response" | grep '"tag_name"' | cut -d'"' -f4) if [[ -z "$tag" ]]; then echo "::error::failed to resolve latest version from GitHub" >&2 exit 1 diff --git a/setup/action.yml b/setup/action.yml index 4aba4a4..b4c9135 100644 --- a/setup/action.yml +++ b/setup/action.yml @@ -38,25 +38,30 @@ runs: - name: Resolve version id: version shell: bash - run: | - tag=$("${{ github.action_path }}/../scripts/resolve-version.sh" "${{ inputs.version }}") - echo "tag=$tag" >> "$GITHUB_OUTPUT" env: + INPUT_VERSION: ${{ inputs.version }} GITHUB_TOKEN: ${{ inputs.token }} + run: | + tag=$("${{ github.action_path }}/../scripts/resolve-version.sh" "$INPUT_VERSION") + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Install hm id: install shell: bash + env: + INPUT_TAG: ${{ steps.version.outputs.tag }} run: | - "${{ github.action_path }}/../scripts/install-hm.sh" "${{ steps.version.outputs.tag }}" + "${{ github.action_path }}/../scripts/install-hm.sh" "$INPUT_TAG" - name: Install Python DSL if: inputs.install-python-dsl == 'true' shell: bash + env: + INPUT_DSL_VERSION: ${{ inputs.python-dsl-version }} run: | pip_spec="harmont" - if [[ -n "${{ inputs.python-dsl-version }}" ]]; then - pip_spec="harmont==${{ inputs.python-dsl-version }}" + if [[ -n "$INPUT_DSL_VERSION" ]]; then + pip_spec="harmont==${INPUT_DSL_VERSION}" fi echo "::group::Installing harmont Python DSL" pip install "$pip_spec" From fc8683770dea7f42444729ebce23ab257507d53b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 14:09:28 -0700 Subject: [PATCH 10/13] feat: cache hm binary between runs, remove Python DSL install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add actions/cache step in setup/ keyed on version + OS + arch - Skip download entirely on cache hit (instant setup on repeat runs) - Remove install-python-dsl input — hm embeds its own DSL engine - Add setup-cached test job to verify cache behavior --- .github/workflows/test-action.yml | 27 ++++++++++++------- action.yml | 10 ------- setup/action.yml | 45 +++++++++++++------------------ 3 files changed, 37 insertions(+), 45 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 06765e9..1ac9f16 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -16,10 +16,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Run harmont via all-in-one action uses: ./ with: @@ -34,15 +30,10 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Setup hm uses: ./setup with: version: latest - install-python-dsl: 'true' - name: Restore cache uses: ./cache-restore @@ -73,3 +64,21 @@ jobs: run: | hm --version echo "Installed version: ${{ steps.setup.outputs.hm-version }}" + + setup-cached: + name: Setup with cache hit (second run) + runs-on: ubuntu-latest + needs: setup-only + steps: + - uses: actions/checkout@v4 + + - name: Setup hm (should hit cache) + id: setup + uses: ./setup + with: + version: latest + + - name: Verify cache was used + run: | + echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}" + hm --version diff --git a/action.yml b/action.yml index 6756c29..cef62d5 100644 --- a/action.yml +++ b/action.yml @@ -36,14 +36,6 @@ inputs: description: Cache key prefix. Bump to force full cache rebuild. required: false default: harmont-v1 - install-python-dsl: - description: Install the harmont Python DSL from PyPI - required: false - default: 'true' - python-dsl-version: - description: Pinned version of harmont PyPI package - required: false - default: '' extra-args: description: Additional arguments passed to 'hm run' required: false @@ -67,8 +59,6 @@ runs: uses: ./setup with: version: ${{ inputs.version }} - install-python-dsl: ${{ inputs.install-python-dsl }} - python-dsl-version: ${{ inputs.python-dsl-version }} token: ${{ inputs.token }} # --- Cache Restore --- diff --git a/setup/action.yml b/setup/action.yml index b4c9135..2606e44 100644 --- a/setup/action.yml +++ b/setup/action.yml @@ -1,5 +1,5 @@ name: Setup Harmont -description: Install the hm CLI and optionally the harmont Python DSL +description: Install the hm CLI with binary caching inputs: version: @@ -8,18 +8,6 @@ inputs: or pin to a specific version (e.g. '0.5.0' or 'v0.5.0'). required: false default: latest - install-python-dsl: - description: > - Install the harmont Python DSL package. Set to 'true' if your - pipelines are written in Python (.harmont/*.py). - required: false - default: 'false' - python-dsl-version: - description: > - Version of the harmont PyPI package. Only used when - install-python-dsl is 'true'. - required: false - default: '' token: description: > GitHub token for downloading release assets and API rate limits. @@ -31,6 +19,9 @@ outputs: hm-version: description: Installed hm version string value: ${{ steps.install.outputs.hm-version }} + cache-hit: + description: Whether the hm binary was restored from cache + value: ${{ steps.cache.outputs.cache-hit }} runs: using: composite @@ -45,24 +36,26 @@ runs: tag=$("${{ github.action_path }}/../scripts/resolve-version.sh" "$INPUT_VERSION") echo "tag=$tag" >> "$GITHUB_OUTPUT" + - name: Cache hm binary + id: cache + uses: actions/cache@v4 + with: + path: /tmp/hm/bin + key: hm-${{ steps.version.outputs.tag }}-${{ runner.os }}-${{ runner.arch }} + - name: Install hm - id: install + if: steps.cache.outputs.cache-hit != 'true' shell: bash env: INPUT_TAG: ${{ steps.version.outputs.tag }} + HM_INSTALL_DIR: /tmp/hm/bin run: | - "${{ github.action_path }}/../scripts/install-hm.sh" "$INPUT_TAG" + "${{ github.action_path }}/../scripts/install-hm.sh" "$INPUT_TAG" "$HM_INSTALL_DIR" - - name: Install Python DSL - if: inputs.install-python-dsl == 'true' + - name: Add hm to PATH and verify + id: install shell: bash - env: - INPUT_DSL_VERSION: ${{ inputs.python-dsl-version }} run: | - pip_spec="harmont" - if [[ -n "$INPUT_DSL_VERSION" ]]; then - pip_spec="harmont==${INPUT_DSL_VERSION}" - fi - echo "::group::Installing harmont Python DSL" - pip install "$pip_spec" - echo "::endgroup::" + echo "/tmp/hm/bin" >> "$GITHUB_PATH" + export PATH="/tmp/hm/bin:$PATH" + echo "hm-version=$(hm --version)" >> "$GITHUB_OUTPUT" From 938ee617f5dd4add62e4bf5066503b5d14ef9e6f Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 14:23:38 -0700 Subject: [PATCH 11/13] feat: add registry-based Docker image caching (GHCR) New cache-backend input: 'gha' (default, existing behavior) or 'registry' (pushes/pulls images to a container registry). Registry backend advantages over GHA cache: - No 10GB size limit (GHCR storage is separate) - Native Docker layer deduplication (shared base layers stored once) - Per-image granularity (only changed images push/pull) - Faster for large images (Docker pull vs untar from GHA cache) Usage: - uses: harmont-dev/actions-hm@v1 with: pipeline: ci cache-backend: registry permissions: packages: write Images stored at ghcr.io///harmont-cache/: --- action.yml | 30 +++++++++++- cache-restore/action.yml | 103 ++++++++++++++++++++++++++++++++++++--- cache-save/action.yml | 93 ++++++++++++++++++++++++++++++++--- 3 files changed, 212 insertions(+), 14 deletions(-) diff --git a/action.yml b/action.yml index cef62d5..426c68b 100644 --- a/action.yml +++ b/action.yml @@ -32,16 +32,34 @@ inputs: description: Enable Docker image caching between runs required: false default: 'true' + cache-backend: + description: > + Cache backend: 'gha' (GitHub Actions cache, 10GB limit) or + 'registry' (container registry, no size limit, layer dedup). + required: false + default: gha cache-key-prefix: - description: Cache key prefix. Bump to force full cache rebuild. + description: > + Cache key prefix (gha backend only). Bump to force rebuild. required: false default: harmont-v1 + cache-registry: + description: > + Container registry for image caching (registry backend only). + required: false + default: ghcr.io + cache-registry-prefix: + description: > + Registry path prefix (registry backend only). Defaults to + ghcr.io///harmont-cache. + required: false + default: '' extra-args: description: Additional arguments passed to 'hm run' required: false default: '' token: - description: GitHub token for API access + description: GitHub token for API access (also used for registry auth) required: false default: ${{ github.token }} @@ -66,8 +84,12 @@ runs: if: inputs.cache == 'true' uses: ./cache-restore with: + backend: ${{ inputs.cache-backend }} cache-key-prefix: ${{ inputs.cache-key-prefix }} + registry: ${{ inputs.cache-registry }} + registry-prefix: ${{ inputs.cache-registry-prefix }} working-directory: ${{ inputs.working-directory }} + token: ${{ inputs.token }} # --- Run Pipeline --- - name: Run harmont pipeline @@ -97,5 +119,9 @@ runs: if: always() && inputs.cache == 'true' uses: ./cache-save with: + backend: ${{ inputs.cache-backend }} cache-key-prefix: ${{ inputs.cache-key-prefix }} + registry: ${{ inputs.cache-registry }} + registry-prefix: ${{ inputs.cache-registry-prefix }} working-directory: ${{ inputs.working-directory }} + token: ${{ inputs.token }} diff --git a/cache-restore/action.yml b/cache-restore/action.yml index d1008c1..9cc1f51 100644 --- a/cache-restore/action.yml +++ b/cache-restore/action.yml @@ -1,30 +1,57 @@ name: Restore Harmont Cache description: > Restore Docker image cache for harmont pipelines. - Uses content-addressed GHA cache with prefix matching - to always get the most recent cache entry. + Supports two backends: GHA cache (default) or container registry (GHCR). inputs: + backend: + description: > + Cache backend: 'gha' uses GitHub Actions cache (10GB limit, simple), + 'registry' uses GitHub Container Registry (no size limit, layer dedup). + required: false + default: gha cache-key-prefix: description: > - Prefix for the cache key. Bump this to force a full cache rebuild. + Prefix for the GHA cache key. Bump to force a full cache rebuild. + Only used with backend 'gha'. required: false default: harmont-v1 + registry: + description: > + Container registry to pull cached images from. + Only used with backend 'registry'. + required: false + default: ghcr.io + registry-prefix: + description: > + Registry path prefix for cached images. Defaults to + ghcr.io///harmont-cache. + Only used with backend 'registry'. + required: false + default: '' working-directory: description: Directory where .harmont-cache/ lives (usually repo root) required: false default: . + token: + description: > + Token for registry authentication. Needs packages:read scope. + Only used with backend 'registry'. + required: false + default: ${{ github.token }} outputs: cache-hit: description: Whether a cache entry was restored - value: ${{ steps.restore.outputs.cache-hit }} + value: ${{ steps.restore-gha.outputs.cache-hit || steps.restore-registry.outputs.restored }} runs: using: composite steps: + # --- GHA backend --- - name: Restore GHA cache - id: restore + id: restore-gha + if: inputs.backend == 'gha' uses: actions/cache/restore@v4 with: path: ${{ inputs.working-directory }}/.harmont-cache/ @@ -32,7 +59,8 @@ runs: restore-keys: | ${{ inputs.cache-key-prefix }}- - - name: Load Docker images from cache + - name: Load Docker images from GHA cache + if: inputs.backend == 'gha' shell: bash working-directory: ${{ inputs.working-directory }} run: | @@ -43,3 +71,66 @@ runs: else echo "No cached images found, skipping restore" fi + + # --- Registry backend --- + - name: Login to container registry + if: inputs.backend == 'registry' + shell: bash + env: + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_TOKEN: ${{ inputs.token }} + run: | + echo "$INPUT_TOKEN" | docker login "$INPUT_REGISTRY" -u __token__ --password-stdin + + - name: Pull cached images from registry + id: restore-registry + if: inputs.backend == 'registry' + shell: bash + env: + INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} + GITHUB_REPOSITORY: ${{ github.repository }} + INPUT_REGISTRY: ${{ inputs.registry }} + working-directory: ${{ inputs.working-directory }} + run: | + prefix="${INPUT_REGISTRY_PREFIX:-${INPUT_REGISTRY}/${GITHUB_REPOSITORY}/harmont-cache}" + restored=0 + + if [ -f .harmont-cache/manifest.json ]; then + manifest=".harmont-cache/manifest.json" + else + # Try pulling the manifest image to bootstrap + if docker pull "${prefix}/manifest:latest" 2>/dev/null; then + mkdir -p .harmont-cache + docker save "${prefix}/manifest:latest" | tar -xf - -O manifest.json > .harmont-cache/manifest.json 2>/dev/null || true + fi + manifest=".harmont-cache/manifest.json" + fi + + if [ ! -f "$manifest" ]; then + echo "No manifest found, cold start" + echo "restored=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "::group::Pulling cached images from registry" + # Read manifest and pull each image + while IFS= read -r tar_name; do + tag=$(python3 -c "import json,sys; m=json.load(open('$manifest')); print(m['images']['$tar_name'])" 2>/dev/null) || continue + # Derive registry tag from the harmont-local tag + # harmont-local/step:hash -> prefix/step:hash + registry_tag="${prefix}/${tag#harmont-local/}" + + if docker pull "$registry_tag" 2>/dev/null; then + # Re-tag as harmont-local so hm recognizes it + docker tag "$registry_tag" "$tag" + restored=$((restored + 1)) + fi + done < <(python3 -c "import json; m=json.load(open('$manifest')); [print(k) for k in m['images']]" 2>/dev/null) + echo "::endgroup::" + + echo "Restored $restored images from registry" + if [ "$restored" -gt 0 ]; then + echo "restored=true" >> "$GITHUB_OUTPUT" + else + echo "restored=false" >> "$GITHUB_OUTPUT" + fi diff --git a/cache-save/action.yml b/cache-save/action.yml index ed687a8..5978098 100644 --- a/cache-save/action.yml +++ b/cache-save/action.yml @@ -1,26 +1,53 @@ name: Save Harmont Cache description: > Save Docker image cache after a harmont pipeline run. - Exports images via 'hm cache save' and uploads to GHA cache - with a content-addressed key. Use with 'if: always()' to save - cache even when the pipeline fails. + Supports two backends: GHA cache (default) or container registry (GHCR). + Use with 'if: always()' to save cache even when the pipeline fails. inputs: + backend: + description: > + Cache backend: 'gha' uses GitHub Actions cache, + 'registry' uses GitHub Container Registry. + required: false + default: gha cache-key-prefix: description: > Must match the prefix used in cache-restore. Bump to force rebuild. + Only used with backend 'gha'. required: false default: harmont-v1 + registry: + description: > + Container registry to push cached images to. + Only used with backend 'registry'. + required: false + default: ghcr.io + registry-prefix: + description: > + Registry path prefix for cached images. Defaults to + ghcr.io///harmont-cache. + Only used with backend 'registry'. + required: false + default: '' working-directory: description: Directory where .harmont-cache/ lives (usually repo root) required: false default: . + token: + description: > + Token for registry authentication. Needs packages:write scope. + Only used with backend 'registry'. + required: false + default: ${{ github.token }} runs: using: composite steps: + # --- GHA backend --- - name: Export Docker images to cache dir - id: manifest + if: inputs.backend == 'gha' + id: manifest-gha shell: bash working-directory: ${{ inputs.working-directory }} run: | @@ -29,8 +56,62 @@ runs: echo "cache-key=${{ inputs.cache-key-prefix }}-${hash}" >> "$GITHUB_OUTPUT" echo "::endgroup::" - - name: Upload cache + - name: Upload GHA cache + if: inputs.backend == 'gha' uses: actions/cache/save@v4 with: path: ${{ inputs.working-directory }}/.harmont-cache/ - key: ${{ steps.manifest.outputs.cache-key }} + key: ${{ steps.manifest-gha.outputs.cache-key }} + + # --- Registry backend --- + - name: Login to container registry + if: inputs.backend == 'registry' + shell: bash + env: + INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_TOKEN: ${{ inputs.token }} + run: | + echo "$INPUT_TOKEN" | docker login "$INPUT_REGISTRY" -u __token__ --password-stdin + + - name: Push images to registry + if: inputs.backend == 'registry' + shell: bash + env: + INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} + GITHUB_REPOSITORY: ${{ github.repository }} + INPUT_REGISTRY: ${{ inputs.registry }} + working-directory: ${{ inputs.working-directory }} + run: | + prefix="${INPUT_REGISTRY_PREFIX:-${INPUT_REGISTRY}/${GITHUB_REPOSITORY}/harmont-cache}" + + # Run hm cache save to get the manifest (also exports tars, but we won't use them) + echo "::group::Exporting image metadata" + hm cache save .harmont-cache/ > /dev/null + echo "::endgroup::" + + if [ ! -f .harmont-cache/manifest.json ]; then + echo "No manifest produced, nothing to push" + exit 0 + fi + + echo "::group::Pushing images to registry" + pushed=0 + while IFS= read -r line; do + tar_name=$(echo "$line" | cut -d'|' -f1) + tag=$(echo "$line" | cut -d'|' -f2) + + # Derive registry tag: harmont-local/step:hash -> prefix/step:hash + registry_tag="${prefix}/${tag#harmont-local/}" + + docker tag "$tag" "$registry_tag" + if docker push "$registry_tag"; then + pushed=$((pushed + 1)) + fi + done < <(python3 -c " + import json + m = json.load(open('.harmont-cache/manifest.json')) + for k, v in m['images'].items(): + print(f'{k}|{v}') + ") + echo "::endgroup::" + echo "Pushed $pushed images to registry" From 8a1927aba4799e82bebbcac7351a3a43c1535432 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 14:33:51 -0700 Subject: [PATCH 12/13] feat: registry-only caching with automatic stale image cleanup Breaking: removed GHA cache backend entirely. All Docker image caching now goes through a container registry (GHCR by default). Cache save now includes automatic cleanup: - After pushing current images, queries GitHub Packages API for stale versions of each step's image - Keeps N previous versions (configurable via cleanup-keep, default 2) - Deletes older versions to prevent unbounded registry growth - Requires packages:delete permission (gracefully skips if denied) Manifest stored as a scratch image (ghcr.io/.../manifest:latest) so restore can bootstrap without prior local state. --- .github/workflows/test-action.yml | 1 + action.yml | 33 +++--- cache-restore/action.yml | 109 +++++++---------- cache-save/action.yml | 188 +++++++++++++++++++++--------- 4 files changed, 190 insertions(+), 141 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 1ac9f16..42ae5b9 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + packages: write jobs: all-in-one: diff --git a/action.yml b/action.yml index 426c68b..a2c11ec 100644 --- a/action.yml +++ b/action.yml @@ -32,34 +32,31 @@ inputs: description: Enable Docker image caching between runs required: false default: 'true' - cache-backend: - description: > - Cache backend: 'gha' (GitHub Actions cache, 10GB limit) or - 'registry' (container registry, no size limit, layer dedup). - required: false - default: gha - cache-key-prefix: - description: > - Cache key prefix (gha backend only). Bump to force rebuild. - required: false - default: harmont-v1 cache-registry: - description: > - Container registry for image caching (registry backend only). + description: Container registry for image caching required: false default: ghcr.io cache-registry-prefix: description: > - Registry path prefix (registry backend only). Defaults to + Registry path prefix for cached images. Defaults to ghcr.io///harmont-cache. required: false default: '' + cache-cleanup: + description: Delete stale images from registry after save + required: false + default: 'true' + cache-cleanup-keep: + description: Number of old image versions to keep per step + required: false + default: '2' extra-args: description: Additional arguments passed to 'hm run' required: false default: '' token: - description: GitHub token for API access (also used for registry auth) + description: > + GitHub token (needs packages:write for cache, packages:delete for cleanup) required: false default: ${{ github.token }} @@ -84,8 +81,6 @@ runs: if: inputs.cache == 'true' uses: ./cache-restore with: - backend: ${{ inputs.cache-backend }} - cache-key-prefix: ${{ inputs.cache-key-prefix }} registry: ${{ inputs.cache-registry }} registry-prefix: ${{ inputs.cache-registry-prefix }} working-directory: ${{ inputs.working-directory }} @@ -119,9 +114,9 @@ runs: if: always() && inputs.cache == 'true' uses: ./cache-save with: - backend: ${{ inputs.cache-backend }} - cache-key-prefix: ${{ inputs.cache-key-prefix }} registry: ${{ inputs.cache-registry }} registry-prefix: ${{ inputs.cache-registry-prefix }} working-directory: ${{ inputs.working-directory }} + cleanup: ${{ inputs.cache-cleanup }} + cleanup-keep: ${{ inputs.cache-cleanup-keep }} token: ${{ inputs.token }} diff --git a/cache-restore/action.yml b/cache-restore/action.yml index 9cc1f51..a152114 100644 --- a/cache-restore/action.yml +++ b/cache-restore/action.yml @@ -1,32 +1,18 @@ name: Restore Harmont Cache description: > - Restore Docker image cache for harmont pipelines. - Supports two backends: GHA cache (default) or container registry (GHCR). + Pull cached Docker images from a container registry. + Images are stored per-step with content-addressed tags, + giving native layer deduplication and no size limits. inputs: - backend: - description: > - Cache backend: 'gha' uses GitHub Actions cache (10GB limit, simple), - 'registry' uses GitHub Container Registry (no size limit, layer dedup). - required: false - default: gha - cache-key-prefix: - description: > - Prefix for the GHA cache key. Bump to force a full cache rebuild. - Only used with backend 'gha'. - required: false - default: harmont-v1 registry: - description: > - Container registry to pull cached images from. - Only used with backend 'registry'. + description: Container registry to pull from required: false default: ghcr.io registry-prefix: description: > Registry path prefix for cached images. Defaults to ghcr.io///harmont-cache. - Only used with backend 'registry'. required: false default: '' working-directory: @@ -35,46 +21,19 @@ inputs: default: . token: description: > - Token for registry authentication. Needs packages:read scope. - Only used with backend 'registry'. + Token for registry authentication (needs packages:read). required: false default: ${{ github.token }} outputs: - cache-hit: - description: Whether a cache entry was restored - value: ${{ steps.restore-gha.outputs.cache-hit || steps.restore-registry.outputs.restored }} + restored: + description: Whether any images were restored from registry + value: ${{ steps.pull.outputs.restored }} runs: using: composite steps: - # --- GHA backend --- - - name: Restore GHA cache - id: restore-gha - if: inputs.backend == 'gha' - uses: actions/cache/restore@v4 - with: - path: ${{ inputs.working-directory }}/.harmont-cache/ - key: ${{ inputs.cache-key-prefix }}-will-never-match - restore-keys: | - ${{ inputs.cache-key-prefix }}- - - - name: Load Docker images from GHA cache - if: inputs.backend == 'gha' - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - if [ -d .harmont-cache ] && [ "$(ls -A .harmont-cache 2>/dev/null)" ]; then - echo "::group::Loading cached Docker images" - hm cache restore .harmont-cache/ - echo "::endgroup::" - else - echo "No cached images found, skipping restore" - fi - - # --- Registry backend --- - name: Login to container registry - if: inputs.backend == 'registry' shell: bash env: INPUT_REGISTRY: ${{ inputs.registry }} @@ -83,49 +42,59 @@ runs: echo "$INPUT_TOKEN" | docker login "$INPUT_REGISTRY" -u __token__ --password-stdin - name: Pull cached images from registry - id: restore-registry - if: inputs.backend == 'registry' + id: pull shell: bash env: INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} - GITHUB_REPOSITORY: ${{ github.repository }} INPUT_REGISTRY: ${{ inputs.registry }} + GITHUB_REPOSITORY: ${{ github.repository }} working-directory: ${{ inputs.working-directory }} run: | prefix="${INPUT_REGISTRY_PREFIX:-${INPUT_REGISTRY}/${GITHUB_REPOSITORY}/harmont-cache}" restored=0 - if [ -f .harmont-cache/manifest.json ]; then - manifest=".harmont-cache/manifest.json" - else - # Try pulling the manifest image to bootstrap - if docker pull "${prefix}/manifest:latest" 2>/dev/null; then - mkdir -p .harmont-cache - docker save "${prefix}/manifest:latest" | tar -xf - -O manifest.json > .harmont-cache/manifest.json 2>/dev/null || true - fi - manifest=".harmont-cache/manifest.json" + echo "::group::Pulling manifest from registry" + manifest_image="${prefix}/manifest:latest" + mkdir -p .harmont-cache + + # Pull the manifest image — a single-layer image containing manifest.json + if ! docker pull "$manifest_image" 2>/dev/null; then + echo "No manifest found in registry, cold start" + echo "restored=false" >> "$GITHUB_OUTPUT" + echo "::endgroup::" + exit 0 fi - if [ ! -f "$manifest" ]; then - echo "No manifest found, cold start" + # Extract manifest.json from the image + container_id=$(docker create "$manifest_image" 2>/dev/null) + docker cp "$container_id:/manifest.json" .harmont-cache/manifest.json 2>/dev/null + docker rm "$container_id" > /dev/null 2>&1 + echo "::endgroup::" + + if [ ! -f .harmont-cache/manifest.json ]; then + echo "Failed to extract manifest, cold start" echo "restored=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "::group::Pulling cached images from registry" - # Read manifest and pull each image - while IFS= read -r tar_name; do - tag=$(python3 -c "import json,sys; m=json.load(open('$manifest')); print(m['images']['$tar_name'])" 2>/dev/null) || continue - # Derive registry tag from the harmont-local tag + echo "::group::Pulling cached images" + while IFS='|' read -r _ tag; do # harmont-local/step:hash -> prefix/step:hash registry_tag="${prefix}/${tag#harmont-local/}" if docker pull "$registry_tag" 2>/dev/null; then - # Re-tag as harmont-local so hm recognizes it docker tag "$registry_tag" "$tag" restored=$((restored + 1)) + echo " ✓ $tag" + else + echo " ✗ $tag (not in registry)" fi - done < <(python3 -c "import json; m=json.load(open('$manifest')); [print(k) for k in m['images']]" 2>/dev/null) + done < <(python3 -c " + import json + m = json.load(open('.harmont-cache/manifest.json')) + for k, v in m['images'].items(): + print(f'{k}|{v}') + ") echo "::endgroup::" echo "Restored $restored images from registry" diff --git a/cache-save/action.yml b/cache-save/action.yml index 5978098..42572ae 100644 --- a/cache-save/action.yml +++ b/cache-save/action.yml @@ -1,71 +1,46 @@ name: Save Harmont Cache description: > - Save Docker image cache after a harmont pipeline run. - Supports two backends: GHA cache (default) or container registry (GHCR). + Push Docker images to a container registry after a harmont pipeline run. + Automatically cleans up stale images no longer in the current manifest. Use with 'if: always()' to save cache even when the pipeline fails. inputs: - backend: - description: > - Cache backend: 'gha' uses GitHub Actions cache, - 'registry' uses GitHub Container Registry. - required: false - default: gha - cache-key-prefix: - description: > - Must match the prefix used in cache-restore. Bump to force rebuild. - Only used with backend 'gha'. - required: false - default: harmont-v1 registry: - description: > - Container registry to push cached images to. - Only used with backend 'registry'. + description: Container registry to push to required: false default: ghcr.io registry-prefix: description: > Registry path prefix for cached images. Defaults to ghcr.io///harmont-cache. - Only used with backend 'registry'. required: false default: '' working-directory: description: Directory where .harmont-cache/ lives (usually repo root) required: false default: . + cleanup: + description: > + Delete stale images from registry that are no longer in the manifest. + Requires packages:delete permission on the token. + required: false + default: 'true' + cleanup-keep: + description: > + Number of previous image versions to keep per step (in addition + to the current one). Set to 0 for aggressive cleanup. + required: false + default: '2' token: description: > - Token for registry authentication. Needs packages:write scope. - Only used with backend 'registry'. + Token for registry auth (needs packages:write, packages:delete for cleanup). required: false default: ${{ github.token }} runs: using: composite steps: - # --- GHA backend --- - - name: Export Docker images to cache dir - if: inputs.backend == 'gha' - id: manifest-gha - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - echo "::group::Saving Docker images" - hash=$(hm cache save .harmont-cache/) - echo "cache-key=${{ inputs.cache-key-prefix }}-${hash}" >> "$GITHUB_OUTPUT" - echo "::endgroup::" - - - name: Upload GHA cache - if: inputs.backend == 'gha' - uses: actions/cache/save@v4 - with: - path: ${{ inputs.working-directory }}/.harmont-cache/ - key: ${{ steps.manifest-gha.outputs.cache-key }} - - # --- Registry backend --- - name: Login to container registry - if: inputs.backend == 'registry' shell: bash env: INPUT_REGISTRY: ${{ inputs.registry }} @@ -73,19 +48,18 @@ runs: run: | echo "$INPUT_TOKEN" | docker login "$INPUT_REGISTRY" -u __token__ --password-stdin - - name: Push images to registry - if: inputs.backend == 'registry' + - name: Export manifest and push images + id: push shell: bash env: INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} - GITHUB_REPOSITORY: ${{ github.repository }} INPUT_REGISTRY: ${{ inputs.registry }} + GITHUB_REPOSITORY: ${{ github.repository }} working-directory: ${{ inputs.working-directory }} run: | prefix="${INPUT_REGISTRY_PREFIX:-${INPUT_REGISTRY}/${GITHUB_REPOSITORY}/harmont-cache}" - # Run hm cache save to get the manifest (also exports tars, but we won't use them) - echo "::group::Exporting image metadata" + echo "::group::Exporting image manifest" hm cache save .harmont-cache/ > /dev/null echo "::endgroup::" @@ -96,16 +70,15 @@ runs: echo "::group::Pushing images to registry" pushed=0 - while IFS= read -r line; do - tar_name=$(echo "$line" | cut -d'|' -f1) - tag=$(echo "$line" | cut -d'|' -f2) - - # Derive registry tag: harmont-local/step:hash -> prefix/step:hash + while IFS='|' read -r _ tag; do registry_tag="${prefix}/${tag#harmont-local/}" docker tag "$tag" "$registry_tag" if docker push "$registry_tag"; then pushed=$((pushed + 1)) + echo " ✓ $registry_tag" + else + echo " ✗ $registry_tag (push failed)" fi done < <(python3 -c " import json @@ -114,4 +87,115 @@ runs: print(f'{k}|{v}') ") echo "::endgroup::" - echo "Pushed $pushed images to registry" + echo "Pushed $pushed images" + + # Push manifest as a lightweight image so restore can bootstrap + echo "::group::Pushing manifest image" + cat > /tmp/Dockerfile.manifest < step + name = tag.split('/')[-1].split(':')[0] + if name not in seen: + seen.add(name) + print(name) + ") + + for step in $step_names; do + package_name="${repo}/harmont-cache/${step}" + + # List all versions for this package via GitHub API + versions=$(curl -fsSL \ + -H "Authorization: Bearer ${INPUT_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/orgs/${owner}/packages/container/${package_name}/versions?per_page=100" 2>/dev/null) || { + # Try user endpoint if org endpoint fails + versions=$(curl -fsSL \ + -H "Authorization: Bearer ${INPUT_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/user/packages/container/${package_name}/versions?per_page=100" 2>/dev/null) || continue + } + + # Get current tag for this step from manifest + current_tag=$(python3 -c " + import json + m = json.load(open('.harmont-cache/manifest.json')) + for tag in m['images'].values(): + parts = tag.split('/')[-1].split(':') + if parts[0] == '${step}': + print(parts[1]) + break + " 2>/dev/null) || continue + + # Parse versions, skip current + keep N most recent, delete the rest + to_delete=$(python3 -c " + import json, sys + versions = json.loads('''${versions}''') + current_tag = '${current_tag}' + keep = int('${keep}') + + # Sort by created_at descending + versions.sort(key=lambda v: v.get('created_at', ''), reverse=True) + + # Find versions to delete (skip current, keep N recent, delete rest) + kept = 0 + for v in versions: + tags = v.get('metadata', {}).get('container', {}).get('tags', []) + if current_tag in tags: + continue + if kept < keep: + kept += 1 + continue + print(v['id']) + " 2>/dev/null) || continue + + for version_id in $to_delete; do + # Try org endpoint first, then user endpoint + if curl -fsSL -X DELETE \ + -H "Authorization: Bearer ${INPUT_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/orgs/${owner}/packages/container/${package_name}/versions/${version_id}" 2>/dev/null; then + echo " Deleted version $version_id of $step" + elif curl -fsSL -X DELETE \ + -H "Authorization: Bearer ${INPUT_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/user/packages/container/${package_name}/versions/${version_id}" 2>/dev/null; then + echo " Deleted version $version_id of $step" + fi + done + done + + echo "::endgroup::" From 73e5c3d30a828583c103124d7cd95c09060e28c5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 15:01:01 -0700 Subject: [PATCH 13/13] docs: add README with usage examples, inputs reference, and migration guide --- README.md | 267 ++++++++++++++++++++++++++ docs/plans/2026-05-25-readme.md | 327 ++++++++++++++++++++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 README.md create mode 100644 docs/plans/2026-05-25-readme.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5f1603 --- /dev/null +++ b/README.md @@ -0,0 +1,267 @@ +# actions-hm + +[![CI](https://img.shields.io/github/actions/workflow/status/harmont-dev/actions-hm/ci.yml?branch=main&logo=github&label=CI)](https://github.com/harmont-dev/actions-hm/actions) +[![GitHub release](https://img.shields.io/github/v/release/harmont-dev/actions-hm?logo=github)](https://github.com/harmont-dev/actions-hm/releases) +[![Marketplace](https://img.shields.io/badge/marketplace-harmont-purple?logo=github)](https://github.com/marketplace/actions/harmont) + +Run [harmont](https://harmont.dev) pipelines in GitHub Actions. One step. Automatic Docker image caching via your container registry. + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci +``` + +That's it. This installs `hm`, pulls cached Docker images from GHCR, runs your pipeline, and pushes updated images back — with automatic cleanup of stale cache entries. + +## Why + +You already define your CI with harmont. This action lets you run it on GitHub Actions without boilerplate: + +- **Zero config caching** — Docker images cached in GHCR with native layer deduplication +- **One step** — no separate setup, login, cache-restore, cache-save dance +- **Fast repeat runs** — `hm` binary cached between runs, images pulled only when changed +- **Auto cleanup** — stale registry images pruned automatically (configurable retention) +- **Granular control** — use sub-actions individually when you need custom steps between them + +## Usage + +### Minimal (all-in-one) + +```yaml +name: CI + +on: [push, pull_request] + +permissions: + contents: read + packages: write + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci +``` + +### Multiple pipelines + +```yaml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: test + parallelism: 4 +``` + +### Granular sub-actions + +For workflows that need custom steps between setup, cache, and run: + +```yaml +jobs: + ci: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: harmont-dev/actions-hm/setup@v1 + + - uses: harmont-dev/actions-hm/cache-restore@v1 + + - run: | + echo "Custom setup between cache restore and pipeline run" + hm run ci + + - uses: harmont-dev/actions-hm/cache-save@v1 + if: always() +``` + +### Pin to specific version + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + version: 0.5.0 +``` + +## Inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `pipeline` | *(auto)* | Pipeline slug to run. Omit if repo has only one pipeline. | +| `version` | `latest` | `hm` CLI version (`latest` or semver like `0.5.0`) | +| `working-directory` | `.` | Path to repo root where `.harmont/` lives | +| `parallelism` | *(cpu count)* | Max concurrent pipeline chains | +| `cache` | `true` | Enable Docker image caching | +| `cache-registry` | `ghcr.io` | Container registry for image caching | +| `cache-registry-prefix` | *(auto)* | Registry path prefix. Default: `ghcr.io///harmont-cache` | +| `cache-cleanup` | `true` | Delete stale images from registry after save | +| `cache-cleanup-keep` | `2` | Number of old image versions to keep per step | +| `extra-args` | | Additional arguments passed to `hm run` | +| `token` | `github.token` | GitHub token (needs `packages:write`, `packages:delete` for cleanup) | + +## Outputs + +| Output | Description | +|--------|-------------| +| `hm-version` | Installed `hm` CLI version | + +## Sub-actions + +| Action | Purpose | +|--------|---------| +| `harmont-dev/actions-hm/setup@v1` | Install `hm` binary (cached between runs) | +| `harmont-dev/actions-hm/cache-restore@v1` | Pull cached Docker images from registry | +| `harmont-dev/actions-hm/cache-save@v1` | Push Docker images to registry + cleanup | + +## How caching works + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GitHub Actions Runner │ +│ │ +│ 1. Pull manifest:latest from GHCR │ +│ 2. Pull each step image (layer dedup = fast) │ +│ 3. Re-tag as harmont-local/* so hm recognizes them │ +│ 4. hm run ci (uses cached images, skips rebuilds) │ +│ 5. Push changed images back to GHCR │ +│ 6. Prune images older than cleanup-keep │ +│ │ +│ Images stored at: │ +│ ghcr.io///harmont-cache/: │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Why GHCR instead of `actions/cache`?** + +- No 10 GB size limit (GHCR storage is unlimited for public repos) +- Native Docker layer deduplication — shared base images stored once +- Per-image granularity — only changed images push/pull +- Faster for large images than tar/untar through GHA cache + +## Permissions + +The action needs `packages:write` on the `GITHUB_TOKEN` to push/pull cache images. For cleanup, it also needs `packages:delete`. + +```yaml +permissions: + contents: read + packages: write +``` + +> **Note:** `packages:delete` is included in `packages:write` for tokens with full `packages` scope. If using a fine-grained PAT, ensure both are granted. + +## Migrating from raw workflow steps + +If you currently have a manual harmont setup in your workflow: + +
+Before (manual setup) + +```yaml +steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build -p harmont-cli + - uses: actions/cache/restore@v4 + with: + path: .harmont-cache/ + key: harmont-v1-will-never-match + restore-keys: harmont-v1- + - run: ./target/debug/hm cache restore .harmont-cache/ + - run: ./target/debug/hm run ci + env: + HM_NONINTERACTIVE: '1' + - run: | + hash=$(./target/debug/hm cache save .harmont-cache/) + echo "key=harmont-v1-${hash}" >> "$GITHUB_OUTPUT" + id: cache-manifest + if: always() + - uses: actions/cache/save@v4 + if: always() + with: + path: .harmont-cache/ + key: ${{ steps.cache-manifest.outputs.key }} +``` + +
+ +
+After (this action) + +```yaml +steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci +``` + +
+ +## FAQ + +### Do I need Docker on the runner? + +Yes. Harmont runs pipeline steps in Docker containers. Use `runs-on: ubuntu-latest` (Docker is pre-installed). + +### What about macOS / Windows runners? + +macOS runners have Docker available via colima/lima. Windows runners are not currently supported (harmont requires Linux containers). + +### Can I use a private registry instead of GHCR? + +Yes. Set `cache-registry` to your registry hostname and provide a token with push/pull access: + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci + cache-registry: registry.example.com + token: ${{ secrets.REGISTRY_TOKEN }} +``` + +### How do I disable caching entirely? + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci + cache: 'false' +``` + +### How do I force a clean cache rebuild? + +Delete the `harmont-cache` packages from your repo's GitHub Packages, or change `cache-registry-prefix` to a new path. + +### The first run is slow — is that expected? + +Yes. The first run has no cached images, so Docker pulls base images and builds from scratch. Subsequent runs reuse cached images and are significantly faster. + +### What permissions does cleanup need? + +`packages:delete` (part of the `packages: write` scope). If your token lacks this, set `cache-cleanup: 'false'` — images accumulate but nothing breaks. + +## License + +MIT diff --git a/docs/plans/2026-05-25-readme.md b/docs/plans/2026-05-25-readme.md new file mode 100644 index 0000000..af3012c --- /dev/null +++ b/docs/plans/2026-05-25-readme.md @@ -0,0 +1,327 @@ +# README Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Write a README.md that makes `actions-hm` irresistible to adopt — instant comprehension, copy-paste first example, progressive disclosure. + +**Architecture:** Single-file documentation following the patterns of the most-starred GitHub Actions: value prop in one sentence, working example in the first scroll, inputs table for reference, FAQ for real questions. + +**Tech Stack:** Markdown, shields.io badges + +**Design principles (from research of hsrs, docker/build-push-action, setup-uv):** +- 3-second rule: "what does this do?" answered in one sentence +- Copy-paste-first: the minimal example IS a complete workflow +- Progressive disclosure: simple → granular → caching details → migration +- "That's it." confidence after the quick start +- Trust signals via badges +- FAQ preempts real support questions + +--- + +## Task 1: Write README.md + +**Files:** +- Create: `README.md` + +**Step 1: Write the full README** + +```markdown +# actions-hm + +[![CI](https://img.shields.io/github/actions/workflow/status/harmont-dev/actions-hm/ci.yml?branch=main&logo=github&label=CI)](https://github.com/harmont-dev/actions-hm/actions) +[![GitHub release](https://img.shields.io/github/v/release/harmont-dev/actions-hm?logo=github)](https://github.com/harmont-dev/actions-hm/releases) +[![Marketplace](https://img.shields.io/badge/marketplace-harmont-purple?logo=github)](https://github.com/marketplace/actions/harmont) + +Run [harmont](https://harmont.dev) pipelines in GitHub Actions. One step. Automatic Docker image caching via your container registry. + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci +``` + +That's it. This installs `hm`, pulls cached Docker images from GHCR, runs your pipeline, and pushes updated images back — with automatic cleanup of stale cache entries. + +## Why + +You already define your CI with harmont. This action lets you run it on GitHub Actions without boilerplate: + +- **Zero config caching** — Docker images cached in GHCR with native layer deduplication +- **One step** — no separate setup, login, cache-restore, cache-save dance +- **Fast repeat runs** — `hm` binary cached between runs, images pulled only when changed +- **Auto cleanup** — stale registry images pruned automatically (configurable retention) +- **Granular control** — use sub-actions individually when you need custom steps between them + +## Usage + +### Minimal (all-in-one) + +```yaml +name: CI + +on: [push, pull_request] + +permissions: + contents: read + packages: write + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci +``` + +### Multiple pipelines + +```yaml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: test + parallelism: 4 +``` + +### Granular sub-actions + +For workflows that need custom steps between setup, cache, and run: + +```yaml +jobs: + ci: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: harmont-dev/actions-hm/setup@v1 + + - uses: harmont-dev/actions-hm/cache-restore@v1 + + - run: | + echo "Custom setup between cache restore and pipeline run" + hm run ci + + - uses: harmont-dev/actions-hm/cache-save@v1 + if: always() +``` + +### Pin to specific version + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + version: 0.5.0 +``` + +## Inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `pipeline` | *(auto)* | Pipeline slug to run. Omit if repo has only one pipeline. | +| `version` | `latest` | `hm` CLI version (`latest` or semver like `0.5.0`) | +| `working-directory` | `.` | Path to repo root where `.harmont/` lives | +| `parallelism` | *(cpu count)* | Max concurrent pipeline chains | +| `cache` | `true` | Enable Docker image caching | +| `cache-registry` | `ghcr.io` | Container registry for image caching | +| `cache-registry-prefix` | *(auto)* | Registry path prefix. Default: `ghcr.io///harmont-cache` | +| `cache-cleanup` | `true` | Delete stale images from registry after save | +| `cache-cleanup-keep` | `2` | Number of old image versions to keep per step | +| `extra-args` | | Additional arguments passed to `hm run` | +| `token` | `github.token` | GitHub token (needs `packages:write`, `packages:delete` for cleanup) | + +## Outputs + +| Output | Description | +|--------|-------------| +| `hm-version` | Installed `hm` CLI version | + +## Sub-actions + +| Action | Purpose | +|--------|---------| +| `harmont-dev/actions-hm/setup@v1` | Install `hm` binary (cached between runs) | +| `harmont-dev/actions-hm/cache-restore@v1` | Pull cached Docker images from registry | +| `harmont-dev/actions-hm/cache-save@v1` | Push Docker images to registry + cleanup | + +## How caching works + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GitHub Actions Runner │ +│ │ +│ 1. Pull manifest:latest from GHCR │ +│ 2. Pull each step image (layer dedup = fast) │ +│ 3. Re-tag as harmont-local/* so hm recognizes them │ +│ 4. hm run ci (uses cached images, skips rebuilds) │ +│ 5. Push changed images back to GHCR │ +│ 6. Prune images older than cleanup-keep │ +│ │ +│ Images stored at: │ +│ ghcr.io///harmont-cache/: │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Why GHCR instead of `actions/cache`?** + +- No 10 GB size limit (GHCR storage is unlimited for public repos) +- Native Docker layer deduplication — shared base images stored once +- Per-image granularity — only changed images push/pull +- Faster for large images than tar/untar through GHA cache + +## Permissions + +The action needs `packages:write` on the `GITHUB_TOKEN` to push/pull cache images. For cleanup, it also needs `packages:delete`. + +```yaml +permissions: + contents: read + packages: write +``` + +> **Note:** `packages:delete` is included in `packages:write` for tokens with full `packages` scope. If using a fine-grained PAT, ensure both are granted. + +## Migrating from raw workflow steps + +If you currently have a manual harmont setup in your workflow: + +
+Before (manual setup) + +```yaml +steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build -p harmont-cli + - uses: actions/cache/restore@v4 + with: + path: .harmont-cache/ + key: harmont-v1-will-never-match + restore-keys: harmont-v1- + - run: ./target/debug/hm cache restore .harmont-cache/ + - run: ./target/debug/hm run ci + env: + HM_NONINTERACTIVE: '1' + - run: | + hash=$(./target/debug/hm cache save .harmont-cache/) + echo "key=harmont-v1-${hash}" >> "$GITHUB_OUTPUT" + id: cache-manifest + if: always() + - uses: actions/cache/save@v4 + if: always() + with: + path: .harmont-cache/ + key: ${{ steps.cache-manifest.outputs.key }} +``` + +
+ +
+After (this action) + +```yaml +steps: + - uses: actions/checkout@v4 + - uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci +``` + +
+ +## FAQ + +### Do I need Docker on the runner? + +Yes. Harmont runs pipeline steps in Docker containers. Use `runs-on: ubuntu-latest` (Docker is pre-installed). + +### What about macOS / Windows runners? + +macOS runners have Docker available via colima/lima. Windows runners are not currently supported (harmont requires Linux containers). + +### Can I use a private registry instead of GHCR? + +Yes. Set `cache-registry` to your registry hostname and provide a token with push/pull access: + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci + cache-registry: registry.example.com + token: ${{ secrets.REGISTRY_TOKEN }} +``` + +### How do I disable caching entirely? + +```yaml +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci + cache: 'false' +``` + +### How do I force a clean cache rebuild? + +Delete the `harmont-cache` packages from your repo's GitHub Packages, or change `cache-registry-prefix` to a new path. + +### The first run is slow — is that expected? + +Yes. The first run has no cached images, so Docker pulls base images and builds from scratch. Subsequent runs reuse cached images and are significantly faster. + +### What permissions does cleanup need? + +`packages:delete` (part of the `packages: write` scope). If your token lacks this, set `cache-cleanup: 'false'` — images accumulate but nothing breaks. + +## License + +MIT +``` + +**Step 2: Validate all YAML code blocks in the README parse correctly** + +Run: `python3 -c "import yaml, re; content=open('README.md').read(); blocks=[b for b in re.findall(r'\`\`\`yaml\n(.*?)\`\`\`', content, re.DOTALL)]; [yaml.safe_load(b) for b in blocks]; print(f'{len(blocks)} YAML blocks valid')"` +Expected: All YAML blocks parse without error + +**Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add README with usage examples, inputs reference, and migration guide" +``` + +--- + +## Design Decisions + +### Why "That's it." after the first example? +Borrowed from hsrs. It signals confidence and tells the reader "no, really, that's all you need." It's the anti-enterprise-documentation move. + +### Why show the before/after migration in collapsible sections? +The 13-line "before" vs 3-line "after" is the most compelling visual argument for adopting the action. Collapsible keeps the README scannable for people who don't have an existing setup. + +### Why ASCII diagram instead of Mermaid for caching? +Mermaid doesn't render in all contexts (npm README, marketplace). ASCII art is universally rendered and fits the terminal-first personality of a CI tool. + +### Why FAQ instead of linking to issues? +Real questions deserve real answers in the README. Every FAQ entry is a support ticket that doesn't get filed. + +### Why no screenshot? +This action doesn't produce visible output (no job summary yet). A screenshot of a green CI check is generic and adds nothing. If/when harmont adds GHA job summaries, add a screenshot then.