From 66fe1a1c5b42121da6fd68fe30515d830f4f7e52 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 10:39:43 -0400 Subject: [PATCH 1/8] Extract container manage steps into manage/ sub-action --- manage/action.yml | 150 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 manage/action.yml diff --git a/manage/action.yml b/manage/action.yml new file mode 100644 index 0000000..4b185ee --- /dev/null +++ b/manage/action.yml @@ -0,0 +1,150 @@ +name: LaunchPad Manage +description: Create or recreate a container via the Container API and report its status +author: mieweb + +inputs: + api_key: + description: "API Key for authentication" + required: true + api_url: + description: "Base URL for the Container API" + required: true + site_id: + description: "Site ID for the container management system" + required: false + default: "1" + hostname: + description: "Container hostname" + required: true + template_name: + description: "Container template (e.g., ghcr.io/mieweb/timeharbor:latest)" + required: false + services: + description: "Services configuration (JSON array)" + required: false + container_env_vars: + description: "Environment variables to set inside the container (JSON string)" + required: false + +runs: + using: "composite" + steps: + # ── Phase 1: Create or locate the container ── + - name: Locate Existing Container + id: locate + uses: mieweb/opensource-server/.github/actions/get-container@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: ${{ inputs.site_id }} + hostname: ${{ inputs.hostname }} + + - name: Plan Create / Recreate + id: plan + shell: bash + env: + FOUND: ${{ steps.locate.outputs.found }} + EXISTING_ID: ${{ steps.locate.outputs.container_id }} + TEMPLATE_NAME: ${{ inputs.template_name }} + run: | + need_create=false + need_delete=false + ready=false + existing_id="" + + if [ "$FOUND" == "true" ] && [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then + if [ -n "$TEMPLATE_NAME" ]; then + echo "Existing container found (ID: $EXISTING_ID); deleting to recreate with updated configuration." + need_delete=true + need_create=true + existing_id="$EXISTING_ID" + else + echo "Existing container found (ID: $EXISTING_ID); no template provided, reusing it." + ready=true + existing_id="$EXISTING_ID" + fi + else + echo "No existing container; creating a new one." + need_create=true + fi + { + echo "need_create=$need_create" + echo "need_delete=$need_delete" + echo "ready=$ready" + echo "existing_id=$existing_id" + } >> $GITHUB_OUTPUT + + - name: Delete Existing Container (recreate) + if: steps.plan.outputs.need_delete == 'true' + uses: mieweb/opensource-server/.github/actions/delete-container@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: ${{ inputs.site_id }} + container_id: ${{ steps.plan.outputs.existing_id }} + + - name: Create Container + id: create + if: steps.plan.outputs.need_create == 'true' + uses: mieweb/opensource-server/.github/actions/create-container@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: ${{ inputs.site_id }} + hostname: ${{ inputs.hostname }} + template_name: ${{ inputs.template_name }} + services: ${{ inputs.services }} + container_env_vars: ${{ inputs.container_env_vars }} + + # ── Phase 2: Wait for the creation job to finish ── + - name: Wait for Job to Complete + id: wait-job + if: ${{ steps.create.outputs.created == 'true' && env.FAILED != '1' }} + uses: mieweb/opensource-server/.github/actions/wait-for-job@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + job_id: ${{ steps.create.outputs.job_id }} + + # ── Phase 3: Query the container for its final details ── + - name: Query Container Status + id: status + if: ${{ steps.plan.outputs.ready == 'true' || steps.create.outputs.created == 'true' || steps.wait-job.outputs.job_done == 'true' }} + uses: mieweb/opensource-server/.github/actions/get-container@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: ${{ inputs.site_id }} + hostname: ${{ inputs.hostname }} + + - name: Report Container Status + if: ${{ steps.plan.outputs.ready == 'true' || steps.create.outputs.created == 'true' || steps.wait-job.outputs.job_done == 'true' }} + shell: bash + env: + CONTAINER_NAME: ${{ inputs.hostname }} + FOUND: ${{ steps.status.outputs.found }} + CONTAINER_STATUS: ${{ steps.status.outputs.status }} + CONTAINER_IP: ${{ steps.status.outputs.ipv4_address }} + SSH_PORT: ${{ steps.status.outputs.ssh_port }} + HTTP_PORT: ${{ steps.status.outputs.http_port }} + NODE_NAME: ${{ steps.status.outputs.node_name }} + run: | + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Container : $CONTAINER_NAME" + echo " Status : ${CONTAINER_STATUS:-unknown}" + echo " IP : ${CONTAINER_IP:-pending}" + echo " SSH Port : ${SSH_PORT:-N/A}" + echo " HTTP Port : ${HTTP_PORT:-N/A}" + echo " Node : ${NODE_NAME:-unknown}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + if [ "$FOUND" != "true" ]; then + echo "::error::Container '$CONTAINER_NAME' was not found after creation." + exit 1 + fi + if [ "$CONTAINER_STATUS" != "running" ]; then + echo "::error::Container status is '$CONTAINER_STATUS', expected 'running'." + exit 1 + fi From 38f712db25c31c977850fc9ee9488900ce042aff Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 10:42:16 -0400 Subject: [PATCH 2/8] Extract container delete steps into delete/ sub-action --- delete/action.yml | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 delete/action.yml diff --git a/delete/action.yml b/delete/action.yml new file mode 100644 index 0000000..bb41c4a --- /dev/null +++ b/delete/action.yml @@ -0,0 +1,48 @@ +name: LaunchPad Delete +description: Delete the container for a branch via the Container API, if one exists +author: mieweb + +inputs: + api_key: + description: "API Key for authentication" + required: true + api_url: + description: "Base URL for the Container API" + required: true + site_id: + description: "Site ID for the container management system" + required: false + default: "1" + hostname: + description: "Container hostname" + required: true + +runs: + using: "composite" + steps: + - name: Locate Container for Deletion + id: locate-delete + uses: mieweb/opensource-server/.github/actions/get-container@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: ${{ inputs.site_id }} + hostname: ${{ inputs.hostname }} + + - name: Delete Container on Branch Deletion + if: steps.locate-delete.outputs.found == 'true' + continue-on-error: true + uses: mieweb/opensource-server/.github/actions/delete-container@main + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: ${{ inputs.site_id }} + container_id: ${{ steps.locate-delete.outputs.container_id }} + + - name: No Container to Delete + if: steps.locate-delete.outputs.found != 'true' + shell: bash + env: + CONTAINER_NAME: ${{ inputs.hostname }} + run: | + echo "No container named '$CONTAINER_NAME' found to delete." From 5c361a6acec25898cbeb78feef280b593da0623c Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 10:45:03 -0400 Subject: [PATCH 3/8] Rewrite root action as dispatcher over manage/ and delete/ sub-actions --- action.yml | 143 +++-------------------------------------------------- 1 file changed, 7 insertions(+), 136 deletions(-) diff --git a/action.yml b/action.yml index 2820d65..040899d 100644 --- a/action.yml +++ b/action.yml @@ -6,8 +6,9 @@ branding: color: "purple" # LaunchPad is the launchpad-specific orchestrator: it derives a container name -# from the repository/branch, decides when to create/recreate/delete, and then -# delegates every API call to the reusable composite actions in +# from the repository/branch, classifies the event, and dispatches to the +# sub-actions in this repository (manage/, delete/), which in turn delegate +# every API call to the reusable composite actions in # mieweb/opensource-server/.github/actions/*. inputs: @@ -139,66 +140,9 @@ runs: fi echo "template_name=$TEMPLATE_NAME" >> $GITHUB_OUTPUT - # ── Phase 1: Create or locate the container ── - - name: Locate Existing Container - id: locate + - name: Manage Container if: steps.classify.outputs.manage == 'true' - uses: mieweb/opensource-server/.github/actions/get-container@main - with: - api_url: ${{ inputs.api_url }} - api_key: ${{ inputs.api_key }} - site_id: ${{ inputs.site_id }} - hostname: ${{ steps.prepare.outputs.container_name }} - - - name: Plan Create / Recreate - id: plan - shell: bash - if: steps.classify.outputs.manage == 'true' - env: - FOUND: ${{ steps.locate.outputs.found }} - EXISTING_ID: ${{ steps.locate.outputs.container_id }} - TEMPLATE_NAME: ${{ steps.prepare.outputs.template_name }} - run: | - need_create=false - need_delete=false - ready=false - existing_id="" - - if [ "$FOUND" == "true" ] && [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then - if [ -n "$TEMPLATE_NAME" ]; then - echo "Existing container found (ID: $EXISTING_ID); deleting to recreate with updated configuration." - need_delete=true - need_create=true - existing_id="$EXISTING_ID" - else - echo "Existing container found (ID: $EXISTING_ID); no template provided, reusing it." - ready=true - existing_id="$EXISTING_ID" - fi - else - echo "No existing container; creating a new one." - need_create=true - fi - { - echo "need_create=$need_create" - echo "need_delete=$need_delete" - echo "ready=$ready" - echo "existing_id=$existing_id" - } >> $GITHUB_OUTPUT - - - name: Delete Existing Container (recreate) - if: steps.plan.outputs.need_delete == 'true' - uses: mieweb/opensource-server/.github/actions/delete-container@main - with: - api_url: ${{ inputs.api_url }} - api_key: ${{ inputs.api_key }} - site_id: ${{ inputs.site_id }} - container_id: ${{ steps.plan.outputs.existing_id }} - - - name: Create Container - id: create - if: steps.plan.outputs.need_create == 'true' - uses: mieweb/opensource-server/.github/actions/create-container@main + uses: $/manage with: api_url: ${{ inputs.api_url }} api_key: ${{ inputs.api_key }} @@ -208,88 +152,15 @@ runs: services: ${{ inputs.services }} container_env_vars: ${{ inputs.container_env_vars }} - # ── Phase 2: Wait for the creation job to finish ── - - name: Wait for Job to Complete - id: wait-job - if: ${{ steps.create.outputs.created == 'true' && env.FAILED != '1' }} - uses: mieweb/opensource-server/.github/actions/wait-for-job@main - with: - api_url: ${{ inputs.api_url }} - api_key: ${{ inputs.api_key }} - job_id: ${{ steps.create.outputs.job_id }} - - # ── Phase 3: Query the container for its final details ── - - name: Query Container Status - id: status - if: ${{ steps.classify.outputs.manage == 'true' && (steps.plan.outputs.ready == 'true' || steps.create.outputs.created == 'true' || steps.wait-job.outputs.job_done == 'true') }} - uses: mieweb/opensource-server/.github/actions/get-container@main - with: - api_url: ${{ inputs.api_url }} - api_key: ${{ inputs.api_key }} - site_id: ${{ inputs.site_id }} - hostname: ${{ steps.prepare.outputs.container_name }} - - - name: Report Container Status - if: ${{ steps.classify.outputs.manage == 'true' && (steps.plan.outputs.ready == 'true' || steps.create.outputs.created == 'true' || steps.wait-job.outputs.job_done == 'true') }} - shell: bash - env: - CONTAINER_NAME: ${{ steps.prepare.outputs.container_name }} - FOUND: ${{ steps.status.outputs.found }} - CONTAINER_STATUS: ${{ steps.status.outputs.status }} - CONTAINER_IP: ${{ steps.status.outputs.ipv4_address }} - SSH_PORT: ${{ steps.status.outputs.ssh_port }} - HTTP_PORT: ${{ steps.status.outputs.http_port }} - NODE_NAME: ${{ steps.status.outputs.node_name }} - run: | - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Container : $CONTAINER_NAME" - echo " Status : ${CONTAINER_STATUS:-unknown}" - echo " IP : ${CONTAINER_IP:-pending}" - echo " SSH Port : ${SSH_PORT:-N/A}" - echo " HTTP Port : ${HTTP_PORT:-N/A}" - echo " Node : ${NODE_NAME:-unknown}" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - if [ "$FOUND" != "true" ]; then - echo "::error::Container '$CONTAINER_NAME' was not found after creation." - exit 1 - fi - if [ "$CONTAINER_STATUS" != "running" ]; then - echo "::error::Container status is '$CONTAINER_STATUS', expected 'running'." - exit 1 - fi - - # ── Container Deletion on Branch Deletion ── - - name: Locate Container for Deletion - id: locate-delete + - name: Delete Container if: steps.classify.outputs.delete == 'true' - uses: mieweb/opensource-server/.github/actions/get-container@main + uses: $/delete with: api_url: ${{ inputs.api_url }} api_key: ${{ inputs.api_key }} site_id: ${{ inputs.site_id }} hostname: ${{ steps.prepare.outputs.container_name }} - - name: Delete Container on Branch Deletion - if: steps.locate-delete.outputs.found == 'true' - continue-on-error: true - uses: mieweb/opensource-server/.github/actions/delete-container@main - with: - api_url: ${{ inputs.api_url }} - api_key: ${{ inputs.api_key }} - site_id: ${{ inputs.site_id }} - container_id: ${{ steps.locate-delete.outputs.container_id }} - - - name: No Container to Delete - if: ${{ steps.classify.outputs.delete == 'true' && steps.locate-delete.outputs.found != 'true' }} - shell: bash - env: - CONTAINER_NAME: ${{ steps.prepare.outputs.container_name }} - run: | - echo "No container named '$CONTAINER_NAME' found to delete." - - name: Catch All Failure Step if: env.FAILED == '1' shell: bash From ea213d2daf988b39b108b9e030784372133f5440 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 10:56:15 -0400 Subject: [PATCH 4/8] Add default lifecycle Dockerfile, app.service template, and build test --- build/Dockerfile | 31 +++++++++++++++++++++++ build/app.service.in | 14 +++++++++++ test/build-test.sh | 46 ++++++++++++++++++++++++++++++++++ test/fixture/package-lock.json | 12 +++++++++ test/fixture/package.json | 7 ++++++ test/fixture/server.js | 5 ++++ 6 files changed, 115 insertions(+) create mode 100644 build/Dockerfile create mode 100644 build/app.service.in create mode 100755 test/build-test.sh create mode 100644 test/fixture/package-lock.json create mode 100644 test/fixture/package.json create mode 100644 test/fixture/server.js diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..a55fba7 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 +# Default LaunchPad application image. Models a standard app lifecycle: +# 1 Dependencies -> DEPENDENCY_COMMAND 2+3 Configure/Build -> BUILD_COMMAND +# 4 Install -> this build (COPY + unit) 5 Run -> RUN_COMMAND via app.service +# Requires a buildx named context "launchpad" pointing at this directory +# (for app.service.in), e.g.: +# docker buildx build -f build/Dockerfile --build-context launchpad=build +ARG BASE_IMAGE=ghcr.io/mieweb/opensource-server/docker-nodejs:latest +FROM ${BASE_IMAGE} +SHELL ["/bin/bash", "-c"] + +ARG WORKING_DIRECTORY=/workspace +ARG DEPENDENCY_COMMAND="npm ci --no-audit --no-fund" +ARG BUILD_COMMAND="" +ARG RUN_COMMAND="npm start" +ARG REPO_URL="" + +COPY . ${WORKING_DIRECTORY} +WORKDIR ${WORKING_DIRECTORY} + +# -- Lifecycle 1: Dependencies -- +RUN eval "$DEPENDENCY_COMMAND" + +# -- Lifecycle 2+3: Configure + Build (no-op when empty) -- +RUN eval "$BUILD_COMMAND" + +# -- Lifecycle 4: Install -- +RUN --mount=type=bind,from=launchpad,source=app.service.in,target=/tmp/app.service.in \ + envsubst '${WORKING_DIRECTORY} ${RUN_COMMAND} ${REPO_URL}' \ + /etc/systemd/system/app.service \ + && systemctl enable app.service diff --git a/build/app.service.in b/build/app.service.in new file mode 100644 index 0000000..2c0d521 --- /dev/null +++ b/build/app.service.in @@ -0,0 +1,14 @@ +[Unit] +Description=LaunchPad-managed application +Documentation=${REPO_URL} +Wants=network-online.target +After=network-online.target environment.service + +[Service] +WorkingDirectory=${WORKING_DIRECTORY} +EnvironmentFile=-/etc/environment +ExecStart=/bin/bash -c '${RUN_COMMAND}' +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/test/build-test.sh b/test/build-test.sh new file mode 100755 index 0000000..14c8d2c --- /dev/null +++ b/test/build-test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Builds the default LaunchPad Dockerfile against the fixture app and asserts +# the image contains a correctly rendered, enabled app.service unit. +set -euo pipefail +cd "$(dirname "$0")/.." + +REPO_URL=https://github.com/mieweb/launchpad + +# ── Scenario 1: all defaults ── +docker buildx build \ + --load \ + -f build/Dockerfile \ + --build-context launchpad=build \ + --build-arg REPO_URL="$REPO_URL" \ + -t launchpad-build-test:default \ + test/fixture + +unit=$(docker run --rm --entrypoint cat launchpad-build-test:default /etc/systemd/system/app.service) +echo "$unit" +echo "$unit" | grep -Fxq "WorkingDirectory=/workspace" +echo "$unit" | grep -Fxq "ExecStart=/bin/bash -c 'npm start'" +echo "$unit" | grep -Fxq "Documentation=$REPO_URL" + +docker run --rm --entrypoint test launchpad-build-test:default -L /etc/systemd/system/multi-user.target.wants/app.service +docker run --rm --entrypoint test launchpad-build-test:default -f /workspace/package.json +docker run --rm --entrypoint test launchpad-build-test:default -f /workspace/server.js + +# ── Scenario 2: overridden build_command and run_command ── +docker buildx build \ + --load \ + -f build/Dockerfile \ + --build-context launchpad=build \ + --build-arg REPO_URL="$REPO_URL" \ + --build-arg BUILD_COMMAND='echo built >built.txt' \ + --build-arg RUN_COMMAND='node server.js --port $PORT' \ + -t launchpad-build-test:custom \ + test/fixture + +docker run --rm --entrypoint test launchpad-build-test:custom -f /workspace/built.txt + +unit2=$(docker run --rm --entrypoint cat launchpad-build-test:custom /etc/systemd/system/app.service) +echo "$unit2" +# envsubst must substitute only the listed variables: $PORT survives verbatim. +echo "$unit2" | grep -Fxq "ExecStart=/bin/bash -c 'node server.js --port \$PORT'" + +echo "PASS: build test succeeded" diff --git a/test/fixture/package-lock.json b/test/fixture/package-lock.json new file mode 100644 index 0000000..a021fa2 --- /dev/null +++ b/test/fixture/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "launchpad-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "launchpad-fixture", + "version": "1.0.0" + } + } +} diff --git a/test/fixture/package.json b/test/fixture/package.json new file mode 100644 index 0000000..f80d7c7 --- /dev/null +++ b/test/fixture/package.json @@ -0,0 +1,7 @@ +{ + "name": "launchpad-fixture", + "version": "1.0.0", + "scripts": { + "start": "node server.js" + } +} diff --git a/test/fixture/server.js b/test/fixture/server.js new file mode 100644 index 0000000..550f87e --- /dev/null +++ b/test/fixture/server.js @@ -0,0 +1,5 @@ +const http = require('http'); +const port = process.env.PORT || 3000; +http.createServer((req, res) => { + res.end('launchpad fixture\n'); +}).listen(port, () => console.log(`listening on ${port}`)); From c054831ccde9272adeda3e82005765bbbe97632a Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 11:00:34 -0400 Subject: [PATCH 5/8] Add CI workflow for build test and action linting --- .github/workflows/test.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7737a67 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate action metadata + run: | + find . -maxdepth 2 -name action.yml -not -path './.github/*' -print0 | + while IFS= read -r -d '' f; do + echo "::group::$f" + npx --yes --package=@action-validator/cli action-validator "$f" + echo "::endgroup::" + done + + - name: Lint workflows + run: | + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color + + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Run build test + run: ./test/build-test.sh From 6d951d137b042b7b681b36f0ebb63ccd6a898170 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 11:03:53 -0400 Subject: [PATCH 6/8] Add build/ sub-action to build and push application images --- build/action.yml | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 build/action.yml diff --git a/build/action.yml b/build/action.yml new file mode 100644 index 0000000..dde2dd2 --- /dev/null +++ b/build/action.yml @@ -0,0 +1,88 @@ +name: LaunchPad Build +description: Build the checked-out repository into a LaunchPad application image and push it to ghcr.io +author: mieweb + +inputs: + image: + description: "Full image reference to build and push. Empty = ghcr.io///launchpad:. Must be pushable with the workflow's GITHUB_TOKEN (i.e., under ghcr.io//)." + required: false + default: "" + base_image: + description: "Base image for the default Dockerfile (must be systemd-enabled and provide envsubst)" + required: false + default: "ghcr.io/mieweb/opensource-server/docker-nodejs:latest" + working_directory: + description: "Directory inside the image where the repository is copied" + required: false + default: "/workspace" + dependency_command: + description: "Lifecycle 1 (Dependencies): install third-party dependencies" + required: false + default: "npm ci --no-audit --no-fund" + build_command: + description: "Lifecycle 2+3 (Configure + Build): e.g. './configure && make'. Empty = skipped." + required: false + default: "" + run_command: + description: "Lifecycle 5 (Run): command app.service executes" + required: false + default: "npm start" + +outputs: + image: + description: "The image reference that was built and pushed" + value: ${{ steps.ref.outputs.image }} + +runs: + using: "composite" + steps: + - name: Validate Workspace + shell: bash + run: | + if [ -z "$(ls -A "$GITHUB_WORKSPACE" 2>/dev/null)" ]; then + echo "::error::Build mode requires actions/checkout before this action" + exit 1 + fi + + - name: Resolve Image Reference + id: ref + shell: bash + env: + IMAGE_INPUT: ${{ inputs.image }} + run: | + if [ -n "$IMAGE_INPUT" ]; then + IMAGE="$IMAGE_INPUT" + else + IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}/launchpad:${GITHUB_SHA}" + fi + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "Image: $IMAGE" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and Push + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ github.action_path }}/Dockerfile + build-contexts: | + launchpad=${{ github.action_path }} + push: true + tags: ${{ steps.ref.outputs.image }} + build-args: | + BASE_IMAGE=${{ inputs.base_image }} + WORKING_DIRECTORY=${{ inputs.working_directory }} + DEPENDENCY_COMMAND=${{ inputs.dependency_command }} + BUILD_COMMAND=${{ inputs.build_command }} + RUN_COMMAND=${{ inputs.run_command }} + REPO_URL=${{ github.server_url }}/${{ github.repository }} + cache-from: type=gha,scope=launchpad-${{ github.ref_name }} + cache-to: type=gha,scope=launchpad-${{ github.ref_name }},mode=max From fa88aaff74a3ab9b6c6af979b9402216c73b6494 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 11:06:46 -0400 Subject: [PATCH 7/8] Route manage events without template_name through the build sub-action --- action.yml | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/action.yml b/action.yml index 040899d..4fed49d 100644 --- a/action.yml +++ b/action.yml @@ -7,8 +7,12 @@ branding: # LaunchPad is the launchpad-specific orchestrator: it derives a container name # from the repository/branch, classifies the event, and dispatches to the -# sub-actions in this repository (manage/, delete/), which in turn delegate -# every API call to the reusable composite actions in +# sub-actions in this repository: +# build/ - when no template_name is given on a manage event, builds the +# checked-out repo into an image and pushes it to ghcr.io +# manage/ - creates or recreates the container via the Container API +# delete/ - removes the container on branch deletion / PR close +# The sub-actions delegate every API call to the reusable composite actions in # mieweb/opensource-server/.github/actions/*. inputs: @@ -31,6 +35,35 @@ inputs: description: "Site ID for the container management system" required: false default: "1" + image: + description: "Build mode: full image reference to build and push. Empty = ghcr.io///launchpad:. Must be pushable with the workflow's GITHUB_TOKEN (i.e., under ghcr.io//)." + required: false + default: "" + base_image: + description: "Build mode: base image for the default Dockerfile (must be systemd-enabled and provide envsubst)" + required: false + default: "ghcr.io/mieweb/opensource-server/docker-nodejs:latest" + working_directory: + description: "Build mode: directory inside the image where the repository is copied" + required: false + default: "/workspace" + dependency_command: + description: "Build mode lifecycle 1 (Dependencies): install third-party dependencies" + required: false + default: "npm ci --no-audit --no-fund" + build_command: + description: "Build mode lifecycle 2+3 (Configure + Build): e.g. './configure && make'. Empty = skipped." + required: false + default: "" + run_command: + description: "Build mode lifecycle 5 (Run): command app.service executes" + required: false + default: "npm start" + +outputs: + image: + description: "Image built and pushed by build mode (empty on the traditional template_name path)" + value: ${{ steps.build.outputs.image }} runs: using: "composite" @@ -140,6 +173,18 @@ runs: fi echo "template_name=$TEMPLATE_NAME" >> $GITHUB_OUTPUT + - name: Build Image + id: build + if: ${{ steps.classify.outputs.manage == 'true' && inputs.template_name == '' }} + uses: $/build + with: + image: ${{ inputs.image }} + base_image: ${{ inputs.base_image }} + working_directory: ${{ inputs.working_directory }} + dependency_command: ${{ inputs.dependency_command }} + build_command: ${{ inputs.build_command }} + run_command: ${{ inputs.run_command }} + - name: Manage Container if: steps.classify.outputs.manage == 'true' uses: $/manage @@ -148,7 +193,7 @@ runs: api_key: ${{ inputs.api_key }} site_id: ${{ inputs.site_id }} hostname: ${{ steps.prepare.outputs.container_name }} - template_name: ${{ steps.prepare.outputs.template_name }} + template_name: ${{ steps.build.outputs.image || steps.prepare.outputs.template_name }} services: ${{ inputs.services }} container_env_vars: ${{ inputs.container_env_vars }} From dee0b6062ba77986566afcc576de229548bf3128 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 12 Aug 2026 11:10:03 -0400 Subject: [PATCH 8/8] Document build mode, new inputs, and breaking template_name change --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a9c6fae..f90a715 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,35 @@ # MIEWeb LaunchPad -GitHub Action that deploys Docker/OCI images as LXC containers on an [opensource-server](https://github.com/mieweb/opensource-server) Proxmox cluster via the Container API. It derives a container name from your repository and branch, decides when to create/recreate/delete, and delegates every API call to the reusable composite actions in [mieweb/opensource-server/.github/actions](https://github.com/mieweb/opensource-server/tree/main/.github/actions). +GitHub Action that deploys your repository as an LXC container on an [opensource-server](https://github.com/mieweb/opensource-server) Proxmox cluster via the Container API. Point it at a Node.js repo with zero configuration and it builds a systemd-enabled image, pushes it to ghcr.io, and deploys it — or hand it a pre-built image via `template_name`. It derives a container name from your repository and branch, decides when to create/recreate/delete, and delegates every API call to the reusable composite actions in [mieweb/opensource-server/.github/actions](https://github.com/mieweb/opensource-server/tree/main/.github/actions). ## Prerequisites -- A Docker/OCI image published to a container registry (GHCR, Docker Hub, etc.) - An API key for the create-a-container server (request one from your site admin) +- **Build mode** (no `template_name`): a public repository — the image package inherits the repo's visibility at first publish, and the cluster pulls anonymously. Private repos need a one-time manual package visibility flip on ghcr.io. +- **Traditional mode**: a Docker/OCI image published to a public container registry (GHCR, Docker Hub, etc.) + +## Application Lifecycle (Build Mode) + +When `template_name` is omitted on a manage event, LaunchPad builds the checked-out repository with a default Dockerfile that models a standard application lifecycle, assuming a Node.js app by default: + +| # | Stage | Input | Default | +|---|-------|-------|---------| +| 1 | Dependencies | `dependency_command` | `npm ci --no-audit --no-fund` | +| 2 | Configure | `build_command` | *(empty — skipped)* | +| 3 | Build | `build_command` (same input, e.g. `./configure && make`) | *(empty — skipped)* | +| 4 | Install | built into the image: repo copied to `working_directory`, systemd unit installed and enabled | — | +| 5 | Run | `run_command`, executed by `app.service` | `npm start` | + +The image is based on `base_image` (systemd + Docker-in-Docker enabled), tagged `ghcr.io///launchpad:` (override with `image`), and pushed with the workflow's `GITHUB_TOKEN`. The generated `app.service` sets `Documentation=` to your repository URL, runs in `working_directory`, and loads container environment variables from `/etc/environment`. + +Requirements and limits: + +- Run `actions/checkout` before this action, and grant the job `permissions: contents: read, packages: write`. +- A `run_command` containing single quotes or newlines breaks the generated unit — commit a script to your repo and use that instead (or use the traditional path with your own Dockerfile). +- A custom `base_image` must be systemd-enabled and provide `envsubst` (Debian package `gettext-base`). +- The `image` override must be under `ghcr.io//` so `GITHUB_TOKEN` can push it. + +> **Breaking change:** previously, omitting `template_name` on a manage event reused an existing container as-is. It now builds and deploys from the repository. Cleanup-only jobs (PR close / branch delete) are unaffected — they never build. ## Repository Secrets @@ -22,10 +46,24 @@ Add these in **Settings > Secrets and variables > Actions**: |-------|----------|---------|-------------| | `api_key` | Yes | — | Bearer token for authenticating with the Container API | | `api_url` | Yes | — | Base URL of the create-a-container server | -| `template_name` | No | — | Docker/OCI image reference to deploy (e.g., `ghcr.io/org/app:tag`). When provided and a container already exists, it is deleted and recreated with the new template. When omitted, an existing container is reused as-is. | +| `template_name` | No | — | Docker/OCI image reference to deploy (e.g., `ghcr.io/org/app:tag`). When provided, an existing container is deleted and recreated with the new template. **When omitted on a manage event, LaunchPad builds the repository instead (build mode).** | | `container_env_vars` | No | — | Environment variables to set inside the container (JSON string, e.g., `'{"NODE_ENV": "production"}'`) | | `services` | No | — | Services configuration (JSON array string) | | `site_id` | No | `1` | Site ID for the container management system | +| `image` | No | `ghcr.io///launchpad:` | Build mode: full image reference to build and push. Must be under `ghcr.io//` | +| `base_image` | No | `ghcr.io/mieweb/opensource-server/docker-nodejs:latest` | Build mode: base image for the default Dockerfile | +| `working_directory` | No | `/workspace` | Build mode: where the repository is copied inside the image | +| `dependency_command` | No | `npm ci --no-audit --no-fund` | Build mode: dependency install command | +| `build_command` | No | *(empty — skipped)* | Build mode: configure/build command (e.g. `./configure && make`) | +| `run_command` | No | `npm start` | Build mode: command `app.service` runs | + +Build-mode inputs are ignored when `template_name` is provided. + +## Outputs + +| Output | Description | +|--------|-------------| +| `image` | The image reference built and pushed by build mode (empty on the traditional `template_name` path) | ## Supported Workflow Events @@ -48,21 +86,21 @@ Containers are named `--`, lowercased, with any character o sequenceDiagram participant GH as GitHub Workflow participant LP as LaunchPad + participant REG as ghcr.io participant API as Container API participant Prox as Proxmox Cluster GH->>LP: push / create / pull_request / delete + alt Manage event, no template_name (build mode) + LP->>REG: build default Dockerfile, push ghcr.io/owner/repo/launchpad:sha + end LP->>API: get-container (by hostname) - alt Manage event (push, create, PR open/sync) - alt Container exists and template_name provided + alt Manage event + alt Container exists LP->>API: delete-container - LP->>API: create-container - else Container exists, no template_name - LP->>LP: Reuse existing container - else No container - LP->>API: create-container end + LP->>API: create-container (template = built image or template_name) API->>Prox: Enqueue creation job LP->>API: wait-for-job (poll until done) LP->>API: get-container (final status) @@ -72,11 +110,51 @@ sequenceDiagram end ``` -Each API call is a thin composite action: [get-container](https://github.com/mieweb/opensource-server/tree/main/.github/actions/get-container), [create-container](https://github.com/mieweb/opensource-server/tree/main/.github/actions/create-container), [delete-container](https://github.com/mieweb/opensource-server/tree/main/.github/actions/delete-container), [wait-for-job](https://github.com/mieweb/opensource-server/tree/main/.github/actions/wait-for-job). +The action is a dispatcher over three sub-actions in this repository — [build](https://github.com/mieweb/launchpad/tree/main/build), [manage](https://github.com/mieweb/launchpad/tree/main/manage), and [delete](https://github.com/mieweb/launchpad/tree/main/delete) — which delegate each API call to a thin composite action: [get-container](https://github.com/mieweb/opensource-server/tree/main/.github/actions/get-container), [create-container](https://github.com/mieweb/opensource-server/tree/main/.github/actions/create-container), [delete-container](https://github.com/mieweb/opensource-server/tree/main/.github/actions/delete-container), [wait-for-job](https://github.com/mieweb/opensource-server/tree/main/.github/actions/wait-for-job). The action fails if the container is not found after creation or its final status is not `running`. -## Basic Usage +## Basic Usage — Zero-Config Build & Deploy + +For a Node.js repository, this is all you need. On every push, LaunchPad builds the repo into a systemd-enabled image, pushes it to ghcr.io, and deploys it: + +```yaml +name: Deploy + +on: + push: + +permissions: + contents: read + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: mieweb/launchpad@main + with: + api_key: ${{ secrets.API_KEY }} + api_url: ${{ secrets.API_URL }} +``` + +Override any lifecycle stage as needed: + +```yaml + - uses: mieweb/launchpad@main + with: + api_key: ${{ secrets.API_KEY }} + api_url: ${{ secrets.API_URL }} + dependency_command: npm ci --no-audit --no-fund && pip install -r requirements.txt + build_command: npm run build + run_command: node dist/server.js +``` + +## Basic Usage — Pre-Built Image + +Build and push the image yourself (any Dockerfile, any workflow), then hand LaunchPad the reference: ```yaml - uses: mieweb/launchpad@main @@ -122,7 +200,7 @@ jobs: api_url: ${{ secrets.API_URL }} ``` -`template_name` is not needed for cleanup — the closed PR event triggers deletion of the container associated with the PR head branch. +`template_name` is not needed for cleanup — the closed PR event triggers deletion of the container associated with the PR head branch. Omitting `template_name` triggers build mode only on manage events; `closed` events go straight to deletion. ## Complete Example: Build + Deploy