diff --git a/.github/workflows/test-setup-action.yaml b/.github/workflows/test-setup-action.yaml new file mode 100644 index 0000000..d5bf622 --- /dev/null +++ b/.github/workflows/test-setup-action.yaml @@ -0,0 +1,180 @@ +name: test-setup-action +permissions: + contents: read + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + setup-action-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Unikraft CLI (default) + uses: ./ + - name: Capture default version + shell: bash + run: | + unikraft version | tee "${RUNNER_TEMP}/unikraft-version-default.txt" + + - name: Install Unikraft CLI (latest) + uses: ./ + with: + version: latest + - name: Capture latest version + shell: bash + run: | + unikraft version | tee "${RUNNER_TEMP}/unikraft-version-latest.txt" + + - name: Install Unikraft CLI (stable) + uses: ./ + with: + version: stable + - name: Capture stable version + shell: bash + run: | + unikraft version | tee "${RUNNER_TEMP}/unikraft-version-stable.txt" + + - name: Install Unikraft CLI (dev) + uses: ./ + with: + version: dev + - name: Capture dev version + shell: bash + run: | + unikraft version | tee "${RUNNER_TEMP}/unikraft-version-dev.txt" + + - name: Install Unikraft CLI (staging) + uses: ./ + with: + version: staging + - name: Capture staging version + shell: bash + run: | + unikraft version | tee "${RUNNER_TEMP}/unikraft-version-staging.txt" + + - name: Install Unikraft CLI (pinned) + uses: ./ + with: + version: 0.2.1 + - name: Capture pinned version + shell: bash + run: | + unikraft version | tee "${RUNNER_TEMP}/unikraft-version-pinned.txt" + + - name: Verify CLI versions + shell: bash + run: | + default_output="$(cat "${RUNNER_TEMP}/unikraft-version-default.txt")" + latest_output="$(cat "${RUNNER_TEMP}/unikraft-version-latest.txt")" + stable_output="$(cat "${RUNNER_TEMP}/unikraft-version-stable.txt")" + dev_output="$(cat "${RUNNER_TEMP}/unikraft-version-dev.txt")" + staging_output="$(cat "${RUNNER_TEMP}/unikraft-version-staging.txt")" + pinned_output="$(cat "${RUNNER_TEMP}/unikraft-version-pinned.txt")" + + if [[ -z "${default_output}" || -z "${latest_output}" || -z "${stable_output}" || -z "${dev_output}" || -z "${staging_output}" || -z "${pinned_output}" ]]; then + echo "One or more version outputs are empty" >&2 + exit 1 + fi + + extract_version() { + local label="$1" + local output="$2" + local version + + version="$(printf '%s\n' "${output}" | awk -F: '/^[[:space:]]*version[[:space:]]*:/ { gsub(/^[[:space:]]+/, "", $2); gsub(/[[:space:]]+$/, "", $2); print $2; exit }')" + if [[ -z "${version}" ]]; then + echo "${label} output did not include a version line: ${output}" >&2 + exit 1 + fi + + if [[ ! "${version}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$ ]]; then + echo "${label} version has unexpected format: ${version}" >&2 + exit 1 + fi + + printf '%s' "${version}" + } + + default_version="$(extract_version "Default" "${default_output}")" + latest_version="$(extract_version "Latest" "${latest_output}")" + stable_version="$(extract_version "Stable" "${stable_output}")" + dev_version="$(extract_version "Dev" "${dev_output}")" + staging_version="$(extract_version "Staging" "${staging_output}")" + pinned_version="$(extract_version "Pinned" "${pinned_output}")" + echo "Default version: ${default_version}" + echo "Latest version: ${latest_version}" + echo "Stable version: ${stable_version}" + echo "Dev version: ${dev_version}" + echo "Staging version: ${staging_version}" + echo "Pinned version: ${pinned_version}" + + if [[ "${default_version}" != "${latest_version}" ]]; then + echo "Expected default and latest versions to match: default=${default_version} latest=${latest_version}" >&2 + exit 1 + fi + + if [[ "${latest_version}" != "${stable_version}" ]]; then + echo "Expected latest and stable versions to match: latest=${latest_version} stable=${stable_version}" >&2 + exit 1 + fi + + if [[ "${dev_version}" != *-* ]]; then + echo "Expected dev version to be a prerelease: ${dev_version}" >&2 + exit 1 + fi + + if [[ "${dev_version}" != "${staging_version}" ]]; then + echo "Expected dev and staging versions to match: dev=${dev_version} staging=${staging_version}" >&2 + exit 1 + fi + + if [[ "${pinned_version}" != *"0.2.1"* ]]; then + echo "Expected pinned version 0.2.1 in output: ${pinned_output}" >&2 + exit 1 + fi + + setup-action-login: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Unikraft CLI (login) + uses: ./ + with: + version: dev + token: ${{ secrets.UKC_TOKEN }} + organization: ${{ secrets.UKC_USER }} + - name: Verify login with metro list + shell: bash + env: + UKC_TOKEN: ${{ secrets.UKC_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${UKC_TOKEN}" ]]; then + echo "Skipping login verification: UKC_TOKEN is not set" + exit 0 + fi + metros="$(unikraft metro list -oquiet)" + echo "${metros}" + if [[ -z "${metros}" ]]; then + echo "No metros returned from unikraft metro list" >&2 + exit 1 + fi + while IFS= read -r line; do + if [[ -z "${line}" ]]; then + echo "Unexpected empty metro entry" >&2 + exit 1 + fi + done <<< "${metros}" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6f40fa4 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, Unikraft GmbH & The Unikraft CLI Authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..640d47c --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# setup-action + +Install the Unikraft CLI from GitHub releases and optionally log in to Unikraft Cloud. + +## Usage + +```yaml +- name: Install Unikraft CLI + uses: unikraft/setup-action@v1 +``` + +Pin a specific version or use the prerelease channel: + +```yaml +- name: Install Unikraft CLI (pinned) + uses: unikraft/setup-action@v1 + with: + version: 0.1.0-staging.71 + +- name: Install Unikraft CLI (dev) + uses: unikraft/setup-action@v1 + with: + version: dev +``` + +Log in to Unikraft Cloud: + +```yaml +- name: Install and login + uses: unikraft/setup-action@v1 + with: + version: dev + token: ${{ secrets.UKC_TOKEN }} + organization: my-org +``` + +## Inputs + +- `version`: Version to install (`latest`, `dev`, or a release tag). Default: `latest`. +- `github-token`: GitHub token used by `gh` for release resolution and downloads. Default: `${{ github.token }}`. +- `token`: Authentication token for Unikraft login. Default: empty. +- `organization`: Organization to associate the login with. Default: empty. + +## Outputs + +- `version`: Resolved release tag that was installed. + +## Notes + +- `organization` requires `token`. +- The action uses `gh` and `jq`, which are available on GitHub-hosted runners. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..cabfd70 --- /dev/null +++ b/action.yml @@ -0,0 +1,167 @@ +name: setup-action +description: Install the Unikraft CLI from GitHub releases +inputs: + version: + description: Version to install (latest/stable, dev/staging, or a release tag) + required: false + default: latest + github-token: + description: GitHub token used by gh for release resolution/downloads + required: false + default: ${{ github.token }} + token: + description: Authentication token for unikraft login + required: false + default: "" + organization: + description: Organization to associate the login with + required: false + default: "" +outputs: + version: + description: Resolved release tag that was installed + value: ${{ steps.resolve.outputs.tag }} +runs: + using: composite + steps: + - name: Resolve release tag + id: resolve + shell: bash + env: + GH_TOKEN: ${{ inputs['github-token'] || github.token }} + REPO: "unikraft/cli" + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + if [[ -z "${VERSION}" ]]; then + VERSION="latest" + fi + + if [[ "${VERSION}" == "latest" || "${VERSION}" == "stable" ]]; then + tag="$(curl -fsSL "https://pkg.unikraft.com/endpoints/cli/content/stable.txt" | tr -d '\r\n')" + elif [[ "${VERSION}" == "dev" || "${VERSION}" == "staging" ]]; then + tag="$(curl -fsSL "https://pkg.unikraft.com/endpoints/cli/content/staging.txt" | tr -d '\r\n')" + else + if [[ "${VERSION}" =~ ^v ]]; then + tag="${VERSION}" + else + tag="v${VERSION}" + fi + + api_err="$(mktemp)" + if ! gh api "repos/${REPO}/releases/tags/${tag}" >/dev/null 2>"${api_err}"; then + err_msg="$(cat "${api_err}")" + rm -f "${api_err}" + if [[ "${err_msg}" == *"HTTP 404"* ]]; then + echo "Release tag '${tag}' not found in repository ${REPO}." >&2 + elif [[ -n "${err_msg}" ]]; then + echo "Failed to fetch release tag '${tag}' from ${REPO}: ${err_msg}" >&2 + else + echo "Failed to fetch release tag '${tag}' from ${REPO}." >&2 + fi + exit 1 + fi + rm -f "${api_err}" + fi + + if [[ -z "${tag}" || "${tag}" == "null" ]]; then + echo "Failed to resolve a release tag for version '${VERSION}'." >&2 + exit 1 + fi + + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + - name: Download release asset + id: download + shell: bash + env: + GH_TOKEN: ${{ inputs['github-token'] || github.token }} + REPO: "unikraft/cli" + TAG: ${{ steps.resolve.outputs.tag }} + run: | + set -euo pipefail + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + + case "${arch}" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch}" >&2 + exit 1 + ;; + esac + + case "${os}" in + linux|darwin) ;; + *) + echo "Unsupported OS: ${os}" >&2 + exit 1 + ;; + esac + + version="${TAG#v}" + filename="unikraft-cli_${version}_${os}_${arch}.tar.gz" + download_dir="$(mktemp -d)" + gh release download "${TAG}" --repo "${REPO}" --pattern "${filename}" --dir "${download_dir}" + echo "archive=${download_dir}/${filename}" >> "${GITHUB_OUTPUT}" + + - name: Install Unikraft CLI + shell: bash + env: + ARCHIVE: "${{ steps.download.outputs.archive }}" + run: | + set -euo pipefail + install_dir="${RUNNER_TEMP}/unikraft-cli/bin" + extract_dir="$(mktemp -d)" + mkdir -p "${install_dir}" + + tar -xzf "${ARCHIVE}" -C "${extract_dir}" + binary_path="" + match_count=0 + while IFS= read -r match; do + match_count=$((match_count + 1)) + if [[ ${match_count} -gt 1 ]]; then + echo "Multiple unikraft binaries found in ${ARCHIVE}" >&2 + exit 1 + fi + binary_path="${match}" + done < <(find "${extract_dir}" -type f -name unikraft -perm /111) + if [[ -z "${binary_path}" ]]; then + echo "unikraft binary not found in ${ARCHIVE}" >&2 + exit 1 + fi + + install -m 0755 "${binary_path}" "${install_dir}/unikraft" + echo "${install_dir}" >> "${GITHUB_PATH}" + + - name: Login to Unikraft Cloud + if: ${{ inputs.token != '' || inputs.organization != '' }} + shell: bash + env: + TOKEN_INPUT: ${{ inputs.token }} + ORGANIZATION: ${{ inputs.organization }} + run: | + set -euo pipefail + + if [[ -z "${TOKEN_INPUT}" && -n "${ORGANIZATION}" ]]; then + echo "setup-action: organization input requires token input" >&2 + exit 1 + fi + + if [[ -z "${TOKEN_INPUT}" ]]; then + exit 0 + fi + + token_file="$(mktemp)" + trap 'rm -f "${token_file}"' EXIT + chmod 0600 "${token_file}" + printf '%s' "${TOKEN_INPUT}" > "${token_file}" + + args=(login --token "${token_file}") + if [[ -n "${ORGANIZATION}" ]]; then + args+=(--organization "${ORGANIZATION}") + fi + + unikraft "${args[@]}"