Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
59e2a9d01This file is pinned to the selected release. Download the original to open it in your design tool.
name: Release
# Cut a release by pushing a tag: git tag -a v0.1.72_cli -F notes.md && git push origin v0.1.72_cli
#
# The `_cli` suffix exists only so this workflow's tag trigger never fires on the backend's
# `v*_backend` release tags (see .github/workflows/production-be-build.yaml) now that both live in one
# repo. It is stripped below before anything treats it as a version — the manifest, the release
# title, and the daemon's self-update comparison all only ever see a bare X.Y.Z.
#
# Publishes the `cli` key of harness/cli/metadata.json — the manifest EVERY RUNNING DAEMON POLLS
# EVERY 60 SECONDS (src/config/env.ts: ADAPTER_UPDATE_URL / ADAPTER_UPDATE_CHECK_MS). That is the
# one way this workflow is not the desktop's: there is no download page and no user clicking
# "install". A minute after the manifest moves, every machine on the fleet is running these bytes.
# Everything below that looks like belt-and-braces is sized against that blast radius.
#
# Shape borrowed from autonomous-harness-desktop's release.yml, minus the parts that do not apply:
#
# * SAME bucket, SAME preflight, SAME "the tag is the version" rule, SAME annotated-tag release
# notes, SAME `metadata_path` dry run. NOT the same credential: this one authenticates with
# workload identity federation instead of a long-lived service-account key (see below).
# * NO scratch-manifest merge. The desktop fans out to three build jobs that each own a manifest
# key, so it points each at its own scratch file and merges once at the end. The CLI is ONE
# platform-independent bundle under ONE key, published by ONE job — the race the desktop
# designs around cannot occur here, so inventing a merge step would be ceremony.
# * NO signing or notarization. The artifacts are two JavaScript files; integrity is the sha256
# in the manifest, which selfUpdate.ts verifies in memory before it writes anything to disk.
#
# cli/scripts/upload-cli.sh is used EXACTLY as it is, with no flag this file invented: GCS_BUCKET,
# METADATA_PATH and OTA_KEY are env overrides it already supports. The script is the production
# release path, and this workflow is its only caller: there is no `make` target that runs it from a
# laptop, so every published CLI version has the tag that names it.
#
# The TAG is the version. CI never auto-bumps: left to itself the script reads the current version
# out of the remote manifest and adds one, so what publishes would depend on when the job ran.
#
# Dry run first: Actions -> Release -> Run workflow, with metadata_path = harness/cli/
# metadata-test.json. No daemon polls that file, so nothing self-updates. Note that the ARTIFACTS
# still go to their real home, harness/cli/<version>/ — the script hardcodes that path and this
# file does not edit scripts. Use an unmistakable version (9.9.9) and delete
# gs://<bucket>/harness/cli/9.9.9/ afterwards.
#
# There is no undo. selfUpdate.ts only ever moves forward, so a bad release is rolled back by
# publishing a HIGHER version containing the older code — never by deleting the new one, which just
# leaves every daemon pointed at a 404.
on:
push:
tags: ['v*_cli']
workflow_dispatch:
inputs:
version:
description: 'Version to publish (X.Y.Z)'
required: true
metadata_path:
description: 'Manifest to write. Leave as-is for a real release; metadata-test.json for a dry run.'
required: false
default: harness/cli/metadata.json
permissions:
contents: read
# Mints the OIDC token the auth steps exchange for a short-lived GCP credential. It is a
# capability, not a secret: what it can reach is decided entirely by the pool's
# attribute-condition and the service account's IAM bindings, both set by
# autonomous-ai/github-templates: scripts/setup-gcp-wif.sh (runbook: WORKLOAD-IDENTITY.md).
id-token: write
concurrency:
# Two releases overlapping would interleave their manifest writes, and the loser's version would
# be the one the fleet installs. Never cancelled in progress: a run killed between "artifacts
# uploaded" and "manifest written" leaves bytes nobody references, which is harmless — one killed
# a second later leaves the manifest pointing at a build whose job never finished.
group: cli-release
cancel-in-progress: false
env:
GCS_BUCKET: s3-autonomous-upgrade-3 # upload-cli.sh reads this too — one place, not three
jobs:
# Resolve and validate before anything else starts. A malformed tag fails here in seconds.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
metadata_path: ${{ steps.resolve.outputs.metadata_path }}
steps:
- id: resolve
# Through env, never `${{ }}` inline in run:. Git permits quotes, backticks and $() in a ref
# name, so interpolating one into the shell would let a tag name execute as code.
env:
REF_NAME: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_METADATA_PATH: ${{ inputs.metadata_path }}
run: |
set -euo pipefail
# A tag push names the ref "vX.Y.Z_cli"; workflow_dispatch's input.version is already
# bare. Strip both the leading "v" and the trailing "_cli" so $ver is always X.Y.Z —
# the suffix is a tag-trigger routing marker only, never part of the published version.
ver="${INPUT_VERSION:-${REF_NAME#v}}"
ver="${ver%_cli}"
if [[ ! "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version '$ver' must look like X.Y.Z — tag as vX.Y.Z_cli"
exit 1
fi
meta="${INPUT_METADATA_PATH:-harness/cli/metadata.json}"
echo "version=$ver" >> "$GITHUB_OUTPUT"
echo "metadata_path=$meta" >> "$GITHUB_OUTPUT"
echo "Publishing **$ver** to \`$meta\`" >> "$GITHUB_STEP_SUMMARY"
# Proving the credential now means a broken one costs seconds instead of a whole bundle+upload.
# Runs beside `version`, so it is free in wall clock.
preflight:
runs-on: ubuntu-latest
steps:
# GCP auth, WITHOUT a long-lived key wherever possible.
#
# A service-account JSON key is a bearer credential with no expiry: it is only as safe as every
# place it has ever been copied to — a laptop, a password manager, a paste into chat, a CI log
# somebody widened. Workload Identity Federation replaces it with a short-lived token minted
# from this job's own OIDC identity, so there is nothing to leak and nothing to rotate. The GCP
# side is one script, in autonomous-ai/github-templates: scripts/setup-gcp-wif.sh.
#
# Federation is the only path: the GCP_SA_KEY fallback and the key behind it were retired on
# 2026-09-17. If this step fails, fix the provider or the service account's binding — do not
# reintroduce a key.
- name: Authenticate to GCP (workload identity federation)
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
- name: Verify GCS write access
run: |
set -euo pipefail
probe="gs://${GCS_BUCKET}/harness/cli/.ci-preflight/${GITHUB_RUN_ID}"
# `gcloud storage`, not `gsutil`: gsutil is a standalone Python tool that reads gcloud's
# credential store, and it does not handle the external-account (federated) credential WIF
# produces. `gcloud storage` is the same gcloud binary that did the token exchange.
echo "$GITHUB_RUN_ID" | gcloud storage cp - "$probe"
gcloud storage rm "$probe"
# No vitest gate here. The full suite (ci.yml) runs on pull requests — but NOTE that ci.yml has no
# `push` trigger and main also takes direct pushes, so a tagged commit has NOT necessarily been
# through it. What stands between a tag and the fleet is exactly what `publish` runs itself:
# `tsc --noEmit` below (esbuild only strips types, it never checks them) and upload-cli.sh's own
# bundle/version checks. The vitest run was dropped on 2026-09-11 after v0.2.16_cli was held up
# by a slow test on a cold runner rather than by a bug; typecheck stays because it is ~15s and is
# the one thing cheap enough to keep in front of a publish that every daemon installs within a
# minute and that cannot be undone.
publish:
needs: [version, preflight]
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# The script's own env override. For a real release this IS harness/cli/metadata.json; for a
# dry run it is the scratch manifest nothing polls.
METADATA_PATH: ${{ needs.version.outputs.metadata_path }}
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
# THE runtime pin — the managed Node the shipped CLI actually executes on (see the same
# pin and its full rationale in ci.yml's `pin` step). Bump the two together, never one.
node-version: 22.23.2
cache: npm
cache-dependency-path: cli/package-lock.json
- run: npm ci
working-directory: cli
# Typecheck only — see the note above `publish` for why this one check stays and vitest does
# not. Before auth on purpose: a type error should fail the release without ever touching GCP.
- run: npx tsc --noEmit
working-directory: cli
# Same two-step auth as `preflight` above, and the same cutover note applies: delete the key
# fallback once a release has gone green on federation.
- name: Authenticate to GCP (workload identity federation)
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
# From the repo root, the way `make upload-cli` calls it. The script bundles with
# ADAPTER_VERSION="$VERSION" baked in, refuses to upload if `node dist/cli.js version` does
# not read back that exact string, uploads cli.js + notify.mjs to harness/cli/$VERSION/, then
# merges the `cli` key into $METADATA_PATH.
#
# Expect `>> could not read remote metadata` on a dry run: METADATA_PATH points at a file that
# does not exist yet, and that line is only the auto-bump's input. The version that publishes
# is the one passed here.
- name: Bundle and publish the CLI
run: bash cli/scripts/upload-cli.sh "$VERSION"
# Do exactly what a daemon does one minute from now, and fail the release if it does not work.
#
# upload-cli.sh already checks the bundle before uploading; this checks the PUBLISHED result —
# manifest actually served, URLs actually reachable, sha256 actually matching the bytes GCS
# hands out, and the downloaded cli.js actually able to print its own version. Those are
# different failures (a half-written manifest, a botched Cache-Control, an object that landed
# in the wrong bucket) and none of them are visible from the upload side.
#
# selfUpdate.ts refuses a digest mismatch, so the fleet is safe either way. What it does NOT
# do is tell anyone: every daemon simply keeps the version it has and the release looks green.
- name: Verify the published release the way a daemon would
run: |
set -euo pipefail
# Built here from $GCS_BUCKET and $METADATA_PATH, both already in this job's environment,
# rather than interpolated into the script by Actions — same rule as the tag name above.
# (A workflow expression is substituted into `run:` BEFORE any shell sees it, so writing
# one is not made safe by putting it in a comment: an empty pair of braces here is a
# workflow syntax error, not a comment. actionlint catches that; nothing else does.)
curl -fsSL "https://storage.googleapis.com/${GCS_BUCKET}/${METADATA_PATH}" -o manifest.json
echo "::group::manifest"; cat manifest.json; echo "::endgroup::"
# The layout install.sh writes next to the downloaded bundle, and it is load-bearing: the
# bundle is ESM, and node decides that from the nearest package.json. Without this file it
# would parse a 2MB ES module as CommonJS. (Node 22 would probably rescue it by sniffing
# the syntax; a verification step must not depend on probably.)
mkdir -p verify && printf '{"type":"module"}\n' > verify/package.json
# The single quotes are the point: this is JavaScript, and its `${...}` template literals
# must reach node unexpanded by the shell.
# shellcheck disable=SC2016
node -e '
const fs = require("node:fs"), crypto = require("node:crypto")
const { execFileSync } = require("node:child_process")
const main = async () => {
const want = process.env.VERSION
const entry = JSON.parse(fs.readFileSync("manifest.json", "utf8")).cli
if (!entry) throw new Error("manifest has no `cli` key")
if (entry.version !== want) throw new Error(`manifest says ${entry.version}, expected ${want}`)
for (const [key, file] of [["cli", "cli.js"], ["notify", "notify.mjs"]]) {
const ref = entry[key]
if (!ref || !ref.url || !ref.sha256) throw new Error(`${key}: manifest entry has no url/sha256`)
const res = await fetch(ref.url, { cache: "no-store" })
if (!res.ok) throw new Error(`${key}: GET ${ref.url} -> ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
const got = crypto.createHash("sha256").update(buf).digest("hex")
if (got !== String(ref.sha256).toLowerCase()) throw new Error(`${key}: sha256 ${got} != manifest ${ref.sha256}`)
if (buf.length !== ref.size) throw new Error(`${key}: ${buf.length} bytes != manifest ${ref.size}`)
fs.writeFileSync(`verify/${file}`, buf)
console.log(` ${file}: ${buf.length} bytes, sha256 ok`)
}
// The bytes a daemon would install, asked their own version. This is the one check
// that proves the manifest and the object describe the SAME build — every check above
// would still pass if a correct manifest pointed at a correctly-hashed wrong file.
const said = execFileSync(process.execPath, ["verify/cli.js", "version"], { encoding: "utf8" }).trim()
if (said !== want) throw new Error(`downloaded cli.js reports ${said}, expected ${want}`)
console.log(` verify/cli.js reports ${said}`)
}
main().catch(err => { console.error(`::error::${err.message}`); process.exit(1) })
'
{
echo "Verified \`$VERSION\` at \`$METADATA_PATH\`"
echo ""
echo "Running daemons self-update within ~1 min."
} >> "$GITHUB_STEP_SUMMARY"
# The GitHub Release. Notes are the tag's OWN message, verbatim — the same rule the desktop repo
# follows. CI generates nothing: what a human wrote and reviewed at tag time is exactly what
# publishes, and `generate_release_notes` would append a second, differently-built list under it
# that silently omits anything which reached main without a PR.
#
# No binaries attached. GCS is the distribution channel — install.sh and the self-updater both
# read the manifest — so a copy here would be a second set of bytes that can disagree with the
# one machines actually install. The notes link to the real ones instead.
github-release:
needs: [version, publish]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write # narrower than the workflow default only here, where a release is created
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Take the release notes from the tag
env:
REF_NAME: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
# actions/checkout replaces the pushed annotated tag with a LIGHTWEIGHT local ref, even at
# fetch-depth: 0, so `git cat-file -t` below would see "commit" and reject a properly
# annotated tag. Re-fetch the tag object to restore the annotation before reading it.
git fetch --force origin tag "$REF_NAME"
# A lightweight tag has no message of its own, and %(contents) would quietly fall through
# to the commit's — publishing "chore(release): vX.Y.Z" as the notes. The release scripts
# always push an annotated tag with notes, but a hand-made `git tag` does not, and this
# job runs AFTER publish: failing here left a live build with no Release page (v0.2.82_cli,
# 2026-09-21). So fall back to the same commit list the script would have generated —
# everything since the previous _cli tag — and warn, rather than leave the release half done.
if [ "$(git cat-file -t "$REF_NAME")" = tag ]; then
git tag -l --format='%(contents)' "$REF_NAME" > RELEASE_NOTES.md
else
: > RELEASE_NOTES.md
fi
if [ ! -s RELEASE_NOTES.md ]; then
echo "::warning::$REF_NAME carries no release notes (lightweight or empty tag) — generating them from the commit list. Next time: make release-cli"
prev="$(git tag -l 'v*_cli' \
| { grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)_cli$' || true; } \
| grep -vx "$REF_NAME" | sort -V | tail -1)"
{
printf 'Harness CLI %s\n\n' "$VERSION"
if [ -n "$prev" ]; then
git log --no-merges --pretty='- %s' "${prev}..${REF_NAME}"
else
git log --no-merges --pretty='- %s' -20 "$REF_NAME"
fi
} > RELEASE_NOTES.md
fi
base="https://cdn.autonomous.ai/harness/cli/${VERSION}"
cat >> RELEASE_NOTES.md <<EOF
---
## Install
\`\`\`bash
curl -fsSL https://harness.autonomous.ai/cli/install.sh | bash
\`\`\`
Already running \`harness\`? The daemon self-updates within a minute — nothing to do.
| Artifact | |
|---|---|
| \`cli.js\` | [download](${base}/cli.js) |
| \`notify.mjs\` | [download](${base}/notify.mjs) |
EOF
echo "::group::RELEASE_NOTES.md"; cat RELEASE_NOTES.md; echo "::endgroup::"
# Pinned to a commit, not a tag: this step runs with `contents: write` and a moving tag on a
# third-party action means whoever can move it can run code against that token.
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
body_path: RELEASE_NOTES.md