From 36a360c932133a417ceedc05ba2b04b3cb49ef6f Mon Sep 17 00:00:00 2001 From: Rishi Jat Date: Wed, 15 Apr 2026 02:54:27 +0530 Subject: [PATCH 1/2] Add GitHub Action for modctl Signed-off-by: Rishi Jat --- .github/workflows/modctl-action.yml | 126 +++++++++++++++++ README.md | 15 ++ action.yml | 207 ++++++++++++++++++++++++++++ docs/getting-started.md | 80 +++++++++++ 4 files changed, 428 insertions(+) create mode 100644 .github/workflows/modctl-action.yml create mode 100644 action.yml diff --git a/.github/workflows/modctl-action.yml b/.github/workflows/modctl-action.yml new file mode 100644 index 00000000..fc7fdb8b --- /dev/null +++ b/.github/workflows/modctl-action.yml @@ -0,0 +1,126 @@ +name: modctl Action + +on: + push: + branches: [main, release-*] + paths: + - action.yml + - README.md + - docs/getting-started.md + - .github/workflows/modctl-action.yml + pull_request: + branches: [main, release-*] + paths: + - action.yml + - README.md + - docs/getting-started.md + - .github/workflows/modctl-action.yml + +permissions: + contents: read + +jobs: + action-build: + name: "Action Build (version: ${{ matrix.modctl_version_label }})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - modctl_version: "" + modctl_version_label: latest + - modctl_version: "0.2.0" + modctl_version_label: "0.2.0" + steps: + - name: Checkout code + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v4.3.1 + + - name: Prepare fixture + run: | + set -euo pipefail + fixture_dir="${RUNNER_TEMP}/modctl-action-fixture" + mkdir -p "${fixture_dir}" + + cat > "${fixture_dir}/Modelfile" << 'EOF' + NAME tiny-model + ARCH transformer + FAMILY tiny + FORMAT safetensors + PARAMSIZE 1 + PRECISION fp16 + CONFIG config.json + MODEL model.safetensors + DOC README.md + EOF + + printf '{}' > "${fixture_dir}/config.json" + printf 'tiny-weights' > "${fixture_dir}/model.safetensors" + printf '# tiny fixture\n' > "${fixture_dir}/README.md" + + artifact_ref="ghcr.io/modelpack/modctl-action-local:${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${{ matrix.modctl_version_label }}" + echo "FIXTURE_DIR=${fixture_dir}" >> "${GITHUB_ENV}" + echo "ARTIFACT_REF=${artifact_ref}" >> "${GITHUB_ENV}" + + - name: Run modctl action + uses: ./ + with: + modctl_version: ${{ matrix.modctl_version }} + modelfile_path: ${{ env.FIXTURE_DIR }}/Modelfile + artifact_name: ${{ env.ARTIFACT_REF }} + context_path: ${{ env.FIXTURE_DIR }} + + - name: Verify artifact exists locally + run: | + set -euo pipefail + modctl inspect "${ARTIFACT_REF}" + + action-registry-login: + name: Action Registry Login Path + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v4.3.1 + + - name: Prepare fixture + run: | + set -euo pipefail + fixture_dir="${RUNNER_TEMP}/modctl-action-fixture-login" + mkdir -p "${fixture_dir}" + + cat > "${fixture_dir}/Modelfile" << 'EOF' + NAME tiny-model + ARCH transformer + FAMILY tiny + FORMAT safetensors + PARAMSIZE 1 + PRECISION fp16 + CONFIG config.json + MODEL model.safetensors + DOC README.md + EOF + + printf '{}' > "${fixture_dir}/config.json" + printf 'tiny-weights' > "${fixture_dir}/model.safetensors" + printf '# tiny fixture\n' > "${fixture_dir}/README.md" + + artifact_ref="ghcr.io/modelpack/modctl-action-login:${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "FIXTURE_DIR=${fixture_dir}" >> "${GITHUB_ENV}" + echo "ARTIFACT_REF=${artifact_ref}" >> "${GITHUB_ENV}" + + - name: Run modctl action with optional registry integration + uses: ./ + with: + modelfile_path: ${{ env.FIXTURE_DIR }}/Modelfile + artifact_name: ${{ env.ARTIFACT_REF }} + context_path: ${{ env.FIXTURE_DIR }} + registry: ghcr.io + registry_username: ${{ github.actor }} + registry_password: ${{ github.token }} + + - name: Verify artifact exists locally + run: | + set -euo pipefail + modctl inspect "${ARTIFACT_REF}" diff --git a/README.md b/README.md index 02e5aca3..0a6c0836 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,21 @@ It offers commands such as `build`, `pull`, `push`, and more, making it easy for You can find the full documentation on the [getting started](./docs/getting-started.md). +## GitHub Action + +Use the built-in action to install `modctl` and build a model artifact in GitHub Actions: + +```yaml +- name: Build model artifact + uses: modelpack/modctl@main + with: + artifact_name: ghcr.io/${{ github.repository_owner }}/my-model:latest + modelfile_path: ./Modelfile + context_path: . +``` + +For full inputs, optional version pinning, and optional registry integration, see [GitHub Action usage](./docs/getting-started.md#github-action). + ## Copyright Copyright © contributors to ModelPack, established as ModelPack a Series of LF Projects, LLC. diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..d1645825 --- /dev/null +++ b/action.yml @@ -0,0 +1,207 @@ +name: "modctl build" +description: "Install modctl and build a model artifact, with optional registry login." +author: "ModelPack" + +inputs: + modctl_version: + description: "modctl version to install (for example: 0.2.0 or v0.2.0). Empty means latest release." + required: false + default: "" + modelfile_path: + description: "Path to the Modelfile used by modctl build." + required: false + default: "Modelfile" + artifact_name: + description: "Model artifact reference passed to modctl build --target." + required: true + context_path: + description: "Build context path passed as the final modctl build argument." + required: false + default: "." + output_remote: + description: "Set to true to pass --output-remote to modctl build." + required: false + default: "false" + plain_http: + description: "Set to true to use plain HTTP for optional login and build operations." + required: false + default: "false" + insecure: + description: "Set to true to disable TLS verification for optional login and build operations." + required: false + default: "false" + registry: + description: "Optional registry host for modctl login (for example: ghcr.io)." + required: false + default: "" + registry_username: + description: "Optional registry username for modctl login." + required: false + default: "" + registry_password: + description: "Optional registry password or token for modctl login." + required: false + default: "" + +outputs: + modctl-version: + description: "Installed modctl version without the leading v prefix." + value: ${{ steps.install.outputs.modctl-version }} + +runs: + using: "composite" + steps: + - name: Validate action inputs + shell: bash + run: | + set -euo pipefail + + validate_bool() { + local name="$1" + local value="$2" + if [[ "$value" != "true" && "$value" != "false" ]]; then + echo "Input '${name}' must be 'true' or 'false', got '${value}'." >&2 + exit 1 + fi + } + + if [[ -z "${{ inputs.artifact_name }}" ]]; then + echo "Input 'artifact_name' is required." >&2 + exit 1 + fi + + if [[ ! -f "${{ inputs.modelfile_path }}" ]]; then + echo "Modelfile not found at '${{ inputs.modelfile_path }}'." >&2 + exit 1 + fi + + if [[ ! -e "${{ inputs.context_path }}" ]]; then + echo "Context path '${{ inputs.context_path }}' does not exist." >&2 + exit 1 + fi + + validate_bool "output_remote" "${{ inputs.output_remote }}" + validate_bool "plain_http" "${{ inputs.plain_http }}" + validate_bool "insecure" "${{ inputs.insecure }}" + + registry="${{ inputs.registry }}" + username="${{ inputs.registry_username }}" + password="${{ inputs.registry_password }}" + if [[ -n "$registry" || -n "$username" || -n "$password" ]]; then + if [[ -z "$registry" || -z "$username" || -z "$password" ]]; then + echo "Optional registry integration requires 'registry', 'registry_username', and 'registry_password' together." >&2 + exit 1 + fi + fi + + - name: Install modctl + id: install + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + requested_version="${{ inputs.modctl_version }}" + + if [[ -z "$requested_version" ]]; then + release_api="https://api.github.com/repos/modelpack/modctl/releases/latest" + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + release_json="$(curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "$release_api")" + else + release_json="$(curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + "$release_api")" + fi + release_tag="$(printf '%s\n' "$release_json" | grep -m1 '"tag_name":' | cut -d '"' -f4)" + if [[ -z "$release_tag" ]]; then + echo "Unable to resolve latest modctl release tag from GitHub API." >&2 + exit 1 + fi + else + if [[ "$requested_version" == v* ]]; then + release_tag="$requested_version" + else + release_tag="v${requested_version}" + fi + fi + + version="${release_tag#v}" + + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + echo "Unsupported runner OS: $(uname -s)" >&2 + exit 1 + ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "Unsupported runner architecture: $(uname -m)" >&2 + exit 1 + ;; + esac + + asset="modctl-${version}-${os}-${arch}.tar.gz" + download_url="https://github.com/modelpack/modctl/releases/download/${release_tag}/${asset}" + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + curl -fsSL "$download_url" -o "${tmp_dir}/${asset}" + tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir" + + if [[ ! -f "${tmp_dir}/modctl" ]]; then + echo "Downloaded release archive did not contain a modctl binary." >&2 + exit 1 + fi + + install_dir="${RUNNER_TEMP}/modctl-bin" + mkdir -p "$install_dir" + mv "${tmp_dir}/modctl" "${install_dir}/modctl" + chmod +x "${install_dir}/modctl" + + echo "$install_dir" >> "$GITHUB_PATH" + echo "modctl-version=${version}" >> "$GITHUB_OUTPUT" + + - name: Login to registry + if: ${{ inputs.registry != '' && inputs.registry_username != '' && inputs.registry_password != '' }} + shell: bash + run: | + set -euo pipefail + + login_cmd=(modctl login -u "${{ inputs.registry_username }}" -p "${{ inputs.registry_password }}") + if [[ "${{ inputs.plain_http }}" == "true" ]]; then + login_cmd+=(--plain-http) + fi + if [[ "${{ inputs.insecure }}" == "true" ]]; then + login_cmd+=(--insecure) + fi + login_cmd+=("${{ inputs.registry }}") + + "${login_cmd[@]}" + + - name: Build model artifact + shell: bash + run: | + set -euo pipefail + + build_cmd=(modctl build -f "${{ inputs.modelfile_path }}" -t "${{ inputs.artifact_name }}") + if [[ "${{ inputs.output_remote }}" == "true" ]]; then + build_cmd+=(--output-remote) + fi + if [[ "${{ inputs.plain_http }}" == "true" ]]; then + build_cmd+=(--plain-http) + fi + if [[ "${{ inputs.insecure }}" == "true" ]]; then + build_cmd+=(--insecure) + fi + build_cmd+=("${{ inputs.context_path }}") + + "${build_cmd[@]}" diff --git a/docs/getting-started.md b/docs/getting-started.md index 3c19b00a..fff977a1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,6 +16,86 @@ $ make $ ./output/modctl -h ``` +## GitHub Action + +`modctl` provides a composite GitHub Action at the repository root, so you can call it as: + +```yaml +uses: modelpack/modctl@ +``` + +### Basic usage + +```yaml +name: Build model with modctl + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build model artifact + uses: modelpack/modctl@main + with: + artifact_name: ghcr.io/${{ github.repository_owner }}/my-model:latest + modelfile_path: ./Modelfile + context_path: . +``` + +### Inputs + +| Input | Required | Default | Description | +| --- | --- | --- | --- | +| `artifact_name` | Yes | N/A | Target model artifact reference for `modctl build --target`. | +| `modelfile_path` | No | `Modelfile` | Path to the Modelfile passed to `modctl build -f`. | +| `context_path` | No | `.` | Build context path argument for `modctl build`. | +| `modctl_version` | No | latest | `modctl` release version to install (supports `0.2.0` and `v0.2.0`). | +| `output_remote` | No | `false` | When `true`, adds `--output-remote` to `modctl build`. | +| `plain_http` | No | `false` | When `true`, adds `--plain-http` to optional `login` and `build`. | +| `insecure` | No | `false` | When `true`, adds `--insecure` to optional `login` and `build`. | +| `registry` | No | empty | Optional registry host used for `modctl login`. | +| `registry_username` | No | empty | Optional username for registry login. | +| `registry_password` | No | empty | Optional password/token for registry login. | + +### Optional version pinning + +```yaml +- name: Build with a specific modctl version + uses: modelpack/modctl@main + with: + modctl_version: 0.2.0 + artifact_name: ghcr.io/${{ github.repository_owner }}/my-model:latest +``` + +### Optional container registry integration + +Registry login is conditional. If any of `registry`, `registry_username`, or `registry_password` is provided, all three must be provided. + +```yaml +permissions: + contents: read + packages: write + +steps: + - uses: actions/checkout@v4 + - name: Build and push directly to a registry + uses: modelpack/modctl@main + with: + artifact_name: ghcr.io/${{ github.repository_owner }}/my-model:latest + modelfile_path: ./Modelfile + context_path: . + output_remote: "true" + registry: ghcr.io + registry_username: ${{ github.actor }} + registry_password: ${{ github.token }} + +``` + +When a dedicated action release tag is available, replace `@main` with that tag. + ## Usage ### Modelfile From 143766f4be2e9e12e32ba513b504dbdb5440c12d Mon Sep 17 00:00:00 2001 From: Rishi Jat Date: Wed, 15 Apr 2026 03:26:37 +0530 Subject: [PATCH 2/2] did all copilot and gemini suggestion Signed-off-by: Rishi Jat --- action.yml | 96 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/action.yml b/action.yml index d1645825..f1c253c7 100644 --- a/action.yml +++ b/action.yml @@ -53,6 +53,16 @@ runs: steps: - name: Validate action inputs shell: bash + env: + ARTIFACT_NAME: ${{ inputs.artifact_name }} + MODELFILE_PATH: ${{ inputs.modelfile_path }} + CONTEXT_PATH: ${{ inputs.context_path }} + OUTPUT_REMOTE: ${{ inputs.output_remote }} + PLAIN_HTTP: ${{ inputs.plain_http }} + INSECURE: ${{ inputs.insecure }} + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.registry_username }} + REGISTRY_PASSWORD: ${{ inputs.registry_password }} run: | set -euo pipefail @@ -65,30 +75,27 @@ runs: fi } - if [[ -z "${{ inputs.artifact_name }}" ]]; then + if [[ -z "${ARTIFACT_NAME}" ]]; then echo "Input 'artifact_name' is required." >&2 exit 1 fi - if [[ ! -f "${{ inputs.modelfile_path }}" ]]; then - echo "Modelfile not found at '${{ inputs.modelfile_path }}'." >&2 + if [[ ! -f "${MODELFILE_PATH}" ]]; then + echo "Modelfile not found at '${MODELFILE_PATH}'." >&2 exit 1 fi - if [[ ! -e "${{ inputs.context_path }}" ]]; then - echo "Context path '${{ inputs.context_path }}' does not exist." >&2 + if [[ ! -e "${CONTEXT_PATH}" ]]; then + echo "Context path '${CONTEXT_PATH}' does not exist." >&2 exit 1 fi - validate_bool "output_remote" "${{ inputs.output_remote }}" - validate_bool "plain_http" "${{ inputs.plain_http }}" - validate_bool "insecure" "${{ inputs.insecure }}" + validate_bool "output_remote" "${OUTPUT_REMOTE}" + validate_bool "plain_http" "${PLAIN_HTTP}" + validate_bool "insecure" "${INSECURE}" - registry="${{ inputs.registry }}" - username="${{ inputs.registry_username }}" - password="${{ inputs.registry_password }}" - if [[ -n "$registry" || -n "$username" || -n "$password" ]]; then - if [[ -z "$registry" || -z "$username" || -z "$password" ]]; then + if [[ -n "${REGISTRY}" || -n "${REGISTRY_USERNAME}" || -n "${REGISTRY_PASSWORD}" ]]; then + if [[ -z "${REGISTRY}" || -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_PASSWORD}" ]]; then echo "Optional registry integration requires 'registry', 'registry_username', and 'registry_password' together." >&2 exit 1 fi @@ -98,34 +105,26 @@ runs: id: install shell: bash env: - GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + MODCTL_VERSION: ${{ inputs.modctl_version }} run: | set -euo pipefail - requested_version="${{ inputs.modctl_version }}" - - if [[ -z "$requested_version" ]]; then - release_api="https://api.github.com/repos/modelpack/modctl/releases/latest" - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - release_json="$(curl -fsSL \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - "$release_api")" - else - release_json="$(curl -fsSL \ - -H "Accept: application/vnd.github+json" \ - "$release_api")" + if [[ -z "${MODCTL_VERSION}" ]]; then + if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI 'gh' is required to resolve the latest modctl release." >&2 + exit 1 fi - release_tag="$(printf '%s\n' "$release_json" | grep -m1 '"tag_name":' | cut -d '"' -f4)" - if [[ -z "$release_tag" ]]; then - echo "Unable to resolve latest modctl release tag from GitHub API." >&2 + release_tag="$(gh api repos/modelpack/modctl/releases/latest --jq '.tag_name')" + if [[ -z "${release_tag}" || "${release_tag}" == "null" ]]; then + echo "Unable to resolve latest modctl release tag via gh api." >&2 exit 1 fi else - if [[ "$requested_version" == v* ]]; then - release_tag="$requested_version" + if [[ "${MODCTL_VERSION}" == v* ]]; then + release_tag="${MODCTL_VERSION}" else - release_tag="v${requested_version}" + release_tag="v${MODCTL_VERSION}" fi fi @@ -173,35 +172,48 @@ runs: - name: Login to registry if: ${{ inputs.registry != '' && inputs.registry_username != '' && inputs.registry_password != '' }} shell: bash + env: + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.registry_username }} + REGISTRY_PASSWORD: ${{ inputs.registry_password }} + PLAIN_HTTP: ${{ inputs.plain_http }} + INSECURE: ${{ inputs.insecure }} run: | set -euo pipefail - login_cmd=(modctl login -u "${{ inputs.registry_username }}" -p "${{ inputs.registry_password }}") - if [[ "${{ inputs.plain_http }}" == "true" ]]; then + login_cmd=(modctl login -u "${REGISTRY_USERNAME}" -p "${REGISTRY_PASSWORD}") + if [[ "${PLAIN_HTTP}" == "true" ]]; then login_cmd+=(--plain-http) fi - if [[ "${{ inputs.insecure }}" == "true" ]]; then + if [[ "${INSECURE}" == "true" ]]; then login_cmd+=(--insecure) fi - login_cmd+=("${{ inputs.registry }}") + login_cmd+=("${REGISTRY}") "${login_cmd[@]}" - name: Build model artifact shell: bash + env: + MODELFILE_PATH: ${{ inputs.modelfile_path }} + ARTIFACT_NAME: ${{ inputs.artifact_name }} + CONTEXT_PATH: ${{ inputs.context_path }} + OUTPUT_REMOTE: ${{ inputs.output_remote }} + PLAIN_HTTP: ${{ inputs.plain_http }} + INSECURE: ${{ inputs.insecure }} run: | set -euo pipefail - build_cmd=(modctl build -f "${{ inputs.modelfile_path }}" -t "${{ inputs.artifact_name }}") - if [[ "${{ inputs.output_remote }}" == "true" ]]; then + build_cmd=(modctl build -f "${MODELFILE_PATH}" -t "${ARTIFACT_NAME}") + if [[ "${OUTPUT_REMOTE}" == "true" ]]; then build_cmd+=(--output-remote) fi - if [[ "${{ inputs.plain_http }}" == "true" ]]; then + if [[ "${PLAIN_HTTP}" == "true" ]]; then build_cmd+=(--plain-http) fi - if [[ "${{ inputs.insecure }}" == "true" ]]; then + if [[ "${INSECURE}" == "true" ]]; then build_cmd+=(--insecure) fi - build_cmd+=("${{ inputs.context_path }}") + build_cmd+=("${CONTEXT_PATH}") "${build_cmd[@]}"