Skip to content

chore(release): bump version to 0.8.2b3 (#978) #29

chore(release): bump version to 0.8.2b3 (#978)

chore(release): bump version to 0.8.2b3 (#978) #29

Workflow file for this run

name: Release
# Cut a hal0 release on a `v*` tag push:
# 1. build the source + UI tarball with the layout the updater expects
# 2. cosign keyless-sign the tarball against the workflow's OIDC subject
# 3. write a hal0.releases.v1 manifest pointing at the GH-release assets
# 4. publish tarball + .sig + manifest as a GitHub Release
#
# Triggered by: `git tag vX.Y.Z && git push origin vX.Y.Z`.
# Also reusable via workflow_call from nightly.yml (a GITHUB_TOKEN tag push
# can't trigger this workflow's `push` event, so nightly invokes it directly).
#
# The manifest is automatically served at https://releases.hal0.dev/<channel>.json
# (e.g. /stable.json) by the Cloudflare Pages middleware in hal0-web
# (functions/_middleware.ts). On a successful upload here, the next request
# to releases.hal0.dev picks it up within ~60s (CF cache TTL) — no hal0-web
# deploy needed. Updater clients should point at
# HAL0_RELEASES_URL=https://releases.hal0.dev/stable.json.
#
# What this workflow does NOT do (deliberate, see "Blockers" in
# scripts/release-prototype/RELEASE_PIPELINE_NOTES.md):
# - rebuild + republish toolbox images. The release manifest mirrors the
# toolbox digests pinned in manifest.json at the moment of the tag. Those
# digests are refreshed by running scripts/update-toolbox-digests.sh on
# main before cutting the release (it queries ghcr.io and patches
# manifest.json in place); see docs/internal/release-manifest.md.
#
# See: docs/internal/release-manifest.md, src/hal0/updater/updater.py,
# scripts/release-prototype/verify-roundtrip.sh, PLAN §9 + §17.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to (re)build a release for, e.g. v0.1.0-rc1"
required: true
default: ""
channel:
description: "Release channel (blank = derive from tag)"
required: false
default: ""
workflow_call:
inputs:
tag:
description: "Tag to build a release for"
required: true
type: string
channel:
description: "Release channel (blank = derive from tag)"
required: false
type: string
default: ""
permissions:
contents: write # required to upload Release assets
id-token: write # required for cosign keyless OIDC
packages: read # in case we ever pull a private image at build time
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false # never cancel a half-published release
jobs:
release:
name: Build, sign, publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# ── 0. Resolve tag + version ────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve tag + version
id: ver
run: |
TAG="${{ inputs.tag }}"
if [[ -z "${TAG}" ]]; then
TAG="${GITHUB_REF#refs/tags/}"
fi
# Strip leading "v"; updater compares dotted versions without it.
VERSION="${TAG#v}"
if [[ -z "${VERSION}" ]]; then
echo "::error::could not resolve version from tag '${TAG}'"
exit 1
fi
# Channel: an explicit input wins; otherwise derive from the tag
# (a `-nightly.<date>` segment ⇒ nightly, else stable).
# On a plain tag-push event `inputs` is undefined and ${{ inputs.channel }}
# renders empty, so the -z branch (Python derivation) is the push path.
# TAG goes through the environment, not string-interpolated into the
# one-liner, so an odd tag can't break/inject into the Python literal.
CHANNEL="${{ inputs.channel }}"
if [[ -z "${CHANNEL}" ]]; then
CHANNEL="$(TAG="${TAG}" PYTHONPATH=src python3 -c "import os; from hal0.release.channel import channel_for_tag; print(channel_for_tag(os.environ['TAG']))")"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
- name: Confirm pyproject.toml version matches the tag
run: |
PYV="$(python3 -c '
import tomllib
print(tomllib.loads(open("pyproject.toml","rb").read().decode())["project"]["version"])
')"
echo "pyproject.toml version: ${PYV}"
echo "tag-derived version: ${{ steps.ver.outputs.version }}"
CHANNEL="${{ steps.ver.outputs.channel }}"
if [[ "${CHANNEL}" == "nightly" ]]; then
# Nightly: pyproject stays on its dev version (e.g. 0.5.0-alpha.1)
# while the tag is v<base>-nightly.<date>; require only the base
# X.Y.Z to match so we never ship a tarball off the wrong line.
# PYV/TAG via env (not interpolated into the literal) so an odd
# version/tag string can't break or inject into the Python.
if ! PYV="${PYV}" TAG="${{ steps.ver.outputs.tag }}" PYTHONPATH=src python3 -c "import os, sys; from hal0.release.channel import base_matches; sys.exit(0 if base_matches(os.environ['PYV'], os.environ['TAG']) else 1)"; then
echo "::error::nightly tag base ≠ pyproject base version (${PYV})"
exit 1
fi
elif [[ "${PYV}" != "${{ steps.ver.outputs.version }}" ]]; then
echo "::error::pyproject.toml version (${PYV}) ≠ tag (${{ steps.ver.outputs.version }})"
echo "::error::bump pyproject.toml + retag, or use scripts/release-check.sh --tag ${{ steps.ver.outputs.tag }} first"
exit 1
fi
# ── 1. Build the UI bundle so the tarball includes ui-dist/ ─────────────
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Build UI
working-directory: ui
run: |
npm ci
npm run build
# ── 2. Stage the release tree ──
- name: Stage release tree
id: stage
run: |
VERSION="${{ steps.ver.outputs.version }}"
STAGE="${RUNNER_TEMP}/hal0-${VERSION}"
mkdir -p "${STAGE}"
# Source + manifest + license — the runtime payload.
cp -a src "${STAGE}/"
cp -a manifest.json "${STAGE}/"
cp -a LICENSE "${STAGE}/"
cp -a README.md "${STAGE}/"
cp -a pyproject.toml "${STAGE}/"
cp -a installer "${STAGE}/"
cp -a packaging "${STAGE}/"
cp -a docs "${STAGE}/"
# Prebuilt UI bundle — staged at ui/dist/ so install.sh's
# "ui/dist already built — left alone" branch fires (the npm
# fallback would `cd ui && npm install` which we don't ship)
# and so the FastAPI mount_dashboard resolution path
# (`<repo>/ui/dist`) finds it without a HAL0_UI_DIST override.
if [[ -d ui/dist ]]; then
mkdir -p "${STAGE}/ui"
cp -a ui/dist "${STAGE}/ui/dist"
else
echo "::error::ui/dist missing after npm run build"
exit 1
fi
echo "${VERSION}" > "${STAGE}/VERSION"
echo "stage=${STAGE}" >> "$GITHUB_OUTPUT"
- name: Build tarball
id: tarball
run: |
VERSION="${{ steps.ver.outputs.version }}"
OUT="${RUNNER_TEMP}/hal0-${VERSION}.tar.gz"
# Use --owner/--group to make the archive reproducible-ish across
# runners. The updater doesn't care, but it makes diffs sane.
tar --owner=0 --group=0 --numeric-owner \
--sort=name --mtime='UTC 2026-01-01' \
-C "${RUNNER_TEMP}" \
-czf "${OUT}" \
"hal0-${VERSION}"
DIGEST="$(sha256sum "${OUT}" | awk '{print $1}')"
SIZE="$(stat -c %s "${OUT}")"
echo "path=${OUT}" >> "$GITHUB_OUTPUT"
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "size=${SIZE}" >> "$GITHUB_OUTPUT"
echo "::notice::tarball ${OUT} ${SIZE}B sha256=${DIGEST}"
# ── 3. cosign keyless-sign the tarball via GH Actions OIDC ──────────────
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: cosign sign-blob (keyless OIDC, legacy raw .sig + .crt)
id: sign
env:
# Belt + braces: the keyless flow reads ACTIONS_ID_TOKEN_REQUEST_*
# from the runner automatically when `id-token: write` is granted.
COSIGN_EXPERIMENTAL: "true"
run: |
TARBALL="${{ steps.tarball.outputs.path }}"
SIG="${TARBALL}.sig"
CRT="${TARBALL}.crt"
# cosign 3.x defaults to --new-bundle-format=true and refuses
# --output-signature. Today's updater (_verify_cosign in
# src/hal0/updater/updater.py) calls verify-blob with the legacy
# --signature flag, so we opt out of the bundle format here.
# cosign 3.x also requires the Fulcio-issued certificate to be
# passed via --certificate at verify time (--certificate-identity-
# regexp is checked against the cert's SAN), so we --output-certificate
# alongside the .sig and publish both as release assets. When the
# updater learns to consume Sigstore Bundles (RELEASE_PIPELINE_NOTES.md
# → Findings § cosign 3.x), drop --new-bundle-format=false and emit
# a .bundle in place of the separate .sig + .crt.
cosign sign-blob --yes \
--new-bundle-format=false \
--output-signature "${SIG}" \
--output-certificate "${CRT}" \
"${TARBALL}"
echo "sig=${SIG}" >> "$GITHUB_OUTPUT"
echo "cert=${CRT}" >> "$GITHUB_OUTPUT"
# ── 4. Self-verify before publishing — never ship a release we can't
# verify with our own production code path. ────────────────────────
- name: Self-verify (cosign verify-blob with workflow OIDC identity)
run: |
TARBALL="${{ steps.tarball.outputs.path }}"
SIG="${{ steps.sign.outputs.sig }}"
CRT="${{ steps.sign.outputs.cert }}"
# The identity regex must match what we'll write into the
# manifest below — keep these two strings in lock-step.
# github.repository preserves case (Hal0ai/hal0); use (?i) to
# absorb the lowercase `hal0ai` redirect form too.
# The Fulcio cert SAN is the workflow that OWNS the signing job
# (release.yml — the job_workflow_ref) at the CALLER's ref:
# release.yml@refs/tags/vX on a direct tag push, or
# release.yml@refs/heads/main when invoked via workflow_call from
# nightly.yml. github.ref is the caller's ref in BOTH cases — NOT
# github.workflow_ref, which is the entry-point (nightly.yml) and
# does NOT match the cert SAN under workflow_call.
IDENT="^(?i)https://github\\.com/${{ github.repository }}/\\.github/workflows/release\\.yml@${{ github.ref }}$"
ISSUER="https://token.actions.githubusercontent.com"
cosign verify-blob \
--signature "${SIG}" \
--certificate "${CRT}" \
--certificate-identity-regexp "${IDENT}" \
--certificate-oidc-issuer "${ISSUER}" \
"${TARBALL}"
# ── 5. Generate the hal0.releases.v1 manifest ───────────────────────────
- name: Generate release manifest
id: manifest
run: |
VERSION="${{ steps.ver.outputs.version }}"
TAG="${{ steps.ver.outputs.tag }}"
CHANNEL="${{ steps.ver.outputs.channel }}"
DIGEST="${{ steps.tarball.outputs.digest }}"
OUT="${RUNNER_TEMP}/${CHANNEL}.json"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}"
MANIFEST_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${CHANNEL}.json"
# Must match the Self-verify identity above (and the cert SAN) so the
# updater's cosign verify-blob succeeds. release.yml@<caller-ref> via
# github.ref — correct for both tag-push and workflow_call (nightly).
IDENT="^(?i)https://github\\.com/${{ github.repository }}/\\.github/workflows/release\\.yml@${{ github.ref }}$"
python3 - "${OUT}" "${VERSION}" "${TAG}" "${CHANNEL}" "${DIGEST}" \
"${BASE_URL}" "${MANIFEST_URL}" "${IDENT}" <<'PY'
import json, sys, datetime as dt
out, version, tag, channel, digest, base, self_url, ident = sys.argv[1:]
# Mirror the toolbox_images block from manifest.json into the
# release manifest so an updater can pull the matching image
# set (see docs/internal/release-manifest.md §"What the release pipeline
# must guarantee", item 6).
repo_manifest = json.loads(open("manifest.json").read())
payload = {
"_schema": "hal0.releases.v1",
"version": version,
"channel": channel,
"url": f"{base}/hal0-{version}.tar.gz",
"sig_url": f"{base}/hal0-{version}.tar.gz.sig",
"cert_url": f"{base}/hal0-{version}.tar.gz.crt",
"digest_sha256": digest,
"signer_identity": ident,
"signer_issuer": "https://token.actions.githubusercontent.com",
"min_data_version": int(repo_manifest.get("min_data_version", 1)),
"released_at": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
"notes_url": f"https://github.com/{__import__('os').environ['GITHUB_REPOSITORY']}/releases/tag/{tag}",
"manifest_url": self_url,
"toolbox_images": repo_manifest.get("toolbox_images", {}),
}
# Refuse to publish if any toolbox digest is null/missing — the
# release would advertise images we never actually pinned.
missing = [n for n, e in payload["toolbox_images"].items() if not e.get("digest")]
if missing:
sys.exit(f"toolbox_images digests missing for: {missing} - run scripts/update-toolbox-digests.sh on main first")
open(out, "w").write(json.dumps(payload, indent=2) + "\n")
print(f"wrote {out}")
PY
echo "path=${OUT}" >> "$GITHUB_OUTPUT"
- name: Self-validate manifest against ReleaseManifest schema
run: |
# Catch shape regressions before publish. Uses the runtime's
# own pydantic schema so the manifest can never drift from
# what hal0.updater can actually parse.
python3 -m pip install -e ".[dev]" >/dev/null
python3 - "${{ steps.manifest.outputs.path }}" <<'PY'
import json, sys
from hal0.updater.updater import _parse_manifest
raw = json.loads(open(sys.argv[1]).read())
mf = _parse_manifest(raw)
print(f"manifest OK: version={mf.version} digest_sha256={mf.digest_sha256[:12]}…")
PY
# ── 6. Generate release notes ────────────────────────────────────────────
- name: Generate release notes
id: notes
run: |
TAG="${{ steps.ver.outputs.tag }}"
CHANNEL="${{ steps.ver.outputs.channel }}"
NOTES_FILE="${RUNNER_TEMP}/RELEASE_NOTES.md"
# Non-fatal wrapper: if anything goes wrong we still publish.
generate_notes() {
if [[ "${CHANNEL}" == "nightly" ]]; then
# Nightly: summarise commits since the previous nightly tag.
# The current tag was already pushed by the nightly.yml tag job,
# so the "previous" nightly is the second entry in newest-first order.
PREV_NIGHTLY="$(git tag --list 'v*-nightly.*' --sort=-creatordate | grep -v "^${TAG}$" | head -n1)"
if [[ -n "${PREV_NIGHTLY}" ]]; then
FROM_REF="${PREV_NIGHTLY}"
FROM_LABEL="since ${PREV_NIGHTLY}"
else
# No prior nightly — fall back to the most recent stable tag.
PREV_STABLE="$(git tag --list 'v*' --sort=-creatordate \
| grep -v '\-nightly\.' | grep -v "^${TAG}$" | head -n1)"
FROM_REF="${PREV_STABLE:-}"
FROM_LABEL="since ${PREV_STABLE:-initial commit}"
fi
{
echo "Nightly ${TAG} — changes ${FROM_LABEL}:"
echo ""
if [[ -n "${FROM_REF}" ]]; then
git log --pretty='- %s (%h)' "${FROM_REF}..HEAD"
else
git log --pretty='- %s (%h)' HEAD
fi
} > "${NOTES_FILE}"
else
# Stable / alpha: extract the matching section from CHANGELOG.md.
VERSION="${TAG#v}"
SECTION="$(PYTHONPATH=src python3 -c "
import sys
from hal0.release.notes import extract_changelog_section
body = extract_changelog_section(open('CHANGELOG.md').read(), sys.argv[1])
print(body)
" "${TAG}" 2>/dev/null || true)"
if [[ -n "${SECTION}" ]]; then
echo "${SECTION}" > "${NOTES_FILE}"
else
# Fallback: commit log since the previous stable tag.
PREV_STABLE="$(git tag --list 'v*' --sort=-creatordate \
| grep -v '\-nightly\.' | grep -v "^${TAG}$" | head -n1)"
{
echo "Changes in ${TAG}:"
echo ""
if [[ -n "${PREV_STABLE}" ]]; then
git log --pretty='- %s' "${PREV_STABLE}..${TAG}"
else
git log --pretty='- %s' "${TAG}"
fi
echo ""
echo "See CHANGELOG.md for full details."
} > "${NOTES_FILE}"
fi
fi
}
if ! generate_notes 2>/dev/null; then
echo "See CHANGELOG.md for release notes." > "${NOTES_FILE}"
fi
echo "path=${NOTES_FILE}" >> "$GITHUB_OUTPUT"
# ── 7. Publish GitHub Release with all three assets ─────────────────────
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.ver.outputs.tag }}"
TARBALL="${{ steps.tarball.outputs.path }}"
SIG="${{ steps.sign.outputs.sig }}"
CRT="${{ steps.sign.outputs.cert }}"
MANIFEST="${{ steps.manifest.outputs.path }}"
NOTES_FILE="${{ steps.notes.outputs.path }}"
# Create the release if it doesn't already exist (workflow_dispatch
# rebuild path) — otherwise just upload the assets, replacing
# any prior copies with --clobber.
if ! gh release view "${TAG}" >/dev/null 2>&1; then
if [[ "${{ steps.ver.outputs.channel }}" == "nightly" ]]; then
# Nightly: a real prerelease, and deliberately NOT --latest —
# /releases/latest and the "Latest" badge must keep pointing at
# the newest STABLE release that install.sh + stable `hal0 update`
# depend on. The nightly channel is followed via releases.hal0.dev
# /nightly.json, not /releases/latest.
gh release create "${TAG}" \
--title "hal0 ${TAG}" \
--notes-file "${NOTES_FILE}" \
--draft=false \
--prerelease=true
else
# Stable: see hal0_v0.1.0-alpha-launch + hal0_release_prerelease_flag
# memories. Pre-v1.0 tags are published --prerelease=false --latest
# on purpose so /releases/latest works while we're pre-stable.
gh release create "${TAG}" \
--title "hal0 ${TAG}" \
--notes-file "${NOTES_FILE}" \
--draft=false \
--prerelease=false \
--latest
fi
fi
gh release upload "${TAG}" "${TARBALL}" "${SIG}" "${CRT}" "${MANIFEST}" --clobber
- name: Summary
run: |
TAG="${{ steps.ver.outputs.tag }}"
{
echo "## hal0 ${TAG} published"
echo ""
echo "| asset | sha256 |"
echo "|---|---|"
echo "| hal0-${{ steps.ver.outputs.version }}.tar.gz | \`${{ steps.tarball.outputs.digest }}\` |"
echo ""
echo "Manifest URL (until releases.hal0.dev exists): "
echo ""
echo " https://github.com/${{ github.repository }}/releases/download/${TAG}/${{ steps.ver.outputs.channel }}.json"
echo ""
echo "Verify locally:"
echo ""
echo '```'
echo "HAL0_RELEASES_URL='https://github.com/${{ github.repository }}/releases/download/${TAG}/${{ steps.ver.outputs.channel }}.json' \\"
echo " hal0 update --check"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"