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." diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000..42ae5b9 --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,85 @@ +name: Test Action + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + packages: write + +jobs: + all-in-one: + name: All-in-one action + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - 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 + + - name: Setup hm + uses: ./setup + with: + version: latest + + - 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 }}" + + 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/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/action.yml b/action.yml new file mode 100644 index 0000000..a2c11ec --- /dev/null +++ b/action.yml @@ -0,0 +1,122 @@ +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-registry: + description: Container registry for image caching + required: false + default: ghcr.io + cache-registry-prefix: + description: > + 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 (needs packages:write for cache, packages:delete for cleanup) + 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 }} + token: ${{ inputs.token }} + + # --- Cache Restore --- + - name: Restore Docker cache + if: inputs.cache == 'true' + uses: ./cache-restore + with: + registry: ${{ inputs.cache-registry }} + registry-prefix: ${{ inputs.cache-registry-prefix }} + working-directory: ${{ inputs.working-directory }} + token: ${{ inputs.token }} + + # --- Run Pipeline --- + - name: Run harmont pipeline + shell: bash + 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 "$INPUT_PIPELINE" ]]; then + args+=("$INPUT_PIPELINE") + fi + if [[ -n "$INPUT_PARALLELISM" ]]; then + args+=("--parallelism" "$INPUT_PARALLELISM") + fi + if [[ -n "$INPUT_EXTRA_ARGS" ]]; then + read -ra extra <<< "$INPUT_EXTRA_ARGS" + args+=("${extra[@]}") + fi + hm run "${args[@]}" + + # --- Cache Save --- + - name: Save Docker cache + if: always() && inputs.cache == 'true' + uses: ./cache-save + with: + 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 new file mode 100644 index 0000000..a152114 --- /dev/null +++ b/cache-restore/action.yml @@ -0,0 +1,105 @@ +name: Restore Harmont Cache +description: > + 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: + 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. + 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). + required: false + default: ${{ github.token }} + +outputs: + restored: + description: Whether any images were restored from registry + value: ${{ steps.pull.outputs.restored }} + +runs: + using: composite + steps: + - name: Login to container 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: pull + shell: bash + env: + INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} + 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 + + 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 + + # 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" + 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 + 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('.harmont-cache/manifest.json')) + for k, v in m['images'].items(): + print(f'{k}|{v}') + ") + 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 new file mode 100644 index 0000000..42572ae --- /dev/null +++ b/cache-save/action.yml @@ -0,0 +1,201 @@ +name: Save Harmont Cache +description: > + 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: + 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. + 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 auth (needs packages:write, packages:delete for cleanup). + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Login to container 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: Export manifest and push images + id: push + shell: bash + env: + INPUT_REGISTRY_PREFIX: ${{ inputs.registry-prefix }} + INPUT_REGISTRY: ${{ inputs.registry }} + 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 + 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 _ 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 + 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" + + # 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::" 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. 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/scripts/resolve-version.sh b/scripts/resolve-version.sh new file mode 100755 index 0000000..b72296d --- /dev/null +++ b/scripts/resolve-version.sh @@ -0,0 +1,29 @@ +#!/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 + 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 + fi + echo "$tag" +elif [[ "$VERSION" =~ ^v ]]; then + echo "$VERSION" +else + echo "v${VERSION}" +fi diff --git a/setup/action.yml b/setup/action.yml new file mode 100644 index 0000000..2606e44 --- /dev/null +++ b/setup/action.yml @@ -0,0 +1,61 @@ +name: Setup Harmont +description: Install the hm CLI with binary caching + +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 + 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 }} + cache-hit: + description: Whether the hm binary was restored from cache + value: ${{ steps.cache.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Resolve version + id: version + shell: bash + 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: 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 + 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" "$HM_INSTALL_DIR" + + - name: Add hm to PATH and verify + id: install + shell: bash + run: | + echo "/tmp/hm/bin" >> "$GITHUB_PATH" + export PATH="/tmp/hm/bin:$PATH" + echo "hm-version=$(hm --version)" >> "$GITHUB_OUTPUT" 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") 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" +} 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" ] +}