From 2d139b3958d9cbadb655da079a328bd723516e0c Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 17 Jun 2026 08:27:03 -0700 Subject: [PATCH 1/3] Add reusable Launchpad Actions for container API Introduce composite GitHub Actions that wrap the Create-a-Container API (create, get, delete, wait-for-job) and a README documenting usage and versioning. Also tweak NetBox integration: update create-container log text, make findClusterId return null when not found, resolve both cluster and site IDs in createVirtualMachine, require at least one of cluster or site (fail loudly if neither), and emit cluster/site fields only when present. These changes improve CI ergonomics and make NetBox handling more robust for site-only configurations. --- .github/actions/README.md | 46 +++++++ .github/actions/create-container/action.yml | 126 ++++++++++++++++++++ .github/actions/delete-container/action.yml | 52 ++++++++ .github/actions/get-container/action.yml | 95 +++++++++++++++ .github/actions/wait-for-job/action.yml | 101 ++++++++++++++++ create-a-container/bin/create-container.js | 2 +- create-a-container/utils/netbox.js | 22 ++-- 7 files changed, 433 insertions(+), 11 deletions(-) create mode 100644 .github/actions/README.md create mode 100644 .github/actions/create-container/action.yml create mode 100644 .github/actions/delete-container/action.yml create mode 100644 .github/actions/get-container/action.yml create mode 100644 .github/actions/wait-for-job/action.yml diff --git a/.github/actions/README.md b/.github/actions/README.md new file mode 100644 index 00000000..eb013195 --- /dev/null +++ b/.github/actions/README.md @@ -0,0 +1,46 @@ +# Reusable Container API Actions + +Composite GitHub Actions that wrap the [Create-a-Container API](../../create-a-container/openapi.v1.yaml). They are the single source of truth for talking to the API from CI, so downstream consumers (notably [`mieweb/launchpad`](../../launchpad.yml)) never embed raw `curl` calls that drift out of sync with API changes. + +Each action is a thin, single-purpose wrapper around one API operation. Orchestration (naming containers, deciding when to recreate, gating on events) belongs to the caller. + +| Action | Purpose | API call | +| --- | --- | --- | +| [`get-container`](get-container/action.yml) | Fetch a container by hostname | `GET /api/v1/sites/{siteId}/containers?hostname=` | +| [`create-container`](create-container/action.yml) | Create a container | `POST /api/v1/sites/{siteId}/containers` | +| [`delete-container`](delete-container/action.yml) | Delete a container by ID | `DELETE /api/v1/sites/{siteId}/containers/{id}` | +| [`wait-for-job`](wait-for-job/action.yml) | Poll a job until it finishes | `GET /api/v1/jobs/{id}` | + +## Usage + +Reference an action by its directory (GitHub resolves the `action.yml` inside it): + +```yaml +- uses: mieweb/opensource-server/.github/actions/create-container@latest + id: create + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: "1" + hostname: my-container + template_name: ghcr.io/mieweb/timeharbor:latest + +- uses: mieweb/opensource-server/.github/actions/wait-for-job@latest + if: steps.create.outputs.created == 'true' + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + job_id: ${{ steps.create.outputs.job_id }} +``` + +`wait-for-job` is kept separate from `create-container` so future flows (e.g. modifying an existing container) can reuse the polling logic. + +## Versioning and pinning + +`uses:` refs must be literal — GitHub does not let you interpolate `${{ github.action_ref }}` into a nested `uses:` value, so the ref a caller passes to `mieweb/launchpad@` cannot automatically flow through to these actions. To pin against API/cluster changes: + +1. Tag releases of this repo (e.g. `v2026.6.2`). +2. Tag a matching `mieweb/launchpad` release whose `uses:` lines reference that same tag. +3. Consumers pin `mieweb/launchpad@v2026.6.2`. + +Use a floating ref (`@latest` or `@main`) only when you accept breaking changes on each release. diff --git a/.github/actions/create-container/action.yml b/.github/actions/create-container/action.yml new file mode 100644 index 00000000..c68ee0a1 --- /dev/null +++ b/.github/actions/create-container/action.yml @@ -0,0 +1,126 @@ +name: Create Container +description: Create a container via the Create-a-Container API and report its creation job. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + site_id: + description: Site ID the container will belong to. + required: false + default: "1" + hostname: + description: Hostname for the new container. + required: true + template_name: + description: Container template/image (e.g. ghcr.io/mieweb/timeharbor:latest). + required: false + default: "" + services: + description: Services configuration (JSON array string). + required: false + default: "" + container_env_vars: + description: Environment variables for the container (JSON string). + required: false + default: "" + +outputs: + container_id: + description: The new (or existing, on conflict) container ID. + value: ${{ steps.create.outputs.container_id }} + job_id: + description: The asynchronous creation job ID (empty when created synchronously). + value: ${{ steps.create.outputs.job_id }} + created: + description: "'true' when an asynchronous creation job was enqueued (poll with wait-for-job)." + value: ${{ steps.create.outputs.created }} + ready: + description: "'true' when the container is already usable (created synchronously or already existed)." + value: ${{ steps.create.outputs.ready }} + +runs: + using: composite + steps: + - name: Create container + id: create + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + SITE_ID: ${{ inputs.site_id }} + CONTAINER_HOSTNAME: ${{ inputs.hostname }} + TEMPLATE_NAME: ${{ inputs.template_name }} + SERVICES: ${{ inputs.services }} + CONTAINER_ENV_VARS: ${{ inputs.container_env_vars }} + run: | + set -uo pipefail + echo "Creating container '$CONTAINER_HOSTNAME' (site $SITE_ID)..." + + CREATE_PAYLOAD=$(jq -n \ + --arg hostname "$CONTAINER_HOSTNAME" \ + --arg template "$TEMPLATE_NAME" \ + --arg services "$SERVICES" \ + --arg env_vars "$CONTAINER_ENV_VARS" \ + '{ + hostname: $hostname, + template: $template, + services: ($services | fromjson? // []), + environment: ($env_vars | fromjson? // {}) + }') + + echo "--- Create Payload ---" + echo "$CREATE_PAYLOAD" + echo "----------------------" + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$API_URL/api/v1/sites/$SITE_ID/containers" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d "$CREATE_PAYLOAD") + + BODY=$(echo "$RESPONSE" | sed '$d') + CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then + echo "--- Create Response ---" + echo "$BODY" + echo "----------------------" + NEW_ID=$(echo "$BODY" | jq -r '.data.containerId // .data.id // .container.id // .id // empty') + JOB_ID=$(echo "$BODY" | jq -r '.data.jobId // .data.job_id // .data.creationJobId // .jobId // .job_id // empty') + if [ -z "$NEW_ID" ] || [ "$NEW_ID" == "null" ]; then + echo "::error::Creation succeeded (HTTP $CODE) but the response contained no container ID. Response: $BODY" + exit 1 + fi + echo "container_id=$NEW_ID" >> "$GITHUB_OUTPUT" + if [ -n "$JOB_ID" ] && [ "$JOB_ID" != "null" ]; then + echo "Container created (ID: $NEW_ID, Job ID: $JOB_ID)." + { + echo "job_id=$JOB_ID" + echo "created=true" + echo "ready=false" + } >> "$GITHUB_OUTPUT" + else + echo "Container created synchronously (ID: $NEW_ID)." + { + echo "created=false" + echo "ready=true" + } >> "$GITHUB_OUTPUT" + fi + elif [ "$CODE" -eq 409 ]; then + EXISTING_ID=$(echo "$BODY" | jq -r '.data.containerId // .data.id // .container.id // empty') + echo "Container already exists (conflict). Using existing container ID: $EXISTING_ID" + { + echo "container_id=$EXISTING_ID" + echo "created=false" + echo "ready=true" + } >> "$GITHUB_OUTPUT" + else + echo "::error::Failed to create container. HTTP $CODE — $BODY" + exit 1 + fi diff --git a/.github/actions/delete-container/action.yml b/.github/actions/delete-container/action.yml new file mode 100644 index 00000000..1c1b694e --- /dev/null +++ b/.github/actions/delete-container/action.yml @@ -0,0 +1,52 @@ +name: Delete Container +description: Delete a container by ID via the Create-a-Container API. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + site_id: + description: Site ID that owns the container. + required: false + default: "1" + container_id: + description: Numeric ID of the container to delete. + required: true + +outputs: + deleted: + description: "'true' if the container was deleted, otherwise 'false'." + value: ${{ steps.delete.outputs.deleted }} + +runs: + using: composite + steps: + - name: Delete container + id: delete + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + SITE_ID: ${{ inputs.site_id }} + CONTAINER_ID: ${{ inputs.container_id }} + run: | + set -uo pipefail + echo "Deleting container ID $CONTAINER_ID (site $SITE_ID)..." + + CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X DELETE "$API_URL/api/v1/sites/$SITE_ID/containers/$CONTAINER_ID" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json") + + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then + echo "Container $CONTAINER_ID deleted (HTTP $CODE)." + echo "deleted=true" >> "$GITHUB_OUTPUT" + else + echo "deleted=false" >> "$GITHUB_OUTPUT" + echo "::error::Failed to delete container $CONTAINER_ID. HTTP $CODE" + exit 1 + fi diff --git a/.github/actions/get-container/action.yml b/.github/actions/get-container/action.yml new file mode 100644 index 00000000..6945ca1f --- /dev/null +++ b/.github/actions/get-container/action.yml @@ -0,0 +1,95 @@ +name: Get Container +description: Fetch a container's details by hostname from the Create-a-Container API. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + site_id: + description: Site ID that owns the container. + required: false + default: "1" + hostname: + description: Container hostname to look up. + required: true + +outputs: + found: + description: "'true' if a container was found, otherwise 'false'." + value: ${{ steps.query.outputs.found }} + container_id: + description: The container's numeric ID (empty if not found). + value: ${{ steps.query.outputs.container_id }} + status: + description: Container status (e.g. running); empty if not found. + value: ${{ steps.query.outputs.status }} + ipv4_address: + description: Container IPv4 address. + value: ${{ steps.query.outputs.ipv4_address }} + ssh_port: + description: Mapped SSH port. + value: ${{ steps.query.outputs.ssh_port }} + http_port: + description: Mapped HTTP port. + value: ${{ steps.query.outputs.http_port }} + node_name: + description: Proxmox node hosting the container. + value: ${{ steps.query.outputs.node_name }} + +runs: + using: composite + steps: + - name: Query container by hostname + id: query + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + SITE_ID: ${{ inputs.site_id }} + CONTAINER_HOSTNAME: ${{ inputs.hostname }} + run: | + set -uo pipefail + echo "Querying container '$CONTAINER_HOSTNAME' (site $SITE_ID)..." + + RAW=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json" \ + "$API_URL/api/v1/sites/$SITE_ID/containers?hostname=$CONTAINER_HOSTNAME") + + HTTP_CODE=$(echo "$RAW" | tail -1) + BODY=$(echo "$RAW" | sed '$d') + + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo "::error::Failed to query container. HTTP $HTTP_CODE — $BODY" + exit 1 + fi + + CONTAINER_ID=$(echo "$BODY" | jq -r '.data[0].id // .data[0].containerId // .containers[0].id // empty') + + if [ -z "$CONTAINER_ID" ] || [ "$CONTAINER_ID" == "null" ]; then + echo "No container found for '$CONTAINER_HOSTNAME'." + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + STATUS=$(echo "$BODY" | jq -r '.data[0].status // .containers[0].status // "unknown"') + IPV4=$(echo "$BODY" | jq -r '.data[0].ipv4Address // .containers[0].ipv4Address // empty') + SSH_PORT=$(echo "$BODY" | jq -r '.data[0].sshPort // .containers[0].sshPort // empty') + HTTP_PORT=$(echo "$BODY" | jq -r '.data[0].httpPort // .containers[0].httpPort // empty') + NODE_NAME=$(echo "$BODY" | jq -r '.data[0].nodeName // .containers[0].nodeName // empty') + + { + echo "found=true" + echo "container_id=$CONTAINER_ID" + echo "status=$STATUS" + echo "ipv4_address=$IPV4" + echo "ssh_port=$SSH_PORT" + echo "http_port=$HTTP_PORT" + echo "node_name=$NODE_NAME" + } >> "$GITHUB_OUTPUT" + + echo "Found container '$CONTAINER_HOSTNAME' (ID: $CONTAINER_ID, status: $STATUS)." diff --git a/.github/actions/wait-for-job/action.yml b/.github/actions/wait-for-job/action.yml new file mode 100644 index 00000000..87dac4f3 --- /dev/null +++ b/.github/actions/wait-for-job/action.yml @@ -0,0 +1,101 @@ +name: Wait for Job +description: Poll a Create-a-Container API job until it completes, fails, or times out. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + job_id: + description: Numeric ID of the job to poll. + required: true + max_attempts: + description: Maximum number of poll attempts before timing out. + required: false + default: "30" + poll_interval: + description: Seconds to wait between poll attempts. + required: false + default: "10" + +outputs: + job_done: + description: "'true' when the job completed successfully." + value: ${{ steps.wait.outputs.job_done }} + +runs: + using: composite + steps: + - name: Poll job until complete + id: wait + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + JOB_ID: ${{ inputs.job_id }} + MAX_ATTEMPTS: ${{ inputs.max_attempts }} + POLL_INTERVAL: ${{ inputs.poll_interval }} + run: | + set -uo pipefail + + if [ -z "$JOB_ID" ] || [ "$JOB_ID" == "null" ]; then + echo "::error::wait-for-job called with an empty job_id." + exit 1 + fi + + ATTEMPT=0 + echo "Waiting for job $JOB_ID to complete (up to $((MAX_ATTEMPTS * POLL_INTERVAL))s)..." + + while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do + ATTEMPT=$((ATTEMPT + 1)) + + RAW=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json" \ + "$API_URL/api/v1/jobs/$JOB_ID") + + HTTP_CODE=$(echo "$RAW" | tail -1) + BODY=$(echo "$RAW" | sed '$d') + + echo " Attempt $ATTEMPT/$MAX_ATTEMPTS — HTTP $HTTP_CODE" + + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo " API returned HTTP $HTTP_CODE — retrying." + sleep "$POLL_INTERVAL" + continue + fi + + if ! echo "$BODY" | jq empty 2>/dev/null; then + echo " Response is not valid JSON — retrying." + sleep "$POLL_INTERVAL" + continue + fi + + JOB_STATUS=$(echo "$BODY" | jq -r '.data.status // .status // "unknown"') + echo " Job status: $JOB_STATUS" + + if [ "$JOB_STATUS" == "success" ] || [ "$JOB_STATUS" == "completed" ]; then + echo "Job $JOB_ID completed successfully." + echo "job_done=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$JOB_STATUS" == "failed" ] || [ "$JOB_STATUS" == "error" ] || [ "$JOB_STATUS" == "cancelled" ]; then + FAILURE_REASON=$(echo "$BODY" | jq -r '.error // .message // .reason // empty') + if [ -n "$FAILURE_REASON" ]; then + echo "::error::Job $JOB_ID entered '$JOB_STATUS' state: $FAILURE_REASON" + else + echo "::error::Job $JOB_ID entered '$JOB_STATUS' state. Full response: $BODY" + fi + exit 1 + fi + + # Still pending or running — keep polling + sleep "$POLL_INTERVAL" + done + + echo "::error::Timed out waiting for job $JOB_ID after $((MAX_ATTEMPTS * POLL_INTERVAL))s." + exit 1 diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 766ed815..5df8813d 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -520,7 +520,7 @@ async function main() { // Register the container in NetBox if the integration is configured await withNetbox(Setting, async (baseUrl, token) => { - console.log(`Registering container in NetBox (cluster: ${site.name})...`); + console.log(`Registering container in NetBox (site: ${site.name})...`); try { await createVirtualMachine(baseUrl, token, { hostname: container.hostname, diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index 70d7dcdc..d4d94d55 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -85,12 +85,11 @@ async function findDeviceId(baseUrl, token, deviceName) { } /** - * Look up a NetBox cluster by name. + * Look up a NetBox cluster by name. Returns null if not found. * @param {string} baseUrl * @param {string} token * @param {string} clusterName - Should match the Site.name value - * @returns {Promise} Cluster ID - * @throws {Error} If the cluster is not found in NetBox + * @returns {Promise} Cluster ID or null */ async function findClusterId(baseUrl, token, clusterName) { const data = await nbFetch( @@ -98,17 +97,15 @@ async function findClusterId(baseUrl, token, clusterName) { token, `/virtualization/clusters/?name=${encodeURIComponent(clusterName)}&limit=1`, ); - if (!data?.results?.length) { - throw new Error(`NetBox: cluster "${clusterName}" not found`); - } - return data.results[0].id; + return data?.results?.[0]?.id ?? null; } /** * Create a virtual machine record in NetBox for a newly provisioned container. * * Steps: - * 1. Resolve cluster ID from site name + * 1. Resolve cluster and/or site ID from the site name (NetBox 3.3+ allows a + * VM to be assigned to a site without a cluster) * 2. Create the VM record * 3. Create an eth0 interface on the VM * 4. Create an IP address assigned to that interface @@ -118,7 +115,7 @@ async function findClusterId(baseUrl, token, clusterName) { * @param {string} token * @param {object} opts * @param {string} opts.hostname - Container hostname (becomes VM name) - * @param {string} opts.clusterName - Site name used to resolve the NetBox cluster + * @param {string} opts.clusterName - Site name used to resolve the NetBox cluster and/or site * @param {string} opts.ipv4Address - Container IPv4 address (CIDR or bare IP) * @param {string} [opts.createdBy] - Username of the person who created the container * @param {string} [opts.nodeName] - Proxmox node name; mapped to the NetBox device within the cluster @@ -133,15 +130,20 @@ async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv findSiteId(baseUrl, token, clusterName), nodeName ? findDeviceId(baseUrl, token, nodeName) : Promise.resolve(null), ]); + // A NetBox VM must be anchored to a cluster or a site. If neither matches the + // site name, fail loudly so the misconfiguration is visible. + if (clusterId === null && siteId === null) { + throw new Error(`NetBox: no cluster or site named "${clusterName}" found`); + } const comment = createdBy ? `${NETBOX_COMMENT}\nCreated by: ${createdBy}` : NETBOX_COMMENT; const vmBody = { name: hostname, - cluster: clusterId, status: 'active', comments: comment, + ...(clusterId !== null && { cluster: clusterId }), ...(siteId !== null && { site: siteId }), ...(deviceId !== null && { device: deviceId }), ...(vcpus != null && { vcpus }), From 46f18dbbf22d950c6360767fc2418c959b1f9273 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 17 Jun 2026 09:00:13 -0700 Subject: [PATCH 2/3] Convert disk size to MB for NetBox API NetBox stores VM disk sizes in megabytes (and displays them by dividing by 1000), so convert gigabyte values to MB before sending. Add MB_PER_GB = 1000 and multiply diskGb by this constant in createVirtualMachine and updateVirtualMachine to ensure correct disk sizing in NetBox. --- create-a-container/utils/netbox.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index d4d94d55..1661615f 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -17,6 +17,10 @@ const NETBOX_COMMENT = 'This container was built using opensource-server'; +// NetBox stores the VM `disk` field in megabytes and divides by 1000 (decimal) +// for display, so convert gigabytes to MB before sending. +const MB_PER_GB = 1000; + /** * Build request headers for NetBox API calls. * @param {string} token - NetBox API token @@ -148,7 +152,7 @@ async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv ...(deviceId !== null && { device: deviceId }), ...(vcpus != null && { vcpus }), ...(memoryMb != null && { memory: memoryMb }), - ...(diskGb != null && { disk: diskGb }), + ...(diskGb != null && { disk: diskGb * MB_PER_GB }), }; const vm = await nbFetch(baseUrl, token, '/virtualization/virtual-machines/', { @@ -213,7 +217,7 @@ async function updateVirtualMachine(baseUrl, token, hostname, { vcpus, memoryMb, const patch = { ...(vcpus != null && { vcpus }), ...(memoryMb != null && { memory: memoryMb }), - ...(diskGb != null && { disk: diskGb }), + ...(diskGb != null && { disk: diskGb * MB_PER_GB }), }; if (Object.keys(patch).length === 0) return; await nbFetch(baseUrl, token, `/virtualization/virtual-machines/${vm.id}/`, { From f7008f2bd4e158927cdbbb936e608bf1edadfe43 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 19 Jun 2026 13:02:51 -0700 Subject: [PATCH 3/3] Remove versioning documentation --- .github/actions/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/actions/README.md b/.github/actions/README.md index eb013195..b5613e10 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -37,10 +37,4 @@ Reference an action by its directory (GitHub resolves the `action.yml` inside it ## Versioning and pinning -`uses:` refs must be literal — GitHub does not let you interpolate `${{ github.action_ref }}` into a nested `uses:` value, so the ref a caller passes to `mieweb/launchpad@` cannot automatically flow through to these actions. To pin against API/cluster changes: - -1. Tag releases of this repo (e.g. `v2026.6.2`). -2. Tag a matching `mieweb/launchpad` release whose `uses:` lines reference that same tag. -3. Consumers pin `mieweb/launchpad@v2026.6.2`. - Use a floating ref (`@latest` or `@main`) only when you accept breaking changes on each release.