diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..d6f97eaa --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,106 @@ +# ── Baked Devcontainer Image ────────────────────────────────────────── +# Pre-installs all heavy tooling so containers start in seconds, not minutes. +# Rebuild: docker build -t hermes-codespace:latest -f .devcontainer/Dockerfile . +# ──────────────────────────────────────────────────────────────────────── + +FROM mcr.microsoft.com/devcontainers/base:ubuntu + +# ── Versions (single source of truth) ───────────────────────────────── +ARG HERMES_VERSION=v2026.7.7.2 +ARG OMNIROUTE_VERSION=3.8.48 +ARG OLLAMA_VERSION=0.32.1 +ARG NODE_VERSION=24.18.0 +ARG MNEMON_VERSION=0.1.17 + +# Export as env so install scripts can see them +ENV OLLAMA_VERSION=${OLLAMA_VERSION} +ENV OLLAMA_NO_START=1 +ENV DEBIAN_FRONTEND=noninteractive + +# ── System packages + ALL heavy installs in ONE layer ────────────────── +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + zsh ripgrep jq curl git ca-certificates gnupg zstd \ + && rm -rf /var/lib/apt/lists/* \ + \ + # ── Ollama ──────────────────────────────────────────────────────── + && curl -fsSL https://ollama.com/install.sh | sh \ + \ + # ── Hermes Agent ───────────────────────────────────────────────── + && curl -fsSL "https://raw.githubusercontent.com/NousResearch/hermes-agent/${HERMES_VERSION}/scripts/install.sh" \ + | bash -s -- --skip-setup \ + && npm cache clean --force \ + \ + # ── agent-client-protocol (inside hermes venv) ──────────────────── + && if [ -x "$HOME/.hermes/hermes-agent/venv/bin/python" ]; then \ + "$HOME/.hermes/hermes-agent/venv/bin/python" -m pip install "agent-client-protocol>=0.9.0,<1.0"; \ + fi \ + \ + # ── ModelRelay ──────────────────────────────────────────────────── + && npm install github:gitricko/modelrelay -g --prefix /usr/local/lib/modelrelay \ + && ln -sf /usr/local/lib/modelrelay/bin/modelrelay /usr/local/bin/modelrelay \ + && npm cache clean --force \ + \ + # ── OmniRoute ───────────────────────────────────────────────────── + && npm install omniroute@${OMNIROUTE_VERSION} -g --prefix /usr/local/lib/omniroute \ + && ln -sf /usr/local/lib/omniroute/bin/omniroute /usr/local/bin/omniroute \ + && npm cache clean --force \ + \ + # ── OmniRoute dist/ dep repair (hollow deps workaround) ────────── + && omni_root="/usr/local/lib/omniroute/lib/node_modules/omniroute" \ + && dist_nm="$omni_root/dist/node_modules" \ + && parent_nm="$omni_root/node_modules" \ + && if [ -d "$dist_nm" ]; then \ + for dst in $(find "$dist_nm" -mindepth 1 -maxdepth 1 -type d 2>/dev/null); do \ + rel="${dst#"$dist_nm"/}" \ + && src="$parent_nm/$rel" \ + && if [ -d "$src" ] && [ ! "$(find "$dst" \( -name '*.js' -o -name '*.mjs' -o -name '*.node' \) -type f 2>/dev/null | head -1)" ] \ + && [ "$(find "$src" \( -name '*.js' -o -name '*.mjs' -o -name '*.node' \) -type f 2>/dev/null | head -1)" ]; then \ + rm -rf "$dst" && cp -r "$src" "$dst" \ + && echo "Repaired hollow dep: $rel"; \ + fi; \ + done; \ + fi \ + \ + # ── TailScale ───────────────────────────────────────────────────── + && mkdir -p /var/run/tailscale /var/lib/tailscale \ + && (curl -fsSL https://tailscale.com/install.sh | sh || echo "WARN: tailscale install failed, continuing") \ + && rm -rf /var/lib/apt/lists/* \ + \ + # ── Mnemon ──────────────────────────────────────────────────────── + && ARCH=amd64 \ + && curl -sL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + -o /tmp/mnemon.tar.gz \ + && tar xzf /tmp/mnemon.tar.gz -C /tmp \ + && cp /tmp/mnemon /usr/local/bin/mnemon \ + && chmod +x /usr/local/bin/mnemon \ + && rm -rf /tmp/mnemon.tar.gz /tmp/mnemon \ + \ + # ── Cline ───────────────────────────────────────────────────────── + && npm install -g cline \ + \ + # ── Claude CLI ──────────────────────────────────────────────────── + && curl -fsSL https://claude.ai/install.sh | bash \ + \ + # ── Final cleanup ───────────────────────────────────────────────── + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /root/.npm /tmp/* /var/tmp/* \ + && rm -rf /root/.cache/pip 2>/dev/null || true + +# ── Copy config files ───────────────────────────────────────────────── +COPY .devcontainer/CLAUDE.md /tmp/devcontainer-config/CLAUDE.md +COPY .devcontainer/claude-term-settings.json /tmp/devcontainer-config/claude-term-settings.json +COPY .devcontainer/.claude.json /tmp/devcontainer-config/.claude.json +COPY .devcontainer/skill-memory-automation.md /tmp/devcontainer-config/skill-memory-automation.md +COPY .devcontainer/.hermes.md /tmp/devcontainer-config/.hermes.md +COPY .devcontainer/cline-globalState.json /tmp/devcontainer-config/cline-globalState.json +COPY .devcontainer/cline-secrets.json /tmp/devcontainer-config/cline-secrets.json +COPY .devcontainer/self-check.sh /usr/local/bin/self-check.sh + +# ── Entrypoint: lightweight service start + config placement ────────── +COPY .devcontainer/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/self-check.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["sleep", "infinity"] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8eae3822..fc342a5b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,12 @@ { "name": "Hermes-Coding-Agent", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "runArgs": [ + "--name", "hermes-codespace" + ], "customizations": { "vscode": { "extensions": [ @@ -8,7 +15,5 @@ "saoudrizwan.claude-dev" ] } - }, - "postCreateCommand": "bash ./.devcontainer/post-create-cmd.sh >> /tmp/hermes-codespace.log 2>&1", - "postStartCommand": "bash ./.devcontainer/start-hermes.sh >> /tmp/hermes-codespace.log 2>&1" + } } \ No newline at end of file diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh new file mode 100755 index 00000000..b93240ac --- /dev/null +++ b/.devcontainer/entrypoint.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# ── Entrypoint: copies baked configs to $HOME and starts services ───── +# This replaces both post-create-cmd.sh and start-hermes.sh. +# Heavy installs are already in the image; this only does runtime setup. +set -e + +SCRIPT_NAME="entrypoint.sh" +echo "***** Hermes Codespace — Baked Image Entrypoint *****" + +# ── Place config files into $HOME (only if not already customized) ─── +place_config() { + local src="$1" dst="$2" + if [ ! -f "$dst" ] || ! cmp -s "$src" "$dst" 2>/dev/null; then + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + echo "[$SCRIPT_NAME] Placed $(basename "$dst")" + fi +} + +place_config /tmp/devcontainer-config/CLAUDE.md "$HOME/.claude/CLAUDE.md" +place_config /tmp/devcontainer-config/claude-term-settings.json "$HOME/.claude/settings.json" +place_config /tmp/devcontainer-config/.claude.json "$HOME/.claude.json" +place_config /tmp/devcontainer-config/.hermes.md "$HOME/.hermes.md" +place_config /tmp/devcontainer-config/cline-globalState.json "$HOME/.cline/data/globalState.json" +place_config /tmp/devcontainer-config/cline-secrets.json "$HOME/.cline/data/secrets.json" +mkdir -p "$HOME/.hermes/skills/memory-automation" +place_config /tmp/devcontainer-config/skill-memory-automation.md "$HOME/.hermes/skills/memory-automation/SKILL.md" + +# ── Hermes config defaults (first session only) ────────────────────── +if command -v hermes &>/dev/null \ + && [ -d "$HOME/.hermes/sessions" ] && [ -z "$(ls -A "$HOME/.hermes/sessions" 2>/dev/null)" ]; then + echo "[$SCRIPT_NAME] Setting up default Hermes config..." + hermes config set model.default auto-fastest + hermes config set model.provider omniroute + hermes config set providers.omniroute.base_url http://localhost:20128/v1 + hermes config set providers.omniroute.api_key no-key-needed + hermes config set providers.modelrelay.base_url http://localhost:7352/v1 + hermes config set providers.modelrelay.api_key no-key-needed + hermes config set fallback_providers.provider modelrelay + hermes config set fallback_providers.model auto-fastest + hermes config set auxiliary.title_generation.model auto-fastest + hermes config set auxiliary.title_generation.provider modelrelay + hermes config set auxiliary.vision.model auto-fastest + hermes config set auxiliary.vision.provider modelrelay + hermes config set auxiliary.compression.model auto-fastest + hermes config set auxiliary.compression.provider modelrelay + hermes config set approvals.mode off + hermes config set memory.memory_enabled true + hermes config set memory.user_profile_enabled true + hermes config set memory.provider mnemon + hermes config set agent.max_turns 120 + hermes config set kanban.failure_limit 3 +fi + +# ── Mnemon USER.md ─────────────────────────────────────────────────── +if [ ! -f "$HOME/.hermes/memories/USER.md" ]; then + mkdir -p "$HOME/.hermes/memories" + cat > "$HOME/.hermes/memories/USER.md" <<'USEREOF' +Always use Mnemon (mnemon_remember / mnemon_recall) as primary memory provider instead of the standard memory() tool. Mnemon has no char limit. Only fall back to memory() for structured preference data (target=user or memory). +USEREOF + echo "[$SCRIPT_NAME] Created USER.md for Mnemon" +fi + +# ── Start services (only if not already running) ───────────────────── +start_service() { + local name="$1" cmd="$2" + local logfile="/tmp/$(echo "$name" | tr ' ' '-').log" + if pgrep -f "$name" > /dev/null 2>&1; then + echo "[$SCRIPT_NAME] $name already running, skipping" + else + echo "[$SCRIPT_NAME] Starting $name..." + setsid $cmd >> "$logfile" 2>&1 & + fi +} + +start_service "ollama serve" "/usr/local/bin/ollama serve" +start_service "modelrelay" "/usr/local/bin/modelrelay" +start_service "omniroute" "/usr/local/bin/omniroute --no-open --log" + +# Pull nomic-embed-text in background after 60s +( sleep 60 && ollama pull nomic-embed-text >> /tmp/ollama-pull.log 2>&1 ) & + +# ── OmniRoute: wait for ready, disable login, create combo ──────────── +MAX_ATTEMPTS=10 +for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do + if curl -s --max-time 3 -o /dev/null -w "%{http_code}" http://localhost:20128/v1/models 2>/dev/null | grep -q "200"; then + break + fi + [ "$attempt" -eq "$MAX_ATTEMPTS" ] && echo "[$SCRIPT_NAME] WARNING: OmniRoute not ready" + sleep 1 +done + +# Disable login requirement +if [ -f "$HOME/.omniroute/storage.sqlite" ]; then + python3 -c " +import sqlite3 +conn = sqlite3.connect('$HOME/.omniroute/storage.sqlite') +conn.execute('UPDATE key_value SET value = ? WHERE key = ?', ('false', 'requireLogin')) +conn.commit() +conn.close() +" 2>/dev/null +fi + +# Create auto-fastest combo (idempotent) +for ((i=1; i<=5; i++)); do + omniroute combo create auto-fastest --strategy auto 2>/dev/null && break + sleep 2 +done + +# Configure combo models +COMBO_ID=$(omniroute combo list --json 2>/dev/null | grep -v "📋" | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print([c['id'] for c in d['combos'] if c['name']=='auto-fastest'][0])" 2>/dev/null) +if [ -n "$COMBO_ID" ]; then + curl -s -X PUT "http://localhost:20128/api/combos/$COMBO_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "models": ["oc/deepseek-v4-flash-free","oc/big-pickle","opencode-zen/deepseek-v4-flash-free","opencode-zen/hy3-free","opencode-zen/mimo-v2.5-free","opencode-zen/north-mini-code-free","opencode-zen/nemotron-3-ultra-free","opencode-zen/big-pickle"], + "strategy": "auto", + "config": {"maxRetries": 2, "retryDelayMs": 1000, "timeoutMs": 120000, "healthCheckEnabled": true} + }' >/dev/null +fi + +# Enable MCP +if ! omniroute mcp status --json 2>/dev/null | python3 -c "import sys,json;exit(0 if json.load(sys.stdin).get('enabled') else 1)" 2>/dev/null; then + curl -s -X PATCH http://localhost:20128/api/settings \ + -H "Content-Type: application/json" -d '{"mcpEnabled":true}' >/dev/null +fi + +# Add omniroute MCP to hermes +yes Y 2>/dev/null | hermes mcp add omniroute --command omniroute --args --mcp 2>/dev/null || true + +# ── Hermes gateway + dashboard ──────────────────────────────────────── +# Update mnemon plugin +rm -rf /tmp/mnemon_repo +if git clone https://github.com/gitricko/hermes-plugin-mnemon /tmp/mnemon_repo 2>/dev/null; then + if [ ! -d "$HOME/.hermes/plugins/mnemon" ] || ! diff -r -q -x __pycache__ "$HOME/.hermes/plugins/mnemon" "/tmp/mnemon_repo/mnemon" >/dev/null 2>&1; then + mkdir -p "$HOME/.hermes/plugins" + rm -rf "$HOME/.hermes/plugins/mnemon" + cp -r "/tmp/mnemon_repo/mnemon" "$HOME/.hermes/plugins/mnemon" + fi + rm -rf /tmp/mnemon_repo +fi + +start_service "hermes gateway" "hermes gateway run --no-supervise" +start_service "hermes dashboard" "hermes dashboard --port 9119 --no-open" + +# Telegram bot deps +$HOME/.hermes/hermes-agent/venv/bin/python -m ensurepip --upgrade 2>/dev/null || true +ln -sf $HOME/.hermes/hermes-agent/venv/bin/pip3 $HOME/.hermes/hermes-agent/venv/bin/pip 2>/dev/null || true +$HOME/.hermes/hermes-agent/venv/bin/pip install python-telegram-bot 2>/dev/null || true + +# Mnemon -> claude-code integration +mnemon setup --yes --global --target claude-code 2>/dev/null || true + +echo "[$SCRIPT_NAME] All services started." +echo "[$SCRIPT_NAME] Running self-check..." +/usr/local/bin/self-check.sh 2>/dev/null || echo "[$SCRIPT_NAME] WARNING: self-check reported issues" + +# ── Execute the CMD (default: sleep infinity) ───────────────────────── +exec "$@" \ No newline at end of file diff --git a/.devcontainer/self-check.sh b/.devcontainer/self-check.sh index 038d2bb2..171d5138 100755 --- a/.devcontainer/self-check.sh +++ b/.devcontainer/self-check.sh @@ -97,7 +97,7 @@ if ! should_skip "services"; then # Poll all service ports until all respond or timeout PORT_POLL_TIMEOUT=60 POLL_STARTED_AT=$(date +%s) - declare -A RESPONDED=([3000]="" [8888]="" [7352]="" [20128]="", [9119]="") + declare -A RESPONDED=([3000]="" [8888]="" [7352]="" [20128]="" [9119]="") while true; do NOW=$(date +%s) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d385ac78 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.vscode +*.md +!README.md +node_modules +*.log +tmp +.cache +.hermes \ No newline at end of file diff --git a/.github/workflows/devcontainer-ci.yml b/.github/workflows/devcontainer-ci.yml index de1c623b..e3d99825 100644 --- a/.github/workflows/devcontainer-ci.yml +++ b/.github/workflows/devcontainer-ci.yml @@ -3,25 +3,244 @@ name: Dev Container CI on: push: branches: [main] + paths: + - '.devcontainer/**' + - '!.devcontainer/screen-shot.png' pull_request: branches: [main] + paths: + - '.devcontainer/**' + - '!.devcontainer/screen-shot.png' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/devcontainer + +permissions: + contents: read + packages: write jobs: - test-devcontainer: - name: Build & Smoke Test + # ── Job 1: Build the Docker image ──────────────────────────────────── + build: + name: Build Image + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + ci_image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:ci-${{ github.run_id }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix= + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=ci-${{ github.run_id }} + + - name: Build and push + id: build + uses: docker/build-push-action@v5 + with: + context: . + file: .devcontainer/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ── Job 2: Smoke test the built image ─────────────────────────────── + smoke-test: + name: Smoke Test + needs: build runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@v4 - - name: run post-create-cmd.sh - run: bash ./.devcontainer/post-create-cmd.sh + - name: Pull pre-built image + run: | + echo "Pulling ${{ needs.build.outputs.ci_image }}" + docker pull ${{ needs.build.outputs.ci_image }} + docker tag ${{ needs.build.outputs.ci_image }} hermes-codespace:test - - name: run start-hermes.sh - run: bash ./.devcontainer/start-hermes.sh + - name: Verify baked tools exist + run: | + docker run --rm --entrypoint bash hermes-codespace:test -c ' + echo "=== Verifying baked tools ===" + PASS=0; FAIL=0 + check() { if eval "$2"; then echo "✅ $1"; PASS=$((PASS+1)); else echo "❌ $1"; FAIL=$((FAIL+1)); fi; } - - name: Smoke Test + check "ollama installed" "command -v ollama" + check "hermes installed" "command -v hermes" + check "omniroute installed" "command -v omniroute" + check "modelrelay installed" "command -v modelrelay" + check "mnemon installed" "command -v mnemon" + check "tailscale installed" "command -v tailscale || true" + check "cline installed" "command -v cline" + check "zsh installed" "command -v zsh" + check "ripgrep installed" "command -v rg" + check "entrypoint.sh exists" "[ -x /usr/local/bin/entrypoint.sh ]" + + echo "" + echo "=== Results: $PASS passed, $FAIL failed ===" + [ "$FAIL" -eq 0 ] && echo "🎉 All tools baked!" || exit 1 + ' + + - name: Verify entrypoint.sh syntax + run: | + docker run --rm --entrypoint bash hermes-codespace:test -c ' + echo "=== Checking entrypoint syntax ===" + bash -n /usr/local/bin/entrypoint.sh && echo "✅ entrypoint.sh syntax OK" + ' + + - name: Verify config files copied to image run: | - bash ./.devcontainer/self-check.sh - echo "Smoke test passed!" \ No newline at end of file + docker run --rm --entrypoint bash hermes-codespace:test -c ' + echo "=== Checking config files ===" + [ -f /tmp/devcontainer-config/CLAUDE.md ] && echo "✅ CLAUDE.md present" + [ -f /tmp/devcontainer-config/.claude.json ] && echo "✅ .claude.json present" + [ -f /tmp/devcontainer-config/.hermes.md ] && echo "✅ .hermes.md present" + [ -f /tmp/devcontainer-config/skill-memory-automation.md ] && echo "✅ memory-automation skill present" + ' + + # ── Job 3: Full integration test (devcontainer CLI) ────────────────── + integration-test: + name: Integration Test + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli + + - name: Pull pre-built image + run: | + docker pull ${{ needs.build.outputs.ci_image }} + docker tag ${{ needs.build.outputs.ci_image }} hermes-codespace:test + + - name: Run self-check inside container + run: | + docker run --rm -d --name test-hc \ + -v "$(pwd):/workspace" \ + hermes-codespace:test + + echo "Waiting for services to start..." + sleep 30 + + docker cp .devcontainer/self-check.sh test-hc:/tmp/self-check.sh + docker exec test-hc bash /tmp/self-check.sh + + docker stop test-hc + + # ── Job 4: Generate image size report ──────────────────────────────── + image-report: + name: Image Size Report + needs: build + runs-on: ubuntu-latest + steps: + - name: Pull pre-built image + run: | + docker pull ${{ needs.build.outputs.ci_image }} + docker tag ${{ needs.build.outputs.ci_image }} hermes-codespace:test + + - name: Report image size + run: | + echo "=== Devcontainer Image Size ===" + docker images hermes-codespace:test --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}" + SIZE=$(docker images hermes-codespace:test --format "{{.Size}}") + echo "" + echo "📊 Image size: **$SIZE**" + + SIZE_BYTES=$(docker image inspect hermes-codespace:test --format '{{.Size}}') + if [ "$SIZE_BYTES" -gt 4294967296 ]; then + echo "⚠️ WARNING: Image exceeds 4GB — may hit Codespace storage limits" + fi + + # ── Job 5: Cleanup temp image from GHCR ───────────────────────────── + cleanup: + name: Cleanup + needs: [build, smoke-test, integration-test, image-report] + if: always() + runs-on: ubuntu-latest + steps: + - name: Delete temp image tag from GHCR + env: + TAG: ci-${{ github.run_id }} + PACKAGE: ${{ env.IMAGE_NAME }} + GHCR_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN }} + run: | + echo "Cleaning up: ${PACKAGE}:${TAG}" + + if [ -z "$GHCR_TOKEN" ]; then + echo "⚠️ GHCR_CLEANUP_TOKEN secret not set — skipping cleanup" + echo " Create a classic PAT at: https://github.com/settings/tokens/new?scopes=read:packages,write:packages" + echo " Then: gh secret set GHCR_CLEANUP_TOKEN --body 'ghp_...' -R ${{ github.repository }}" + exit 0 + fi + + # URL-encode the package name for the REST API + # Strip the username prefix — user/packages endpoint scopes to authenticated user + PKG_ENCODED=$(echo "$PACKAGE" | sed 's|^[^/]*/||' | tr '[:upper:]' '[:lower:]' | sed 's|/|%2F|g') + + # Find the version with our temp tag + FOUND=0 + for PAGE in 1 2 3; do + VERSIONS=$(curl -sf \ + -H "Authorization: token ${GHCR_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/user/packages/container/${PKG_ENCODED}/versions?per_page=100&page=${PAGE}" 2>/dev/null || echo "[]") + + for VERSION_ID in $(echo "$VERSIONS" | jq -r '.[].id'); do + TAGS=$(echo "$VERSIONS" | jq -r --arg vid "$VERSION_ID" '.[] | select(.id == ($vid|tonumber)) | .metadata.container.tags[]' 2>/dev/null) + if echo "$TAGS" | grep -q "^${TAG}$"; then + echo "Found version ${VERSION_ID} with tag ${TAG}" + echo "Deleting..." + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X DELETE \ + -H "Authorization: token ${GHCR_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/user/packages/container/${PKG_ENCODED}/versions/${VERSION_ID}") + + if [ "$HTTP_CODE" = "204" ] || [ "$HTTP_CODE" = "200" ]; then + echo "✅ Deleted ${TAG} (version ${VERSION_ID})" + else + echo "⚠️ Delete returned HTTP ${HTTP_CODE}" + fi + FOUND=1 + break 2 + fi + done + + # Stop paging if we got fewer results than the page size + COUNT=$(echo "$VERSIONS" | jq 'length' 2>/dev/null) + [ "$COUNT" -lt 100 ] && break + done + + if [ "$FOUND" -eq 0 ]; then + echo "✅ No version with tag ${TAG} found — may have been cleaned already" + fi