From 7ac2e8fb203c977581757eede65e2f15a19479c9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Mon, 25 May 2026 03:07:40 -0700 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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. From d600295630c5b22db16b13cf32f87f80159de1bf Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:18:42 -0700 Subject: [PATCH 14/20] feat(cache-save): add hm-path input for custom binary location --- cache-save/action.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cache-save/action.yml b/cache-save/action.yml index 42572ae..711901e 100644 --- a/cache-save/action.yml +++ b/cache-save/action.yml @@ -36,6 +36,12 @@ inputs: Token for registry auth (needs packages:write, packages:delete for cleanup). required: false default: ${{ github.token }} + hm-path: + description: > + Path to the hm binary. Defaults to 'hm' (assumes it's on PATH). + Use './target/debug/hm' or similar when testing a locally-built binary. + required: false + default: hm runs: using: composite @@ -54,13 +60,14 @@ runs: env: INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} INPUT_REGISTRY: ${{ inputs.registry }} + INPUT_HM_PATH: ${{ inputs.hm-path }} GITHUB_REPOSITORY: ${{ github.repository }} working-directory: ${{ inputs.working-directory }} run: | prefix="${INPUT_REGISTRY_PREFIX:-${INPUT_REGISTRY}/${GITHUB_REPOSITORY}/harmont-cache}" echo "::group::Exporting image manifest" - hm cache save .harmont-cache/ > /dev/null + "$INPUT_HM_PATH" cache save .harmont-cache/ > /dev/null echo "::endgroup::" if [ ! -f .harmont-cache/manifest.json ]; then From e8e4d41fae47f61d4431e6c7ab5606671c225a29 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:21:26 -0700 Subject: [PATCH 15/20] feat: wire hm-path through root action, skip setup when provided --- action.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index a2c11ec..6732199 100644 --- a/action.yml +++ b/action.yml @@ -54,6 +54,12 @@ inputs: description: Additional arguments passed to 'hm run' required: false default: '' + hm-path: + description: > + Path to the hm binary. Use when testing a locally-built binary + instead of an installed release. Overrides setup step. + required: false + default: '' token: description: > GitHub token (needs packages:write for cache, packages:delete for cleanup) @@ -70,6 +76,7 @@ runs: steps: # --- Setup --- - name: Setup Harmont + if: inputs.hm-path == '' id: setup uses: ./setup with: @@ -92,6 +99,7 @@ runs: working-directory: ${{ inputs.working-directory }} env: HM_NONINTERACTIVE: '1' + INPUT_HM_PATH: ${{ inputs.hm-path || 'hm' }} INPUT_PIPELINE: ${{ inputs.pipeline }} INPUT_PARALLELISM: ${{ inputs.parallelism }} INPUT_EXTRA_ARGS: ${{ inputs.extra-args }} @@ -107,7 +115,7 @@ runs: read -ra extra <<< "$INPUT_EXTRA_ARGS" args+=("${extra[@]}") fi - hm run "${args[@]}" + "$INPUT_HM_PATH" run "${args[@]}" # --- Cache Save --- - name: Save Docker cache @@ -119,4 +127,5 @@ runs: working-directory: ${{ inputs.working-directory }} cleanup: ${{ inputs.cache-cleanup }} cleanup-keep: ${{ inputs.cache-cleanup-keep }} + hm-path: ${{ inputs.hm-path || 'hm' }} token: ${{ inputs.token }} From 97fa847b1a50513650bede88f9580d1b0fe5b672 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:24:23 -0700 Subject: [PATCH 16/20] test: add integration test for custom hm-path (dogfood pattern) --- .github/workflows/test-action.yml | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 42ae5b9..fffd26c 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -83,3 +83,53 @@ jobs: run: | echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}" hm --version + + custom-binary: + name: Custom hm-path (dogfood pattern) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Create mock hm binary + run: | + mkdir -p /tmp/mock-hm + cat > /tmp/mock-hm/hm << 'SCRIPT' + #!/bin/bash + case "$1" in + --version) echo "mock-0.0.1" ;; + cache) + case "$2" in + save) + mkdir -p "$3" + echo '{"images":{}}' > "$3/manifest.json" + echo "abc123" + ;; + restore) echo "restored" ;; + esac + ;; + run) echo "ran pipeline: $2" ;; + esac + SCRIPT + chmod +x /tmp/mock-hm/hm + + - name: Cache restore (no manifest yet, cold start) + uses: ./cache-restore + with: + working-directory: tests/fixtures + + - name: Run with custom binary + working-directory: tests/fixtures + env: + HM_NONINTERACTIVE: '1' + run: /tmp/mock-hm/hm run hello + + - name: Cache save with custom binary + if: always() + uses: ./cache-save + with: + working-directory: tests/fixtures + hm-path: /tmp/mock-hm/hm From f900711546ba4cbdf896af9dfeb757d85f35ced5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:24:30 -0700 Subject: [PATCH 17/20] docs: add custom binary / dogfood usage example to README --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index f5f1603..2699290 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,29 @@ jobs: if: always() ``` +### Custom binary (dogfood pattern) + +When testing a locally-built `hm` binary (e.g., the harmont-cli repo's own CI): + +```yaml +- name: Build hm from source + run: cargo build -p harmont-cli + +- uses: harmont-dev/actions-hm/cache-restore@v1 + +- run: ./target/debug/hm run ci + env: + HM_NONINTERACTIVE: '1' + +- uses: harmont-dev/actions-hm/cache-save@v1 + if: always() + with: + hm-path: ./target/debug/hm +``` + +The `hm-path` input tells cache-save where to find the binary. Cache-restore +doesn't need it — it uses Docker directly. + ### Pin to specific version ```yaml @@ -116,6 +139,7 @@ jobs: | `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 | +| `hm-path` | | Path to a locally-built `hm` binary (skips install; used for dogfooding) | | `extra-args` | | Additional arguments passed to `hm run` | | `token` | `github.token` | GitHub token (needs `packages:write`, `packages:delete` for cleanup) | From 88dedb9f064654ea95ce73698a844cd935b62987 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:33:05 -0700 Subject: [PATCH 18/20] docs: add implementation and migration plans --- .../plans/2026-05-25-harmont-github-action.md | 1000 +++++++++++++++++ docs/plans/2026-05-27-dogfood-migration.md | 477 ++++++++ 2 files changed, 1477 insertions(+) create mode 100644 docs/plans/2026-05-25-harmont-github-action.md create mode 100644 docs/plans/2026-05-27-dogfood-migration.md diff --git a/docs/plans/2026-05-25-harmont-github-action.md b/docs/plans/2026-05-25-harmont-github-action.md new file mode 100644 index 0000000..b2e978a --- /dev/null +++ b/docs/plans/2026-05-25-harmont-github-action.md @@ -0,0 +1,1000 @@ +# Harmont GitHub Action Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a GitHub Action (`harmont-dev/actions-hm`) that makes it trivial to run harmont pipelines inside GitHub Actions workflows — one step to adopt, zero Docker-caching boilerplate. + +**Architecture:** Four composite actions sharing shell scripts. `setup/` installs the `hm` binary and optional Python DSL. `cache-restore/` and `cache-save/` wrap the `hm cache restore`/`hm cache save` commands with GHA's `actions/cache`. The root `action.yml` composes all three into a single step: install → restore cache → `hm run` → save cache. All shell logic lives in `scripts/` and is tested with ShellCheck + bats. + +**Tech Stack:** YAML (composite actions), Bash (install/cache scripts), bats-core (script tests), ShellCheck (lint), GitHub Actions + +**Why split sub-actions?** Power users who run multiple pipelines or need custom steps between cache-restore and run can use the granular actions. Newcomers use the root all-in-one. + +**Target user experience:** + +```yaml +# One-liner adoption: +- uses: harmont-dev/actions-hm@v1 + with: + pipeline: ci + +# Granular control: +- uses: harmont-dev/actions-hm/setup@v1 +- uses: harmont-dev/actions-hm/cache-restore@v1 +- run: hm run ci +- uses: harmont-dev/actions-hm/cache-save@v1 + if: always() +``` + +**Repo structure at completion:** + +``` +actions-hm/ +├── action.yml # All-in-one composite +├── setup/ +│ └── action.yml # Install hm + optional Python DSL +├── cache-restore/ +│ └── action.yml # Restore Docker image cache +├── cache-save/ +│ └── action.yml # Save Docker image cache +├── scripts/ +│ ├── install-hm.sh # Download/install hm binary +│ └── resolve-version.sh # Resolve 'latest' to release tag +├── tests/ +│ ├── resolve-version.bats # Unit tests for version resolution +│ └── install-hm.bats # Unit tests for install script +├── .github/ +│ └── workflows/ +│ ├── ci.yml # Lint + unit tests for this action +│ └── test-action.yml # Integration test exercising the action +└── .gitignore +``` + +--- + +## Task 1: Project scaffolding and linting infrastructure + +**Files:** +- Create: `.gitignore` +- Create: `.github/workflows/ci.yml` + +**Step 1: Write `.gitignore`** + +```gitignore +# bats +test_helper/ +tests/tmp/ + +# OS +.DS_Store +``` + +**Step 2: Write CI workflow that runs ShellCheck + bats** + +This workflow will fail initially (no scripts exist yet). That's expected — it validates that our test infrastructure works. + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@2.0.0 + with: + scandir: scripts/ + + bats: + name: Bats tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install bats + run: | + sudo apt-get update && sudo apt-get install -y bats + - name: Run tests + run: bats tests/*.bats +``` + +**Step 3: Commit** + +```bash +git add .gitignore .github/workflows/ci.yml +git commit -m "chore: scaffold project with CI workflow" +``` + +--- + +## Task 2: Version resolution script + +Resolves a user-provided version input (`latest`, `1.2.3`, `v1.2.3`) into a concrete release tag. This is the smallest, most testable piece — start here. + +**Files:** +- Create: `scripts/resolve-version.sh` +- Create: `tests/resolve-version.bats` + +**Step 1: Write the failing test** + +```bash +#!/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" ] +} +``` + +**Step 2: Run tests, verify they fail** + +Run: `bats tests/resolve-version.bats` +Expected: FAIL — script doesn't exist + +**Step 3: Write minimal implementation** + +```bash +#!/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 +``` + +**Step 4: Make script executable and run tests** + +Run: `chmod +x scripts/resolve-version.sh && bats tests/resolve-version.bats` +Expected: All 4 tests PASS + +**Step 5: Commit** + +```bash +git add scripts/resolve-version.sh tests/resolve-version.bats +git commit -m "feat: add version resolution script with tests" +``` + +--- + +## Task 3: Install script + +Downloads the `hm` binary and adds it to `$GITHUB_PATH`. Tries GitHub releases first, falls back to `cargo-binstall`, then `cargo install`. + +**Files:** +- Create: `scripts/install-hm.sh` +- Create: `tests/install-hm.bats` + +**Step 1: Write the failing test** + +```bash +#!/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 the script to test the detect_platform function + 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" { + # This test verifies the path-registration logic in isolation + source "$INSTALL" + INSTALL_DIR="$TMPDIR/bin" + mkdir -p "$INSTALL_DIR" + register_path "$INSTALL_DIR" + grep -q "$INSTALL_DIR" "$GITHUB_PATH" +} +``` + +**Step 2: Run tests, verify they fail** + +Run: `bats tests/install-hm.bats` +Expected: FAIL — script doesn't exist + +**Step 3: Write implementation** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:?version tag required (e.g. v0.5.0)}" +INSTALL_DIR="${2:-${RUNNER_TOOL_CACHE:-/tmp}/hm/bin}" +REPO="harmont-dev/harmont-cli" + +detect_platform() { + local os="${OS:-$(uname -s)}" + local arch="${ARCH:-$(uname -m)}" + + 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 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 +``` + +**Step 4: Make executable and run tests** + +Run: `chmod +x scripts/install-hm.sh && bats tests/install-hm.bats` +Expected: All 4 tests PASS + +**Step 5: Commit** + +```bash +git add scripts/install-hm.sh tests/install-hm.bats +git commit -m "feat: add hm install script with platform detection and fallback chain" +``` + +--- + +## Task 4: Setup sub-action (`setup/action.yml`) + +Composite action that installs `hm` and optionally the Python DSL. + +**Files:** +- Create: `setup/action.yml` + +**Step 1: Write the action definition** + +```yaml +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::" +``` + +**Step 2: Validate YAML is well-formed** + +Run: `python3 -c "import yaml; yaml.safe_load(open('setup/action.yml'))"` +Expected: No error + +**Step 3: Commit** + +```bash +git add setup/action.yml +git commit -m "feat: add setup sub-action for hm CLI installation" +``` + +--- + +## Task 5: Cache restore sub-action (`cache-restore/action.yml`) + +Wraps the `actions/cache/restore` + `hm cache restore` pattern from harmont's dogfood job. + +**Files:** +- Create: `cache-restore/action.yml` + +**Step 1: Write the action definition** + +Key design decisions lifted from harmont's CI: +- Primary key intentionally never matches → forces prefix-based restore of most recent cache +- Prefix `harmont-v1-` allows cache invalidation by bumping the version +- Cache path is `.harmont-cache/` relative to working directory + +```yaml +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::" +``` + +**Step 2: Validate YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('cache-restore/action.yml'))"` +Expected: No error + +**Step 3: Commit** + +```bash +git add cache-restore/action.yml +git commit -m "feat: add cache-restore sub-action wrapping hm cache restore" +``` + +--- + +## Task 6: Cache save sub-action (`cache-save/action.yml`) + +Wraps `hm cache save` + `actions/cache/save` with content-addressed keys. Designed to run with `if: always()` so cache is saved even on pipeline failure. + +**Files:** +- Create: `cache-save/action.yml` + +**Step 1: Write the action definition** + +```yaml +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 }} +``` + +**Step 2: Validate YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('cache-save/action.yml'))"` +Expected: No error + +**Step 3: Commit** + +```bash +git add cache-save/action.yml +git commit -m "feat: add cache-save sub-action with content-addressed keys" +``` + +--- + +## Task 7: All-in-one root action (`action.yml`) + +Composes setup + cache-restore + `hm run` + cache-save into a single step. This is the primary entry point for new users. + +**Files:** +- Create: `action.yml` + +**Step 1: Write the action definition** + +```yaml +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 }} +``` + +**Step 2: Validate YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('action.yml'))"` +Expected: No error + +**Step 3: Commit** + +```bash +git add action.yml +git commit -m "feat: add all-in-one root action composing setup + cache + run" +``` + +--- + +## Task 8: Integration test workflow + +A workflow that exercises the action end-to-end. Uses a minimal inline harmont pipeline to verify the full flow. + +**Files:** +- Create: `.github/workflows/test-action.yml` +- Create: `tests/fixtures/.harmont/hello.py` + +**Step 1: Create a minimal test pipeline** + +```python +import harmont as hm + + +@hm.pipeline("hello") +def hello() -> hm.Step: + return hm.sh("echo 'hello from harmont action test'", label="greet") +``` + +**Step 2: Write the test workflow** + +```yaml +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 }}" +``` + +**Step 3: Commit** + +```bash +git add tests/fixtures/.harmont/hello.py .github/workflows/test-action.yml +git commit -m "feat: add integration test workflow with fixture pipeline" +``` + +--- + +## Task 9: Update CI workflow with complete checks + +Now that all files exist, update CI to lint everything and add YAML validation. + +**Files:** +- Modify: `.github/workflows/ci.yml` + +**Step 1: Update the CI workflow** + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@2.0.0 + with: + scandir: scripts/ + + bats: + name: Bats tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install 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." +``` + +**Step 2: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "chore: add YAML validation to CI" +``` + +--- + +## Task 10: Final review and polish + +**Step 1: Run ShellCheck locally** + +Run: `shellcheck scripts/*.sh` +Expected: No warnings (or fix any that appear) + +**Step 2: Run bats locally** + +Run: `bats tests/*.bats` +Expected: All tests pass + +**Step 3: Verify all YAML files parse** + +Run: `for f in action.yml setup/action.yml cache-restore/action.yml cache-save/action.yml; do python3 -c "import yaml; yaml.safe_load(open('$f'))" && echo "OK: $f"; done` +Expected: All OK + +**Step 4: Review the composite action `uses:` references** + +Verify that: +- `action.yml` uses `./setup`, `./cache-restore`, `./cache-save` (relative paths) +- `setup/action.yml` references `${{ github.action_path }}/../scripts/` (correct traversal) + +**Step 5: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "chore: polish scripts and fix lint warnings" +``` + +--- + +## Design Decisions Log + +### Why composite actions (not JavaScript)? +- Zero build step, zero node_modules — the action works from a tag checkout +- Shell scripts are transparent and auditable +- `actions/cache` handles the hard parts (GHA cache API) already +- Composite actions support `if: always()` on steps, which solves the post-step cache save problem + +### Why separate cache-restore and cache-save? +- Harmont's own CI uses `actions/cache/restore` and `actions/cache/save` separately (not the combined `actions/cache`) because the cache key is only known after `hm cache save` computes the content hash +- Splitting lets users do work between restore and save (e.g., multiple `hm run` calls) +- The `if: always()` pattern for save is explicit and visible in user workflows + +### Why the "never-match primary key" pattern? +Lifted directly from harmont's dogfood job. The primary key `harmont-v1-will-never-match` intentionally never matches exactly, forcing GHA to use `restore-keys` prefix matching. This always restores the most recent cache entry rather than an exact stale match. Combined with content-addressed save keys (`harmont-v1-${hash}`), this ensures: +- Cache is always warm (prefix match) +- New entries are only created when images change (content hash) +- No cache thrashing + +### Why default `install-python-dsl: 'true'` in root action but `'false'` in setup? +- Root action is the "just works" path → install everything the user likely needs +- Setup sub-action is the "I know what I'm doing" path → minimal by default + +### Why `HM_NONINTERACTIVE=1`? +Harmont prompts for user input in some scenarios. In CI there's no TTY, so this env var tells harmont to use defaults or fail instead of hanging. + +### Fallback install chain: release binary → cargo-binstall → cargo install +- Prebuilt binary: fastest, no toolchain needed (seconds) +- cargo-binstall: fast, auto-detects platform binaries from crates.io metadata +- cargo install: always works but slow (compiles from source, minutes) +- Most users will hit the fast path once harmont publishes release binaries diff --git a/docs/plans/2026-05-27-dogfood-migration.md b/docs/plans/2026-05-27-dogfood-migration.md new file mode 100644 index 0000000..56cf73e --- /dev/null +++ b/docs/plans/2026-05-27-dogfood-migration.md @@ -0,0 +1,477 @@ +# Dogfood Pipeline Migration: harmont-cli-2 → actions-hm + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable `harmont-cli-2`'s dogfood CI job to use `actions-hm` sub-actions for Docker image caching, replacing 8 manual cache steps with 2 action calls while preserving the build-from-source requirement. + +**Architecture:** Add a `hm-path` input to `cache-save/action.yml` so it can use a locally-built binary instead of requiring `hm` on `$PATH`. The `cache-restore` sub-action already works without `hm` (pure Docker operations). The root all-in-one action stays unchanged — it's for end users, not dogfood. The dogfood workflow will use the granular sub-actions directly, skipping `setup/` entirely (it builds from source). + +**Tech Stack:** YAML (composite actions), Bash (shell scripts), GitHub Actions, GHCR (registry cache) + +--- + +## Evaluation Summary + +### What the dogfood pipeline does today + +In `harmont-cli-2/.github/workflows/ci.yml`, the `dogfood` job: + +1. Builds `hm` from source (`cargo build -p harmont-cli`) +2. Enables FUSE `allow_other` (`sudo sed -i ...`) +3. Restores `.harmont-cache/` from GHA file cache (`actions/cache/restore@v4`) +4. Loads Docker images (`./target/debug/hm cache restore .harmont-cache/`) +5. Runs the pipeline (`./target/debug/hm run ci`) +6. Exports manifest (`./target/debug/hm cache save .harmont-cache/`) +7. Computes content-addressed key from hash output +8. Saves `.harmont-cache/` to GHA file cache (`actions/cache/save@v4`) + +Steps 3-4 and 6-8 are caching boilerplate. Steps 1-2 and 5 are dogfood-specific. + +### What actions-hm provides today + +| Sub-action | What it does | Needs `hm` binary? | Dogfood-compatible? | +|---|---|---|---| +| `setup/` | Downloads released binary | N/A | **No** — dogfood builds from source | +| `cache-restore/` | Docker login → pull manifest image → pull cached images → retag | **No** | **Yes** | +| `cache-save/` | `hm cache save` → push images → push manifest image → cleanup stale | **Yes** (calls `hm cache save`) | **Almost** — needs `hm` on PATH | + +### Gap analysis + +| Gap | Severity | Fix | +|---|---|---| +| `cache-save` hardcodes `hm` command name | **Blocking** | Add `hm-path` input, default `hm` | +| No FUSE setup | **Non-issue** | Dogfood handles this before the action | +| No `cargo build` | **Non-issue** | Dogfood handles this before the action | +| Registry cache vs GHA file cache | **Upgrade** | GHCR has no 10GB limit, native layer dedup | +| Needs `packages:write` + `packages:delete` permissions | **Minor** | Already standard for GHCR; add to dogfood workflow permissions | + +### Migration benefit + +**Before (8 steps):** +```yaml +- uses: actions/cache/restore@v4 # restore GHA cache +- run: hm cache restore .harmont-cache/ # load images +- run: hm run ci # run pipeline +- run: hm cache save .harmont-cache/ # export manifest +- uses: actions/cache/save@v4 # save GHA cache +``` +Plus `id:`, `if:`, `key:`, `restore-keys:` boilerplate on each step. + +**After (3 steps):** +```yaml +- uses: harmont-dev/actions-hm/cache-restore@v1 # pull from GHCR +- run: ./target/debug/hm run ci # run pipeline +- uses: harmont-dev/actions-hm/cache-save@v1 # push to GHCR + cleanup +``` + +Also gains automatic stale image cleanup (keeps N old versions per step). + +--- + +## Task 1: Add `hm-path` input to `cache-save/action.yml` + +The `cache-save` sub-action calls `hm cache save .harmont-cache/` on line 63. The dogfood pipeline builds `hm` at `./target/debug/hm` — it's not on `$PATH`. Adding an `hm-path` input lets callers point to a custom binary. + +**Files:** +- Modify: `cache-save/action.yml:1-10` (add input) +- Modify: `cache-save/action.yml:60-65` (use input in run step) + +**Step 1: Write the failing test scenario** + +No bats test exists for sub-actions (they're integration-tested via `test-action.yml`). Verify manually that current `cache-save` hardcodes `hm`: + +Run: `grep -n 'hm cache save' cache-save/action.yml` +Expected: Line ~63, `hm cache save .harmont-cache/` + +**Step 2: Add `hm-path` input to `cache-save/action.yml`** + +Add to the `inputs:` block after `token:`: + +```yaml + hm-path: + description: > + Path to the hm binary. Defaults to 'hm' (assumes it's on PATH). + Use './target/debug/hm' or similar when testing a locally-built binary. + required: false + default: hm +``` + +**Step 3: Wire `hm-path` into the export step** + +In the "Export manifest and push images" step, add `INPUT_HM_PATH: ${{ inputs.hm-path }}` to the `env:` block and replace the `hm cache save` call: + +Change line ~63 from: +```bash +hm cache save .harmont-cache/ > /dev/null +``` +To: +```bash +"$INPUT_HM_PATH" cache save .harmont-cache/ > /dev/null +``` + +**Step 4: Validate YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('cache-save/action.yml'))"` +Expected: No error + +**Step 5: Commit** + +```bash +git add cache-save/action.yml +git commit -m "feat(cache-save): add hm-path input for custom binary location" +``` + +--- + +## Task 2: Wire `hm-path` through root `action.yml` + +The root all-in-one action should pass through `hm-path` to `cache-save` so power users of the root action can also use a custom binary. This keeps the granular and all-in-one paths consistent. + +**Files:** +- Modify: `action.yml:10-60` (add input) +- Modify: `action.yml:90-123` (pass to run step and cache-save) + +**Step 1: Add `hm-path` input to root `action.yml`** + +Add to inputs after `extra-args`: + +```yaml + hm-path: + description: > + Path to the hm binary. Use when testing a locally-built binary + instead of an installed release. Overrides setup step. + required: false + default: '' +``` + +**Step 2: Make the run step use `hm-path` when set** + +In the "Run harmont pipeline" step, change the env block to include: +```yaml +INPUT_HM_PATH: ${{ inputs.hm-path || 'hm' }} +``` + +And change the run command from `hm run "${args[@]}"` to `"$INPUT_HM_PATH" run "${args[@]}"`. + +**Step 3: Pass `hm-path` to cache-save** + +In the "Save Docker cache" step, add: +```yaml +hm-path: ${{ inputs.hm-path || 'hm' }} +``` + +**Step 4: Conditionally skip setup when hm-path is set** + +Add `if: inputs.hm-path == ''` to the "Setup Harmont" step. If the user provides their own binary, there's no need to download one. + +**Step 5: Validate YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('action.yml'))"` +Expected: No error + +**Step 6: Commit** + +```bash +git add action.yml +git commit -m "feat: wire hm-path through root action, skip setup when provided" +``` + +--- + +## Task 3: Add `hm-path` to integration tests + +Verify the new input works in CI by adding a test job that builds `hm` from source (or mocks it) and uses `hm-path`. + +**Files:** +- Modify: `.github/workflows/test-action.yml` + +**Step 1: Add a `custom-binary` test job** + +Add after the existing jobs: + +```yaml + custom-binary: + name: Custom hm-path (dogfood pattern) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Create mock hm binary + run: | + mkdir -p /tmp/mock-hm + cat > /tmp/mock-hm/hm << 'SCRIPT' + #!/bin/bash + case "$1" in + --version) echo "mock-0.0.1" ;; + cache) + case "$2" in + save) + mkdir -p "$3" + echo '{"images":{}}' > "$3/manifest.json" + echo "abc123" + ;; + restore) echo "restored" ;; + esac + ;; + run) echo "ran pipeline: $2" ;; + esac + SCRIPT + chmod +x /tmp/mock-hm/hm + + - name: Cache restore (no manifest yet, cold start) + uses: ./cache-restore + with: + working-directory: tests/fixtures + + - name: Run with custom binary + working-directory: tests/fixtures + env: + HM_NONINTERACTIVE: '1' + run: /tmp/mock-hm/hm run hello + + - name: Cache save with custom binary + if: always() + uses: ./cache-save + with: + working-directory: tests/fixtures + hm-path: /tmp/mock-hm/hm +``` + +**Step 2: Validate YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test-action.yml'))"` +Expected: No error + +**Step 3: Commit** + +```bash +git add .github/workflows/test-action.yml +git commit -m "test: add integration test for custom hm-path (dogfood pattern)" +``` + +--- + +## Task 4: Write the migrated dogfood workflow + +Create the replacement dogfood job that uses `actions-hm` sub-actions. This lives in `harmont-cli-2`, not this repo. Document it here as a reference for the migration PR. + +**Files:** +- Reference only: `harmont-cli-2/.github/workflows/ci.yml` (dogfood job) + +**Step 1: Document the target workflow** + +The migrated `dogfood` job in `harmont-cli-2/.github/workflows/ci.yml` should look like: + +```yaml + dogfood: + name: dogfood (hm run ci) + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + packages: write + packages: delete # for stale image cleanup + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-node@v4 + with: + node-version: "23" + cache: npm + cache-dependency-path: dsls/harmont-ts/package-lock.json + + - name: Install esbuild (for harmont-ts bundle) + working-directory: dsls/harmont-ts + run: npm ci + + - name: Build hm + run: cargo build -p harmont-cli + + - name: Enable FUSE allow_other + run: sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf + + - name: Restore Docker cache + uses: harmont-dev/actions-hm/cache-restore@v1 + + - name: hm run ci + env: + HM_NONINTERACTIVE: '1' + run: ./target/debug/hm run ci + + - name: Save Docker cache + if: always() + uses: harmont-dev/actions-hm/cache-save@v1 + with: + hm-path: ./target/debug/hm +``` + +**Step 2: Diff comparison** + +Lines removed from current dogfood job: +```yaml +# These 5 steps become 2 action calls: +- uses: actions/cache/restore@v4 # REMOVED + with: + path: .harmont-cache/ + key: harmont-v1-will-never-match + restore-keys: harmont-v1- + +- run: ./target/debug/hm cache restore .harmont-cache/ # REMOVED + +- id: cache-manifest # REMOVED + run: hash=$(./target/debug/hm cache save .harmont-cache/) + echo "key=harmont-v1-${hash}" >> "$GITHUB_OUTPUT" + +- uses: actions/cache/save@v4 # REMOVED + with: + path: .harmont-cache/ + key: ${{ steps.cache-manifest.outputs.key }} +``` + +Lines added: +```yaml +- uses: harmont-dev/actions-hm/cache-restore@v1 # NEW (1 step replaces 2) + +- uses: harmont-dev/actions-hm/cache-save@v1 # NEW (1 step replaces 3) + with: + hm-path: ./target/debug/hm +``` + +**Net change:** -5 steps, +2 steps, +stale image cleanup for free. + +**Step 3: Commit plan documentation** + +```bash +git add docs/plans/2026-05-27-dogfood-migration.md +git commit -m "docs: add dogfood migration evaluation and plan" +``` + +--- + +## Task 5: Verify `cache-restore` works without `hm` binary + +Confirm that `cache-restore/action.yml` has zero dependency on the `hm` binary. This is a read-only verification — no code changes expected. + +**Files:** +- Read: `cache-restore/action.yml` + +**Step 1: Grep for `hm` commands** + +Run: `grep -n 'hm ' cache-restore/action.yml` +Expected: No matches (only Docker and Python commands) + +**Step 2: Verify the restore flow** + +The restore sub-action should only use: +- `docker login` — authenticate to registry +- `docker pull` — pull manifest image and cached images +- `docker create` / `docker cp` / `docker rm` — extract manifest.json +- `docker tag` — retag registry images as `harmont-local/*` +- `python3 -c` — parse manifest.json + +If `hm` appears anywhere, it needs an `hm-path` input too (same as Task 1). + +**Step 3: Document result** + +Run: `echo "cache-restore has no hm dependency: $(grep -c 'hm cache' cache-restore/action.yml) matches"` +Expected: `0 matches` — confirmed no dependency. + +--- + +## Task 6: Update README with dogfood/custom-binary usage + +Add a section to `README.md` showing how to use the action with a locally-built binary (the dogfood pattern). + +**Files:** +- Modify: `README.md` + +**Step 1: Add a "Custom binary" section** + +Add under the existing "Granular sub-actions" section: + +```markdown +### Custom binary (dogfood pattern) + +When testing a locally-built `hm` binary (e.g., the harmont-cli repo's own CI): + +```yaml +- name: Build hm from source + run: cargo build -p harmont-cli + +- uses: harmont-dev/actions-hm/cache-restore@v1 + +- run: ./target/debug/hm run ci + env: + HM_NONINTERACTIVE: '1' + +- uses: harmont-dev/actions-hm/cache-save@v1 + if: always() + with: + hm-path: ./target/debug/hm +``` + +The `hm-path` input tells cache-save where to find the binary. Cache-restore +doesn't need it — it uses Docker directly. +``` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add custom binary / dogfood usage example to README" +``` + +--- + +## Caching: GHA file cache vs GHCR registry + +The migration switches caching from GHA file cache to GHCR registry. Comparison: + +| | GHA file cache (current) | GHCR registry (actions-hm) | +|---|---|---| +| Size limit | 10 GB total per repo | Unlimited (GHCR storage) | +| Granularity | Single `.harmont-cache/` tarball | Per-image, per-tag | +| Layer dedup | None (full tarball each time) | Native Docker layer dedup | +| Cross-branch | Shared via prefix match | Shared (same registry) | +| Stale cleanup | Manual / GHA eviction | Automatic (configurable keep-N) | +| Auth | `GITHUB_TOKEN` (contents read) | `GITHUB_TOKEN` (packages write/delete) | +| Cold start cost | Full tarball download | Per-image parallel pulls | + +**Verdict:** Registry cache is strictly better for the dogfood use case. Larger Docker image sets won't hit the 10GB cap, and layer dedup means incremental changes push/pull only deltas. + +--- + +## Risk assessment + +| Risk | Likelihood | Mitigation | +|---|---|---| +| GHCR rate limits on heavy push/pull | Low | GitHub-hosted runners have generous limits to GHCR | +| `packages:write` permission not granted | Low | Add to `permissions:` block in workflow | +| Registry cold start slower than file cache | Medium | First run pulls nothing (cold start same as today). Subsequent runs may be faster due to layer dedup | +| `hm cache save` output format changes | Low | Pin `actions-hm` to a tag; manifest.json format is stable | + +--- + +## Execution order + +Tasks 1-3 are in this repo (actions-hm). Task 4 is a migration PR in harmont-cli-2. Task 5 is verification. Task 6 is documentation. + +Dependency graph: +``` +Task 1 (hm-path in cache-save) ─┬─→ Task 2 (wire through root action) + ├─→ Task 3 (integration test) + ├─→ Task 4 (migration PR in harmont-cli-2) + └─→ Task 6 (README update) +Task 5 (verify cache-restore) ──────→ (independent, can run first) +``` + +Tasks 2, 3, 5, 6 can all run in parallel after Task 1. +Task 4 depends on Task 1 being merged + tagged. From 13e24f14a727638dafa3e74f576aa8bc8c204862 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:37:20 -0700 Subject: [PATCH 19/20] cleanup slop --- .../plans/2026-05-25-harmont-github-action.md | 1000 ----------------- docs/plans/2026-05-25-readme.md | 327 ------ docs/plans/2026-05-27-dogfood-migration.md | 477 -------- 3 files changed, 1804 deletions(-) delete mode 100644 docs/plans/2026-05-25-harmont-github-action.md delete mode 100644 docs/plans/2026-05-25-readme.md delete mode 100644 docs/plans/2026-05-27-dogfood-migration.md diff --git a/docs/plans/2026-05-25-harmont-github-action.md b/docs/plans/2026-05-25-harmont-github-action.md deleted file mode 100644 index b2e978a..0000000 --- a/docs/plans/2026-05-25-harmont-github-action.md +++ /dev/null @@ -1,1000 +0,0 @@ -# Harmont GitHub Action Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Create a GitHub Action (`harmont-dev/actions-hm`) that makes it trivial to run harmont pipelines inside GitHub Actions workflows — one step to adopt, zero Docker-caching boilerplate. - -**Architecture:** Four composite actions sharing shell scripts. `setup/` installs the `hm` binary and optional Python DSL. `cache-restore/` and `cache-save/` wrap the `hm cache restore`/`hm cache save` commands with GHA's `actions/cache`. The root `action.yml` composes all three into a single step: install → restore cache → `hm run` → save cache. All shell logic lives in `scripts/` and is tested with ShellCheck + bats. - -**Tech Stack:** YAML (composite actions), Bash (install/cache scripts), bats-core (script tests), ShellCheck (lint), GitHub Actions - -**Why split sub-actions?** Power users who run multiple pipelines or need custom steps between cache-restore and run can use the granular actions. Newcomers use the root all-in-one. - -**Target user experience:** - -```yaml -# One-liner adoption: -- uses: harmont-dev/actions-hm@v1 - with: - pipeline: ci - -# Granular control: -- uses: harmont-dev/actions-hm/setup@v1 -- uses: harmont-dev/actions-hm/cache-restore@v1 -- run: hm run ci -- uses: harmont-dev/actions-hm/cache-save@v1 - if: always() -``` - -**Repo structure at completion:** - -``` -actions-hm/ -├── action.yml # All-in-one composite -├── setup/ -│ └── action.yml # Install hm + optional Python DSL -├── cache-restore/ -│ └── action.yml # Restore Docker image cache -├── cache-save/ -│ └── action.yml # Save Docker image cache -├── scripts/ -│ ├── install-hm.sh # Download/install hm binary -│ └── resolve-version.sh # Resolve 'latest' to release tag -├── tests/ -│ ├── resolve-version.bats # Unit tests for version resolution -│ └── install-hm.bats # Unit tests for install script -├── .github/ -│ └── workflows/ -│ ├── ci.yml # Lint + unit tests for this action -│ └── test-action.yml # Integration test exercising the action -└── .gitignore -``` - ---- - -## Task 1: Project scaffolding and linting infrastructure - -**Files:** -- Create: `.gitignore` -- Create: `.github/workflows/ci.yml` - -**Step 1: Write `.gitignore`** - -```gitignore -# bats -test_helper/ -tests/tmp/ - -# OS -.DS_Store -``` - -**Step 2: Write CI workflow that runs ShellCheck + bats** - -This workflow will fail initially (no scripts exist yet). That's expected — it validates that our test infrastructure works. - -```yaml -name: CI - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 - with: - scandir: scripts/ - - bats: - name: Bats tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install bats - run: | - sudo apt-get update && sudo apt-get install -y bats - - name: Run tests - run: bats tests/*.bats -``` - -**Step 3: Commit** - -```bash -git add .gitignore .github/workflows/ci.yml -git commit -m "chore: scaffold project with CI workflow" -``` - ---- - -## Task 2: Version resolution script - -Resolves a user-provided version input (`latest`, `1.2.3`, `v1.2.3`) into a concrete release tag. This is the smallest, most testable piece — start here. - -**Files:** -- Create: `scripts/resolve-version.sh` -- Create: `tests/resolve-version.bats` - -**Step 1: Write the failing test** - -```bash -#!/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" ] -} -``` - -**Step 2: Run tests, verify they fail** - -Run: `bats tests/resolve-version.bats` -Expected: FAIL — script doesn't exist - -**Step 3: Write minimal implementation** - -```bash -#!/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 -``` - -**Step 4: Make script executable and run tests** - -Run: `chmod +x scripts/resolve-version.sh && bats tests/resolve-version.bats` -Expected: All 4 tests PASS - -**Step 5: Commit** - -```bash -git add scripts/resolve-version.sh tests/resolve-version.bats -git commit -m "feat: add version resolution script with tests" -``` - ---- - -## Task 3: Install script - -Downloads the `hm` binary and adds it to `$GITHUB_PATH`. Tries GitHub releases first, falls back to `cargo-binstall`, then `cargo install`. - -**Files:** -- Create: `scripts/install-hm.sh` -- Create: `tests/install-hm.bats` - -**Step 1: Write the failing test** - -```bash -#!/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 the script to test the detect_platform function - 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" { - # This test verifies the path-registration logic in isolation - source "$INSTALL" - INSTALL_DIR="$TMPDIR/bin" - mkdir -p "$INSTALL_DIR" - register_path "$INSTALL_DIR" - grep -q "$INSTALL_DIR" "$GITHUB_PATH" -} -``` - -**Step 2: Run tests, verify they fail** - -Run: `bats tests/install-hm.bats` -Expected: FAIL — script doesn't exist - -**Step 3: Write implementation** - -```bash -#!/usr/bin/env bash -set -euo pipefail - -VERSION="${1:?version tag required (e.g. v0.5.0)}" -INSTALL_DIR="${2:-${RUNNER_TOOL_CACHE:-/tmp}/hm/bin}" -REPO="harmont-dev/harmont-cli" - -detect_platform() { - local os="${OS:-$(uname -s)}" - local arch="${ARCH:-$(uname -m)}" - - 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 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 -``` - -**Step 4: Make executable and run tests** - -Run: `chmod +x scripts/install-hm.sh && bats tests/install-hm.bats` -Expected: All 4 tests PASS - -**Step 5: Commit** - -```bash -git add scripts/install-hm.sh tests/install-hm.bats -git commit -m "feat: add hm install script with platform detection and fallback chain" -``` - ---- - -## Task 4: Setup sub-action (`setup/action.yml`) - -Composite action that installs `hm` and optionally the Python DSL. - -**Files:** -- Create: `setup/action.yml` - -**Step 1: Write the action definition** - -```yaml -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::" -``` - -**Step 2: Validate YAML is well-formed** - -Run: `python3 -c "import yaml; yaml.safe_load(open('setup/action.yml'))"` -Expected: No error - -**Step 3: Commit** - -```bash -git add setup/action.yml -git commit -m "feat: add setup sub-action for hm CLI installation" -``` - ---- - -## Task 5: Cache restore sub-action (`cache-restore/action.yml`) - -Wraps the `actions/cache/restore` + `hm cache restore` pattern from harmont's dogfood job. - -**Files:** -- Create: `cache-restore/action.yml` - -**Step 1: Write the action definition** - -Key design decisions lifted from harmont's CI: -- Primary key intentionally never matches → forces prefix-based restore of most recent cache -- Prefix `harmont-v1-` allows cache invalidation by bumping the version -- Cache path is `.harmont-cache/` relative to working directory - -```yaml -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::" -``` - -**Step 2: Validate YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('cache-restore/action.yml'))"` -Expected: No error - -**Step 3: Commit** - -```bash -git add cache-restore/action.yml -git commit -m "feat: add cache-restore sub-action wrapping hm cache restore" -``` - ---- - -## Task 6: Cache save sub-action (`cache-save/action.yml`) - -Wraps `hm cache save` + `actions/cache/save` with content-addressed keys. Designed to run with `if: always()` so cache is saved even on pipeline failure. - -**Files:** -- Create: `cache-save/action.yml` - -**Step 1: Write the action definition** - -```yaml -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 }} -``` - -**Step 2: Validate YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('cache-save/action.yml'))"` -Expected: No error - -**Step 3: Commit** - -```bash -git add cache-save/action.yml -git commit -m "feat: add cache-save sub-action with content-addressed keys" -``` - ---- - -## Task 7: All-in-one root action (`action.yml`) - -Composes setup + cache-restore + `hm run` + cache-save into a single step. This is the primary entry point for new users. - -**Files:** -- Create: `action.yml` - -**Step 1: Write the action definition** - -```yaml -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 }} -``` - -**Step 2: Validate YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('action.yml'))"` -Expected: No error - -**Step 3: Commit** - -```bash -git add action.yml -git commit -m "feat: add all-in-one root action composing setup + cache + run" -``` - ---- - -## Task 8: Integration test workflow - -A workflow that exercises the action end-to-end. Uses a minimal inline harmont pipeline to verify the full flow. - -**Files:** -- Create: `.github/workflows/test-action.yml` -- Create: `tests/fixtures/.harmont/hello.py` - -**Step 1: Create a minimal test pipeline** - -```python -import harmont as hm - - -@hm.pipeline("hello") -def hello() -> hm.Step: - return hm.sh("echo 'hello from harmont action test'", label="greet") -``` - -**Step 2: Write the test workflow** - -```yaml -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 }}" -``` - -**Step 3: Commit** - -```bash -git add tests/fixtures/.harmont/hello.py .github/workflows/test-action.yml -git commit -m "feat: add integration test workflow with fixture pipeline" -``` - ---- - -## Task 9: Update CI workflow with complete checks - -Now that all files exist, update CI to lint everything and add YAML validation. - -**Files:** -- Modify: `.github/workflows/ci.yml` - -**Step 1: Update the CI workflow** - -```yaml -name: CI - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 - with: - scandir: scripts/ - - bats: - name: Bats tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install 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." -``` - -**Step 2: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "chore: add YAML validation to CI" -``` - ---- - -## Task 10: Final review and polish - -**Step 1: Run ShellCheck locally** - -Run: `shellcheck scripts/*.sh` -Expected: No warnings (or fix any that appear) - -**Step 2: Run bats locally** - -Run: `bats tests/*.bats` -Expected: All tests pass - -**Step 3: Verify all YAML files parse** - -Run: `for f in action.yml setup/action.yml cache-restore/action.yml cache-save/action.yml; do python3 -c "import yaml; yaml.safe_load(open('$f'))" && echo "OK: $f"; done` -Expected: All OK - -**Step 4: Review the composite action `uses:` references** - -Verify that: -- `action.yml` uses `./setup`, `./cache-restore`, `./cache-save` (relative paths) -- `setup/action.yml` references `${{ github.action_path }}/../scripts/` (correct traversal) - -**Step 5: Final commit if any fixes were needed** - -```bash -git add -A -git commit -m "chore: polish scripts and fix lint warnings" -``` - ---- - -## Design Decisions Log - -### Why composite actions (not JavaScript)? -- Zero build step, zero node_modules — the action works from a tag checkout -- Shell scripts are transparent and auditable -- `actions/cache` handles the hard parts (GHA cache API) already -- Composite actions support `if: always()` on steps, which solves the post-step cache save problem - -### Why separate cache-restore and cache-save? -- Harmont's own CI uses `actions/cache/restore` and `actions/cache/save` separately (not the combined `actions/cache`) because the cache key is only known after `hm cache save` computes the content hash -- Splitting lets users do work between restore and save (e.g., multiple `hm run` calls) -- The `if: always()` pattern for save is explicit and visible in user workflows - -### Why the "never-match primary key" pattern? -Lifted directly from harmont's dogfood job. The primary key `harmont-v1-will-never-match` intentionally never matches exactly, forcing GHA to use `restore-keys` prefix matching. This always restores the most recent cache entry rather than an exact stale match. Combined with content-addressed save keys (`harmont-v1-${hash}`), this ensures: -- Cache is always warm (prefix match) -- New entries are only created when images change (content hash) -- No cache thrashing - -### Why default `install-python-dsl: 'true'` in root action but `'false'` in setup? -- Root action is the "just works" path → install everything the user likely needs -- Setup sub-action is the "I know what I'm doing" path → minimal by default - -### Why `HM_NONINTERACTIVE=1`? -Harmont prompts for user input in some scenarios. In CI there's no TTY, so this env var tells harmont to use defaults or fail instead of hanging. - -### Fallback install chain: release binary → cargo-binstall → cargo install -- Prebuilt binary: fastest, no toolchain needed (seconds) -- cargo-binstall: fast, auto-detects platform binaries from crates.io metadata -- cargo install: always works but slow (compiles from source, minutes) -- Most users will hit the fast path once harmont publishes release binaries diff --git a/docs/plans/2026-05-25-readme.md b/docs/plans/2026-05-25-readme.md deleted file mode 100644 index af3012c..0000000 --- a/docs/plans/2026-05-25-readme.md +++ /dev/null @@ -1,327 +0,0 @@ -# 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. diff --git a/docs/plans/2026-05-27-dogfood-migration.md b/docs/plans/2026-05-27-dogfood-migration.md deleted file mode 100644 index 56cf73e..0000000 --- a/docs/plans/2026-05-27-dogfood-migration.md +++ /dev/null @@ -1,477 +0,0 @@ -# Dogfood Pipeline Migration: harmont-cli-2 → actions-hm - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Enable `harmont-cli-2`'s dogfood CI job to use `actions-hm` sub-actions for Docker image caching, replacing 8 manual cache steps with 2 action calls while preserving the build-from-source requirement. - -**Architecture:** Add a `hm-path` input to `cache-save/action.yml` so it can use a locally-built binary instead of requiring `hm` on `$PATH`. The `cache-restore` sub-action already works without `hm` (pure Docker operations). The root all-in-one action stays unchanged — it's for end users, not dogfood. The dogfood workflow will use the granular sub-actions directly, skipping `setup/` entirely (it builds from source). - -**Tech Stack:** YAML (composite actions), Bash (shell scripts), GitHub Actions, GHCR (registry cache) - ---- - -## Evaluation Summary - -### What the dogfood pipeline does today - -In `harmont-cli-2/.github/workflows/ci.yml`, the `dogfood` job: - -1. Builds `hm` from source (`cargo build -p harmont-cli`) -2. Enables FUSE `allow_other` (`sudo sed -i ...`) -3. Restores `.harmont-cache/` from GHA file cache (`actions/cache/restore@v4`) -4. Loads Docker images (`./target/debug/hm cache restore .harmont-cache/`) -5. Runs the pipeline (`./target/debug/hm run ci`) -6. Exports manifest (`./target/debug/hm cache save .harmont-cache/`) -7. Computes content-addressed key from hash output -8. Saves `.harmont-cache/` to GHA file cache (`actions/cache/save@v4`) - -Steps 3-4 and 6-8 are caching boilerplate. Steps 1-2 and 5 are dogfood-specific. - -### What actions-hm provides today - -| Sub-action | What it does | Needs `hm` binary? | Dogfood-compatible? | -|---|---|---|---| -| `setup/` | Downloads released binary | N/A | **No** — dogfood builds from source | -| `cache-restore/` | Docker login → pull manifest image → pull cached images → retag | **No** | **Yes** | -| `cache-save/` | `hm cache save` → push images → push manifest image → cleanup stale | **Yes** (calls `hm cache save`) | **Almost** — needs `hm` on PATH | - -### Gap analysis - -| Gap | Severity | Fix | -|---|---|---| -| `cache-save` hardcodes `hm` command name | **Blocking** | Add `hm-path` input, default `hm` | -| No FUSE setup | **Non-issue** | Dogfood handles this before the action | -| No `cargo build` | **Non-issue** | Dogfood handles this before the action | -| Registry cache vs GHA file cache | **Upgrade** | GHCR has no 10GB limit, native layer dedup | -| Needs `packages:write` + `packages:delete` permissions | **Minor** | Already standard for GHCR; add to dogfood workflow permissions | - -### Migration benefit - -**Before (8 steps):** -```yaml -- uses: actions/cache/restore@v4 # restore GHA cache -- run: hm cache restore .harmont-cache/ # load images -- run: hm run ci # run pipeline -- run: hm cache save .harmont-cache/ # export manifest -- uses: actions/cache/save@v4 # save GHA cache -``` -Plus `id:`, `if:`, `key:`, `restore-keys:` boilerplate on each step. - -**After (3 steps):** -```yaml -- uses: harmont-dev/actions-hm/cache-restore@v1 # pull from GHCR -- run: ./target/debug/hm run ci # run pipeline -- uses: harmont-dev/actions-hm/cache-save@v1 # push to GHCR + cleanup -``` - -Also gains automatic stale image cleanup (keeps N old versions per step). - ---- - -## Task 1: Add `hm-path` input to `cache-save/action.yml` - -The `cache-save` sub-action calls `hm cache save .harmont-cache/` on line 63. The dogfood pipeline builds `hm` at `./target/debug/hm` — it's not on `$PATH`. Adding an `hm-path` input lets callers point to a custom binary. - -**Files:** -- Modify: `cache-save/action.yml:1-10` (add input) -- Modify: `cache-save/action.yml:60-65` (use input in run step) - -**Step 1: Write the failing test scenario** - -No bats test exists for sub-actions (they're integration-tested via `test-action.yml`). Verify manually that current `cache-save` hardcodes `hm`: - -Run: `grep -n 'hm cache save' cache-save/action.yml` -Expected: Line ~63, `hm cache save .harmont-cache/` - -**Step 2: Add `hm-path` input to `cache-save/action.yml`** - -Add to the `inputs:` block after `token:`: - -```yaml - hm-path: - description: > - Path to the hm binary. Defaults to 'hm' (assumes it's on PATH). - Use './target/debug/hm' or similar when testing a locally-built binary. - required: false - default: hm -``` - -**Step 3: Wire `hm-path` into the export step** - -In the "Export manifest and push images" step, add `INPUT_HM_PATH: ${{ inputs.hm-path }}` to the `env:` block and replace the `hm cache save` call: - -Change line ~63 from: -```bash -hm cache save .harmont-cache/ > /dev/null -``` -To: -```bash -"$INPUT_HM_PATH" cache save .harmont-cache/ > /dev/null -``` - -**Step 4: Validate YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('cache-save/action.yml'))"` -Expected: No error - -**Step 5: Commit** - -```bash -git add cache-save/action.yml -git commit -m "feat(cache-save): add hm-path input for custom binary location" -``` - ---- - -## Task 2: Wire `hm-path` through root `action.yml` - -The root all-in-one action should pass through `hm-path` to `cache-save` so power users of the root action can also use a custom binary. This keeps the granular and all-in-one paths consistent. - -**Files:** -- Modify: `action.yml:10-60` (add input) -- Modify: `action.yml:90-123` (pass to run step and cache-save) - -**Step 1: Add `hm-path` input to root `action.yml`** - -Add to inputs after `extra-args`: - -```yaml - hm-path: - description: > - Path to the hm binary. Use when testing a locally-built binary - instead of an installed release. Overrides setup step. - required: false - default: '' -``` - -**Step 2: Make the run step use `hm-path` when set** - -In the "Run harmont pipeline" step, change the env block to include: -```yaml -INPUT_HM_PATH: ${{ inputs.hm-path || 'hm' }} -``` - -And change the run command from `hm run "${args[@]}"` to `"$INPUT_HM_PATH" run "${args[@]}"`. - -**Step 3: Pass `hm-path` to cache-save** - -In the "Save Docker cache" step, add: -```yaml -hm-path: ${{ inputs.hm-path || 'hm' }} -``` - -**Step 4: Conditionally skip setup when hm-path is set** - -Add `if: inputs.hm-path == ''` to the "Setup Harmont" step. If the user provides their own binary, there's no need to download one. - -**Step 5: Validate YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('action.yml'))"` -Expected: No error - -**Step 6: Commit** - -```bash -git add action.yml -git commit -m "feat: wire hm-path through root action, skip setup when provided" -``` - ---- - -## Task 3: Add `hm-path` to integration tests - -Verify the new input works in CI by adding a test job that builds `hm` from source (or mocks it) and uses `hm-path`. - -**Files:** -- Modify: `.github/workflows/test-action.yml` - -**Step 1: Add a `custom-binary` test job** - -Add after the existing jobs: - -```yaml - custom-binary: - name: Custom hm-path (dogfood pattern) - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Create mock hm binary - run: | - mkdir -p /tmp/mock-hm - cat > /tmp/mock-hm/hm << 'SCRIPT' - #!/bin/bash - case "$1" in - --version) echo "mock-0.0.1" ;; - cache) - case "$2" in - save) - mkdir -p "$3" - echo '{"images":{}}' > "$3/manifest.json" - echo "abc123" - ;; - restore) echo "restored" ;; - esac - ;; - run) echo "ran pipeline: $2" ;; - esac - SCRIPT - chmod +x /tmp/mock-hm/hm - - - name: Cache restore (no manifest yet, cold start) - uses: ./cache-restore - with: - working-directory: tests/fixtures - - - name: Run with custom binary - working-directory: tests/fixtures - env: - HM_NONINTERACTIVE: '1' - run: /tmp/mock-hm/hm run hello - - - name: Cache save with custom binary - if: always() - uses: ./cache-save - with: - working-directory: tests/fixtures - hm-path: /tmp/mock-hm/hm -``` - -**Step 2: Validate YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test-action.yml'))"` -Expected: No error - -**Step 3: Commit** - -```bash -git add .github/workflows/test-action.yml -git commit -m "test: add integration test for custom hm-path (dogfood pattern)" -``` - ---- - -## Task 4: Write the migrated dogfood workflow - -Create the replacement dogfood job that uses `actions-hm` sub-actions. This lives in `harmont-cli-2`, not this repo. Document it here as a reference for the migration PR. - -**Files:** -- Reference only: `harmont-cli-2/.github/workflows/ci.yml` (dogfood job) - -**Step 1: Document the target workflow** - -The migrated `dogfood` job in `harmont-cli-2/.github/workflows/ci.yml` should look like: - -```yaml - dogfood: - name: dogfood (hm run ci) - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - packages: write - packages: delete # for stale image cleanup - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - uses: actions/setup-node@v4 - with: - node-version: "23" - cache: npm - cache-dependency-path: dsls/harmont-ts/package-lock.json - - - name: Install esbuild (for harmont-ts bundle) - working-directory: dsls/harmont-ts - run: npm ci - - - name: Build hm - run: cargo build -p harmont-cli - - - name: Enable FUSE allow_other - run: sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf - - - name: Restore Docker cache - uses: harmont-dev/actions-hm/cache-restore@v1 - - - name: hm run ci - env: - HM_NONINTERACTIVE: '1' - run: ./target/debug/hm run ci - - - name: Save Docker cache - if: always() - uses: harmont-dev/actions-hm/cache-save@v1 - with: - hm-path: ./target/debug/hm -``` - -**Step 2: Diff comparison** - -Lines removed from current dogfood job: -```yaml -# These 5 steps become 2 action calls: -- uses: actions/cache/restore@v4 # REMOVED - with: - path: .harmont-cache/ - key: harmont-v1-will-never-match - restore-keys: harmont-v1- - -- run: ./target/debug/hm cache restore .harmont-cache/ # REMOVED - -- id: cache-manifest # REMOVED - run: hash=$(./target/debug/hm cache save .harmont-cache/) - echo "key=harmont-v1-${hash}" >> "$GITHUB_OUTPUT" - -- uses: actions/cache/save@v4 # REMOVED - with: - path: .harmont-cache/ - key: ${{ steps.cache-manifest.outputs.key }} -``` - -Lines added: -```yaml -- uses: harmont-dev/actions-hm/cache-restore@v1 # NEW (1 step replaces 2) - -- uses: harmont-dev/actions-hm/cache-save@v1 # NEW (1 step replaces 3) - with: - hm-path: ./target/debug/hm -``` - -**Net change:** -5 steps, +2 steps, +stale image cleanup for free. - -**Step 3: Commit plan documentation** - -```bash -git add docs/plans/2026-05-27-dogfood-migration.md -git commit -m "docs: add dogfood migration evaluation and plan" -``` - ---- - -## Task 5: Verify `cache-restore` works without `hm` binary - -Confirm that `cache-restore/action.yml` has zero dependency on the `hm` binary. This is a read-only verification — no code changes expected. - -**Files:** -- Read: `cache-restore/action.yml` - -**Step 1: Grep for `hm` commands** - -Run: `grep -n 'hm ' cache-restore/action.yml` -Expected: No matches (only Docker and Python commands) - -**Step 2: Verify the restore flow** - -The restore sub-action should only use: -- `docker login` — authenticate to registry -- `docker pull` — pull manifest image and cached images -- `docker create` / `docker cp` / `docker rm` — extract manifest.json -- `docker tag` — retag registry images as `harmont-local/*` -- `python3 -c` — parse manifest.json - -If `hm` appears anywhere, it needs an `hm-path` input too (same as Task 1). - -**Step 3: Document result** - -Run: `echo "cache-restore has no hm dependency: $(grep -c 'hm cache' cache-restore/action.yml) matches"` -Expected: `0 matches` — confirmed no dependency. - ---- - -## Task 6: Update README with dogfood/custom-binary usage - -Add a section to `README.md` showing how to use the action with a locally-built binary (the dogfood pattern). - -**Files:** -- Modify: `README.md` - -**Step 1: Add a "Custom binary" section** - -Add under the existing "Granular sub-actions" section: - -```markdown -### Custom binary (dogfood pattern) - -When testing a locally-built `hm` binary (e.g., the harmont-cli repo's own CI): - -```yaml -- name: Build hm from source - run: cargo build -p harmont-cli - -- uses: harmont-dev/actions-hm/cache-restore@v1 - -- run: ./target/debug/hm run ci - env: - HM_NONINTERACTIVE: '1' - -- uses: harmont-dev/actions-hm/cache-save@v1 - if: always() - with: - hm-path: ./target/debug/hm -``` - -The `hm-path` input tells cache-save where to find the binary. Cache-restore -doesn't need it — it uses Docker directly. -``` - -**Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: add custom binary / dogfood usage example to README" -``` - ---- - -## Caching: GHA file cache vs GHCR registry - -The migration switches caching from GHA file cache to GHCR registry. Comparison: - -| | GHA file cache (current) | GHCR registry (actions-hm) | -|---|---|---| -| Size limit | 10 GB total per repo | Unlimited (GHCR storage) | -| Granularity | Single `.harmont-cache/` tarball | Per-image, per-tag | -| Layer dedup | None (full tarball each time) | Native Docker layer dedup | -| Cross-branch | Shared via prefix match | Shared (same registry) | -| Stale cleanup | Manual / GHA eviction | Automatic (configurable keep-N) | -| Auth | `GITHUB_TOKEN` (contents read) | `GITHUB_TOKEN` (packages write/delete) | -| Cold start cost | Full tarball download | Per-image parallel pulls | - -**Verdict:** Registry cache is strictly better for the dogfood use case. Larger Docker image sets won't hit the 10GB cap, and layer dedup means incremental changes push/pull only deltas. - ---- - -## Risk assessment - -| Risk | Likelihood | Mitigation | -|---|---|---| -| GHCR rate limits on heavy push/pull | Low | GitHub-hosted runners have generous limits to GHCR | -| `packages:write` permission not granted | Low | Add to `permissions:` block in workflow | -| Registry cold start slower than file cache | Medium | First run pulls nothing (cold start same as today). Subsequent runs may be faster due to layer dedup | -| `hm cache save` output format changes | Low | Pin `actions-hm` to a tag; manifest.json format is stable | - ---- - -## Execution order - -Tasks 1-3 are in this repo (actions-hm). Task 4 is a migration PR in harmont-cli-2. Task 5 is verification. Task 6 is documentation. - -Dependency graph: -``` -Task 1 (hm-path in cache-save) ─┬─→ Task 2 (wire through root action) - ├─→ Task 3 (integration test) - ├─→ Task 4 (migration PR in harmont-cli-2) - └─→ Task 6 (README update) -Task 5 (verify cache-restore) ──────→ (independent, can run first) -``` - -Tasks 2, 3, 5, 6 can all run in parallel after Task 1. -Task 4 depends on Task 1 being merged + tagged. From d55f6c5df2ec5d1d32455c5fe8b9dbe1d2caf1a2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 27 May 2026 07:41:00 -0700 Subject: [PATCH 20/20] deslop --- README.md | 63 +++++++++---------------------------------------------- 1 file changed, 10 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 2699290..ada9e9b 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,17 @@ # 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) +> [!WARNING] +> +> This repo is currently considered experimental. -Run [harmont](https://harmont.dev) pipelines in GitHub Actions. One step. Automatic Docker image caching via your container registry. +Run [harmont](https://harmont.dev) pipelines in GitHub Actions. ```yaml -- uses: harmont-dev/actions-hm@v1 +- uses: harmont-dev/actions-hm@main 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) @@ -55,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: harmont-dev/actions-hm@v1 + - uses: harmont-dev/actions-hm@main with: pipeline: lint @@ -63,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: harmont-dev/actions-hm@v1 + - uses: harmont-dev/actions-hm@main with: pipeline: test parallelism: 4 @@ -95,37 +83,6 @@ jobs: if: always() ``` -### Custom binary (dogfood pattern) - -When testing a locally-built `hm` binary (e.g., the harmont-cli repo's own CI): - -```yaml -- name: Build hm from source - run: cargo build -p harmont-cli - -- uses: harmont-dev/actions-hm/cache-restore@v1 - -- run: ./target/debug/hm run ci - env: - HM_NONINTERACTIVE: '1' - -- uses: harmont-dev/actions-hm/cache-save@v1 - if: always() - with: - hm-path: ./target/debug/hm -``` - -The `hm-path` input tells cache-save where to find the binary. Cache-restore -doesn't need it — it uses Docker directly. - -### Pin to specific version - -```yaml -- uses: harmont-dev/actions-hm@v1 - with: - version: 0.5.0 -``` - ## Inputs | Input | Default | Description | @@ -236,7 +193,7 @@ steps: ```yaml steps: - uses: actions/checkout@v4 - - uses: harmont-dev/actions-hm@v1 + - uses: harmont-dev/actions-hm@main with: pipeline: ci ``` @@ -258,7 +215,7 @@ macOS runners have Docker available via colima/lima. Windows runners are not cur Yes. Set `cache-registry` to your registry hostname and provide a token with push/pull access: ```yaml -- uses: harmont-dev/actions-hm@v1 +- uses: harmont-dev/actions-hm@main with: pipeline: ci cache-registry: registry.example.com @@ -268,7 +225,7 @@ Yes. Set `cache-registry` to your registry hostname and provide a token with pus ### How do I disable caching entirely? ```yaml -- uses: harmont-dev/actions-hm@v1 +- uses: harmont-dev/actions-hm@main with: pipeline: ci cache: 'false'