diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index 2edbfeb6c..bd7b90f92 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -1,9 +1,38 @@ name: release-ctx -# Builds the miner CLI for every platform miners actually run and attaches the -# archives (plus SHA256SUMS.txt) to the GitHub Release for the tag. +# Builds the miner CLI (`ctx`) for every platform miners actually run and +# attaches the archives (plus SHA256SUMS.txt) to a GitHub Release. # scripts/install-ctx.sh downloads from that release and verifies the checksum, # so a release without the sums file installs nothing. +# +# Build ref vs release tag — two different things: +# +# * The **build ref** is the commit `ctx` is compiled from. On a tag push it +# is the tagged commit. On workflow_dispatch it is the tip of `main` at +# run time — never an input. A publishing workflow must not check out a +# caller-chosen ref (privileged context, cache poisoning, untrusted code); +# to release an older commit on `main`, push a v*.*.* tag on it instead. +# * The **release tag** is the GitHub Release the archives are attached to. +# On a tag push it is the pushed tag. On workflow_dispatch it is `tag`, +# which must already exist: the operator cuts the tag / Release first, and +# a typo can never mint a new release. +# +# So the release tag is a publishing label — `v*.*.*` numbering follows the +# network's prod tags (deploy/README.md § Prod release flow) — while the `ctx` +# sources come from the tip of `main`. A dispatch never checks the tag out: +# an old tag predates `bins/ctx`, which is how `package ID specification ctx +# did not match any packages` happened. The resolve job refuses a build +# commit that is not on `main` (miners install what this publishes; an +# unmerged branch or a fork is never released) or has no `bins/ctx`, before +# any runner starts cargo; every archive of one run is built from the same +# resolved commit, and the appended release notes record that commit. +# +# Operator recipe (full text: deploy/README.md § ctx CLI release): +# gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" +# → the tag push runs this workflow from the tagged commit +# gh workflow run release-ctx.yml -f tag=vX.Y.Z +# → rebuild from the tip of main and (re)attach to that existing tag +# Any v*.*.* tag push also triggers deploy-prod.yml (fail-closed preflight). on: push: @@ -11,19 +40,116 @@ on: workflow_dispatch: inputs: tag: - description: "Existing tag to build and attach assets to" + description: "Existing release tag to attach the ctx assets to (publishing label, e.g. v3.3.30). Sources: tip of main." required: true type: string +# Read-only by default; only the job that touches the Release gets write. permissions: - contents: write + contents: read + +# A tag push and a dispatch for the same tag (or a re-run) would race on the +# asset upload. Queue them per tag and never cancel a run about to publish. +concurrency: + group: release-ctx-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false env: CARGO_TERM_COLOR: always jobs: + resolve: + name: resolve build ref + runs-on: ubuntu-latest + timeout-minutes: 5 + # `source` / `revision`, not `ref` / `sha`: CodeQL's untrusted-checkout + # heuristic (actions/cache-poisoning) classifies any checkout whose ref + # expression is named *ref*/*branch*/*head*/*sha*/*commit* as a PR head, + # whatever it flows from. The trust argument is the resolve step below. + outputs: + tag: ${{ steps.tag.outputs.tag }} + source: ${{ steps.source.outputs.source }} + revision: ${{ steps.source.outputs.revision }} + steps: + # Push: the pushed tag ref (checkout peels annotated tags to the commit). + # Dispatch: the tip of main — never an input, never the release tag. + # Full history so the commit can be checked against main below. + - name: Checkout build ref + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} + fetch-depth: 0 + + - name: Resolve release tag + id: tag + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + PUSHED_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" + case "$tag" in + v*.*.*) ;; + *) + echo "::error::tag '$tag' does not match v*.*.* (the same shape the push trigger accepts)" + exit 1 + ;; + esac + if ! git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + echo "::error::tag '$tag' does not exist on origin. Create the tag / Release first (gh release create $tag --target main …), then dispatch again." + exit 1 + fi + else + tag="$PUSHED_TAG" + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + # Derived from the checkout alone (no input reaches this step), so the + # commit every build job checks out is trusted code from main. + - name: Resolve build commit + id: source + env: + SOURCE: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} + run: | + set -euo pipefail + revision="$(git rev-parse HEAD)" + # Miners install what this publishes, so it is built from commits on + # the working branch only — never from an unmerged branch or a fork. + git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then + echo "::error::$SOURCE ($revision) is not on main. ctx is released from commits on main only; land the change first, or dispatch (which builds the tip of main)." + exit 1 + fi + if [ ! -f bins/ctx/Cargo.toml ]; then + echo "::error::$SOURCE ($revision) has no bins/ctx — it predates the ctx CLI. Dispatch instead: the release tag stays a label and the sources come from the tip of main." + exit 1 + fi + { + echo "source=$SOURCE" + echo "revision=$revision" + } >> "$GITHUB_OUTPUT" + + - name: Summary + env: + TAG: ${{ steps.tag.outputs.tag }} + SOURCE: ${{ steps.source.outputs.source }} + REVISION: ${{ steps.source.outputs.revision }} + run: | + set -euo pipefail + { + echo "## release-ctx" + echo + echo "| Release tag | Built from | Commit |" + echo "|-------------|------------|--------|" + echo "| \`$TAG\` | \`$SOURCE\` | \`$REVISION\` |" + } >> "$GITHUB_STEP_SUMMARY" + echo "release tag $TAG <- $SOURCE @ $REVISION" + build: name: ctx ${{ matrix.name }} + needs: [resolve] runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: @@ -48,22 +174,25 @@ jobs: runner: windows-latest target: x86_64-pc-windows-msvc steps: + # Every platform builds the one commit resolve picked, so the archives in + # SHA256SUMS.txt cannot straddle a push to main that lands mid-run. - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - ref: ${{ inputs.tag || github.ref }} + ref: ${{ needs.resolve.outputs.revision }} - name: Install Rust toolchain - # Pin by commit SHA. Never @master under contents:write. + # Every third-party action here is pinned by commit SHA (version in the + # trailing comment). A movable tag in a workflow that publishes what + # miners install would let upstream change the code on this path. uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 with: toolchain: "1.96.0" targets: ${{ matrix.target }} - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - with: - key: ${{ matrix.target }} + # No cargo cache on purpose: a release build is cold and reproducible, + # and a cache written after compiling a dispatched ref would be restored + # by every later run on main (cache poisoning). # `ring` needs a C toolchain, and the musl targets are what make the # Linux archives run on any distro without a glibc floor. @@ -91,7 +220,7 @@ jobs: Remove-Item ctx.exe - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ctx-${{ matrix.name }} path: | @@ -102,12 +231,14 @@ jobs: release: name: attach assets - needs: [build] + needs: [resolve, build] runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: write steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: ctx-* merge-multiple: true @@ -123,21 +254,64 @@ jobs: sha256sum ctx-* > SHA256SUMS.txt cat SHA256SUMS.txt + # Hand-written notes stay. The install block is added once per release, + # however many times ctx is rebuilt for that tag; every build appends one + # provenance line naming the commit it came from. + - name: Release notes + id: notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.resolve.outputs.tag }} + SOURCE: ${{ needs.resolve.outputs.source }} + REVISION: ${{ needs.resolve.outputs.revision }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + # Only "release not found" means the tag has no Release yet (the + # next step creates one). Any other failure is not an empty body: + # guessing would append a second install block to the real notes. + set +e + existing="$(gh release view "$TAG" --json body -q .body 2>"$RUNNER_TEMP/gh-release.err")" + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + if grep -q "release not found" "$RUNNER_TEMP/gh-release.err"; then + existing="" + else + echo "::error::could not read the release notes of $TAG (gh exit $rc); refusing to guess" + cat "$RUNNER_TEMP/gh-release.err" + exit 1 + fi + fi + # The block is present when the notes carry this exact command — a + # bare link to the script is not the install instructions. + install_cmd='curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh' + { + echo "body<> "$GITHUB_OUTPUT" + - name: Attach to release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: - tag_name: ${{ inputs.tag || github.ref_name }} + tag_name: ${{ needs.resolve.outputs.tag }} files: dist/* fail_on_unmatched_files: true - # Keep hand-written release notes; only add the install block. append_body: true - body: | - Cortex subnet CLI (`ctx`) for ${{ inputs.tag || github.ref_name }}. - - ```bash - curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh - ctx challenges - ctx status - ``` - - Default gateway: https://gateway.cortex.foundation + body: ${{ steps.notes.outputs.body }} diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md index b7601f1eb..5cfc1eb13 100644 --- a/deploy/AGENTS.md +++ b/deploy/AGENTS.md @@ -219,6 +219,7 @@ Tunnel writes gitignored `deploy/env/local-tunnel.env` (`BASE_GATEWAY_PUBLIC_URL | Staging | CI green on `main` (`deploy-staging.yml`) | `--build-from source` on droplet OK for iteration | | Images | Push to `main` (`images.yml`) | Build/push GHCR digests; promote + **commit** `deploy/pins/staging.json` + `deploy/digests/.json` | | Prod | Tag `v*.*.*` (`deploy-prod.yml`) | **`--build-from registry` only** — promote staging→prod pins, pull GHCR digests; no Rust source build on prod hosts | +| ctx CLI | Tag `v*.*.*`, or `workflow_dispatch tag=vX.Y.Z` (`release-ctx.yml`) | Builds `bins/ctx` from the tagged commit (push) or from the tip of `main` (dispatch — never the tag, never an input), commits on `main` only, no cargo cache, and attaches `ctx-*` archives + `SHA256SUMS.txt` to that Release. Tag = publishing label, build ref = sources; `scripts/install-ctx.sh` refuses a release without the sums. Recipe: [`README.md`](README.md) § `ctx` CLI release | Ladder: CI → GHCR digests → `deploy/pins/staging.json` (committed by `images.yml`) → tag → preflight (CI + staging pins match tag SHA) → `promote.sh` → `remote-deploy.sh --build-from registry`. Details: [`README.md`](README.md) § Auto CI deploy and § Promotion pipeline. diff --git a/deploy/README.md b/deploy/README.md index 9a07c0170..e72d646ee 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -126,6 +126,51 @@ combination. Verify locally: `./deploy/scripts/assert-compose-matrix.sh`. 7. Both prod hosts: `remote-deploy.sh --build-from registry` (pull GHCR `@sha256`, retag to Compose tags, `up --no-build`). 8. Smoke `/healthz`. `environment: production` (enable required reviewers in GitHub UI). +**`ctx` CLI release (miner installer):** `.github/workflows/release-ctx.yml` +builds `bins/ctx` for linux / darwin (amd64 + arm64) and windows and attaches +`ctx-*.tar.gz` / `.zip` plus `SHA256SUMS.txt` to the GitHub Release. +`scripts/install-ctx.sh` (the `curl … | sh` in the miner docs) installs from +`releases/latest` — or `CTX_VERSION=vX.Y.Z` — and refuses a release without +those files, so a release published without this workflow installs nothing. +The **release tag is a publishing label**; the **build ref is where the +sources come from**: + +| Trigger | Attaches to | Builds from | +|---------|-------------|-------------| +| push tag `v*.*.*` | that tag | the tagged commit (must be on `main`) | +| `workflow_dispatch` | `tag` (must already exist) | the tip of `main` at run time — never an input | + +```bash +# 1. Cut the label on tip (the tag push runs release-ctx from the tagged commit). +gh release create vX.Y.Z --target main --title "ctx CLI vX.Y.Z" --notes "…" +# 2. (Re)build ctx from the tip of main and attach it to that existing tag — +# also the fix for a release that has no ctx assets. +gh workflow run release-ctx.yml -f tag=vX.Y.Z +# 2b. To release an older commit on main, tag that commit instead of dispatching. +git tag -a vX.Y.Z -m "ctx CLI vX.Y.Z" && git push origin vX.Y.Z +# 3. Confirm the six assets, then install the way a miner does. +gh run watch && gh release view vX.Y.Z --json assets -q '.assets[].name' +curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh +``` + +A dispatch never checks the tag out: an old tag predates `bins/ctx` and used +to fail with `package ID specification ctx did not match any packages`. It +takes no build ref either — a publishing workflow must not check out a +caller-chosen ref (privileged context, cache poisoning). The `resolve` job +refuses a build commit that is not on `main` (miners install what this +publishes; an unmerged branch or a fork is never released) or has no +`bins/ctx`, refuses a `tag` that does not exist (a typo cannot mint a +release), builds all five archives cold — no cargo cache — from one resolved +commit, and appends that commit to the release notes. Runs for the same tag +queue (`concurrency`) instead of racing on the upload. Any +`v*.*.*` tag push also triggers `deploy-prod.yml`, whose preflight fails +closed unless CI is green for that SHA and `deploy/pins/staging.json` carries +it — a ctx-only label therefore shows a failed `deploy-prod` preflight and +deploys nothing. `releases/latest` is GitHub's newest non-draft, +non-prerelease release, ordered by the tagged commit's date rather than the +publish date — cut labels on tip, and mark experimental labels pre-release to +keep them off the default install path. + Required GitHub secrets: | Secret | Purpose | diff --git a/docs/external-miner/README.md b/docs/external-miner/README.md index ed46edcdf..c2057b71e 100644 --- a/docs/external-miner/README.md +++ b/docs/external-miner/README.md @@ -32,6 +32,16 @@ ctx challenges ctx status ``` +The installer takes the newest [release](https://github.com/CortexLM/cortex/releases) +and verifies `ctx--.tar.gz` against that release's `SHA256SUMS.txt`. +It refuses anything unverified: a release published without those files +stops with a message naming it and installs nothing. To install a specific +release instead, pin the tag with `CTX_VERSION`: + +```bash +curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | CTX_VERSION=vX.Y.Z sh +``` + Default gateway is [https://gateway.cortex.foundation](https://gateway.cortex.foundation). `--gateway` overrides it for a local stack. `LIUM_API_KEY` is forwarded as `X-Lium-Api-Key` and never printed. diff --git a/docs/external-miner/troubleshoot.md b/docs/external-miner/troubleshoot.md index 425c4cd10..0fefb9b1e 100644 --- a/docs/external-miner/troubleshoot.md +++ b/docs/external-miner/troubleshoot.md @@ -10,7 +10,9 @@ Install `ctx` from [README](./README.md). Proof miners pay Lium | Symptom | Likely cause | What to check | |---------|--------------|---------------| -| `install-ctx` aborts on checksum | Missing or mismatched `SHA256SUMS.txt` | The installer refuses an unverified binary. Wait for a `v*.*.*` release, or build `ctx` from this repo | +| `install-ctx` stops with `release vX.Y.Z has no ctx assets` | That release was published without the `release-ctx` workflow, so it carries no `ctx-*.tar.gz` / `SHA256SUMS.txt` | Nothing was installed. Pin a [release](https://github.com/CortexLM/cortex/releases) that lists them (`CTX_VERSION=vX.Y.Z`), or wait for the operator to attach `ctx` assets to that tag | +| `install-ctx` says `no release named vX.Y.Z` | `CTX_VERSION` names a tag that has no release | Check the tag on the releases page, or unset `CTX_VERSION` for the newest release | +| `install-ctx` aborts on checksum | Mismatched `SHA256SUMS.txt`, or `ctx-*.tar.gz` listed but absent | The installer refuses an unverified binary. Re-run later (an upload may be in progress), pin another release, or build `ctx` from this repo (`cargo build -p ctx --release --locked`) | | `request to … failed` | Gateway not reachable | `ctx status --gateway https://gateway.cortex.foundation`. A local stack needs `--gateway http://127.0.0.1:8080` | | `can_score: NO` / HTTP 503 | The host cannot score right now | Nothing was stored and nothing was rented. Read the error; do not retry-spend | diff --git a/scripts/install-ctx.sh b/scripts/install-ctx.sh index fab49f065..bc4b67eb8 100755 --- a/scripts/install-ctx.sh +++ b/scripts/install-ctx.sh @@ -4,9 +4,18 @@ # curl -fsSL https://raw.githubusercontent.com/CortexLM/cortex/main/scripts/install-ctx.sh | sh # # Knobs (all optional): -# CTX_VERSION release tag to install, e.g. v0.2.0 (default: latest) +# CTX_VERSION release tag to install, e.g. vX.Y.Z (default: latest). +# Pin it to install a specific release, or when `latest` +# was published without ctx assets: +# curl -fsSL .../install-ctx.sh | CTX_VERSION=vX.Y.Z sh # CTX_INSTALL_DIR install directory (default: $HOME/.local/bin) # +# The ctx archives (ctx--.tar.gz) and SHA256SUMS.txt are attached +# to a release by the release-ctx GitHub Actions workflow +# (.github/workflows/release-ctx.yml). A release cut without that workflow has +# neither file; this script then stops and names the release instead of +# installing anything. Releases: https://github.com/CortexLM/cortex/releases +# # The download is checksum-verified against the release's SHA256SUMS.txt. A # missing or mismatched checksum aborts the install rather than running an # unverified binary. @@ -17,6 +26,8 @@ REPO="CortexLM/cortex" GATEWAY="https://gateway.cortex.foundation" VERSION="${CTX_VERSION:-latest}" INSTALL_DIR="${CTX_INSTALL_DIR:-$HOME/.local/bin}" +RELEASES="https://github.com/$REPO/releases" +SCRIPT_URL="https://raw.githubusercontent.com/$REPO/main/scripts/install-ctx.sh" die() { echo "install-ctx: $*" >&2 @@ -34,7 +45,7 @@ need uname case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; - *) die "unsupported OS $(uname -s). Windows users: download ctx-windows-amd64.zip from https://github.com/$REPO/releases" ;; + *) die "unsupported OS $(uname -s). Windows users: download ctx-windows-amd64.zip from $RELEASES" ;; esac case "$(uname -m)" in @@ -45,9 +56,9 @@ esac asset="ctx-${os}-${arch}.tar.gz" if [ "$VERSION" = "latest" ]; then - base="https://github.com/$REPO/releases/latest/download" + base="$RELEASES/latest/download" else - base="https://github.com/$REPO/releases/download/$VERSION" + base="$RELEASES/download/$VERSION" fi sha256_of() { @@ -60,17 +71,83 @@ sha256_of() { fi } +# Download $1 to $2. Returns 0 when fetched and 44 when the server answered +# 404 (that file is not on the release); any other failure aborts the install +# so a network error is never mistaken for a missing asset. +fetch() { + code="$(curl -sSL -o "$2" -w '%{http_code}' "$1")" || die "download failed: $1" + case "$code" in + 2??) return 0 ;; + 404) + rm -f "$2" + return 44 + ;; + *) die "download failed (HTTP $code): $1" ;; + esac +} + +# The tag `latest` resolves to right now, so an error can name the release +# that is missing ctx assets. Best effort: a failure here only degrades the +# message, never the checksum verification. +release_label() { + if [ "$VERSION" != "latest" ]; then + echo "$VERSION" + return 0 + fi + url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$RELEASES/latest" 2>/dev/null || true)" + case "$url" in + */releases/tag/*) echo "${url##*/} (latest)" ;; + *) echo "latest" ;; + esac +} + +# Whether $RELEASES/tag/ exists at all, to tell "no such release" from +# "release exists but was cut without ctx assets". Only a 404 means absent; +# an outage or a network error aborts with its own message, so nobody is told +# to change CTX_VERSION when GitHub is what is failing. +release_exists() { + case "$VERSION" in + latest) return 0 ;; + esac + code="$(curl -sSLI -o /dev/null -w '%{http_code}' "$RELEASES/tag/$VERSION")" \ + || die "could not reach $RELEASES to check release $VERSION (network error); retry later" + case "$code" in + 2??) return 0 ;; + 404) return 44 ;; + *) die "HTTP $code from $RELEASES/tag/$VERSION while checking that the release exists; GitHub may be unavailable, retry later" ;; + esac +} + tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT INT TERM -echo "install-ctx: downloading $asset ($VERSION)" -curl -fsSL "$base/$asset" -o "$tmp/$asset" \ - || die "download failed: $base/$asset" -curl -fsSL "$base/SHA256SUMS.txt" -o "$tmp/SHA256SUMS.txt" \ - || die "no SHA256SUMS.txt in that release; refusing to install unverified" +label="$(release_label)" +echo "install-ctx: release $label" + +# Sums first. A release published without the release-ctx workflow has no ctx +# assets at all, and that deserves a clearer message than a 404 on the tarball. +if ! fetch "$base/SHA256SUMS.txt" "$tmp/SHA256SUMS.txt"; then + if ! release_exists; then + die "no release named $VERSION under $RELEASES (set CTX_VERSION to an existing tag, or unset it for latest)" + fi + cat >&2 < release-ctx -> Run workflow -> tag=), or pin a +install-ctx: release that lists ctx-*.tar.gz and SHA256SUMS.txt under $RELEASES: +install-ctx: curl -fsSL $SCRIPT_URL | CTX_VERSION=vX.Y.Z sh +EOF + exit 1 +fi want="$(grep " \{1,2\}\*\{0,1\}${asset}\$" "$tmp/SHA256SUMS.txt" | cut -d' ' -f1 | head -n1)" -[ -n "$want" ] || die "$asset is not listed in SHA256SUMS.txt" +[ -n "$want" ] || die "release $label has no ${os}-${arch} build ($asset is not listed in SHA256SUMS.txt)" + +echo "install-ctx: downloading $asset" +fetch "$base/$asset" "$tmp/$asset" \ + || die "$asset is listed in SHA256SUMS.txt but missing from release $label (incomplete upload?)" got="$(sha256_of "$tmp/$asset")" [ "$want" = "$got" ] || die "checksum mismatch for $asset (expected $want, got $got)" @@ -81,7 +158,7 @@ mkdir -p "$INSTALL_DIR" cp "$tmp/ctx" "$INSTALL_DIR/ctx" chmod 755 "$INSTALL_DIR/ctx" -echo "install-ctx: installed $("$INSTALL_DIR/ctx" --version) to $INSTALL_DIR/ctx" +echo "install-ctx: installed $("$INSTALL_DIR/ctx" --version) from release $label to $INSTALL_DIR/ctx" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; *)