chore(release): bump version to 0.8.2b3 (#978) #29
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |