Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/release-metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Release Metadata

on:
push:
branches: [main]
paths:
- "CHANGELOG.md"
- "RELEASING.md"
- "README.md"
- "Dockerfile"
- "scripts/checkout-runa-ref"
- "scripts/release-check"
- "scripts/release-cut"
- "scripts/test-release-check"
- "scripts/verify-release-adoption.sh"
- ".github/workflows/release.yml"
- ".github/workflows/release-metadata.yml"
pull_request:
branches: [main]
paths:
- "CHANGELOG.md"
- "RELEASING.md"
- "README.md"
- "Dockerfile"
- "scripts/checkout-runa-ref"
- "scripts/release-check"
- "scripts/release-cut"
- "scripts/test-release-check"
- "scripts/verify-release-adoption.sh"
- ".github/workflows/release.yml"
- ".github/workflows/release-metadata.yml"

permissions:
contents: read

jobs:
metadata:
name: Release metadata
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Check release metadata
run: ./scripts/release-check metadata
71 changes: 71 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: read

jobs:
publish:
name: Publish GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Validate release tag
run: ./scripts/release-check release "$GITHUB_REF_NAME"

- name: Require annotated tag
run: |
test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag

- name: Require tag target on main
run: |
set -euo pipefail
git fetch --force origin refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main

- name: Install container tooling
run: |
sudo apt-get update
sudo apt-get install -y podman

- name: Build base container image
run: |
podman build \
--build-arg "BASE_REF=$GITHUB_REF_NAME" \
--tag "localhost/base:$GITHUB_REF_NAME" \
.

- name: Verify release artifacts
run: |
./scripts/release-check release "$GITHUB_REF_NAME" \
--container-image "localhost/base:$GITHUB_REF_NAME"

- name: Extract release notes
run: ./scripts/release-check notes "$GITHUB_REF_NAME" > release-notes.md

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
release_flags=()
if [[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)-rc[.](0|[1-9][0-9]*)$ ]]; then
release_flags+=(--prerelease)
fi
gh release create "$GITHUB_REF_NAME" \
--title "base $GITHUB_REF_NAME" \
--notes-file release-notes.md \
--verify-tag \
"${release_flags[@]}"
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Release ceremony tooling now verifies the base changelog, Dockerfile label
surface, tag-time image identity, and GitHub Release publication path.

### Fixed

- `RUNA_REF` tag checkout now resolves SemVer-shaped values only through
explicit tag refs so homonymous branches cannot shadow release inputs.
- Release tag validation now rejects leading-zero numeric identifiers so base
release tags match the ecosystem SemVer grammar.
- GitHub Release publication now triggers for documented release tags and lets
`release-check` reject malformed `v*` tags before container work begins.
- Release tooling now checks out `RUNA_REF` values through the same tag-or-SHA
path that the Dockerfile uses, so verifier acceptance matches build
capability.
- `RUNA_REF` SHA checkout now rejects non-commit objects so container labels
cannot name an annotated tag object while building the tagged commit.
- Manual GitHub Release recovery guidance now preserves prerelease
classification for release candidate tags.
- `release-cut` now publishes the release commit and tag with an atomic push
and restores local state after publication failures so reruns do not require
manual cleanup.
- Image builds now expose OCI and Tesserine labels for the base ref, runa ref,
and Claude Code version so deployment contents can be inspected without
entering a container.
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ RUN apk add --no-cache \
rust-1.89

ARG RUNA_REF=v0.1.2-rc.1
RUN git clone --depth 1 --branch "${RUNA_REF}" \
https://github.com/tesserine/runa.git /build/runa \
COPY scripts/checkout-runa-ref /usr/local/bin/checkout-runa-ref
RUN checkout-runa-ref checkout "${RUNA_REF}" /build/runa \
&& cd /build/runa \
&& cargo build --release \
&& cp target/release/runa /build/runa-bin \
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ image build, and runtime contract can be verified together. The built image
exposes `org.tesserine.base.ref`, `org.tesserine.runa.ref`, and
`org.tesserine.claude-code.version` labels for deployment inspection.

Release operation is documented in [RELEASING.md](RELEASING.md). The release
tooling verifies the changelog, Dockerfile label surface, release workflows,
and tag-time image identity before publishing GitHub releases.

## Using with agentd

Reference this image in your agent configuration:
Expand Down
119 changes: 119 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Releasing base

Audience: the release operator cutting a base repository release or release
candidate. This document assumes access to the repository, GitHub, `gh`, and a
local container runtime compatible with Docker or Podman commands.

## Release Identity

base uses one repository tag for the source release and container image. The
tag is `vX.Y.Z` for stable releases and `vX.Y.Z-rc.N` for deployment release
candidates.

Release tags follow Semantic Versioning 2.0.0 numeric grammar: each integer is
either `0` or a non-zero digit followed by zero or more digits. The
ecosystem-wide codification is tracked in tesserine/commons#26.

Artifacts built from the tag must report that identity:

- The container image exposes `org.opencontainers.image.revision=<tag>`.
- The container image exposes `org.tesserine.base.ref=<tag>`.
- The container image exposes `org.tesserine.runa.ref=<immutable runa ref>`.

The runa ref must be an immutable tag or full commit SHA. base verifies that
the ref is present and immutable-shaped; ecosystem verification owns proving
that the runa ref matches the release manifest.

## Pre-Release Gate

A releasable commit is on `main`, up to date with `origin/main`, and has a
clean working tree. `--allow-dirty` is not part of the release path.

Before cutting a release:

```sh
git checkout main
git pull --ff-only
git status --short
./scripts/release-check metadata
```

For a final tag-time check against an image built from the release tag:

```sh
podman build \
--build-arg BASE_REF="vX.Y.Z" \
--tag "localhost/base:vX.Y.Z" \
.
./scripts/release-check release "vX.Y.Z" \
--container-image "localhost/base:vX.Y.Z"
```

Use `BASE_CONTAINER_RUNTIME=docker` when Docker should be used instead of
Podman.

## Atomic Release Operation

Stable releases and deployment release candidates use the same repo-owned
operation:

```sh
./scripts/release-cut "vX.Y.Z"
```

For release candidates:

```sh
./scripts/release-cut "vX.Y.Z-rc.N"
```

The command verifies the clean `main` precondition, rolls `CHANGELOG.md` from
`[Unreleased]` into `[X.Y.Z] — YYYY-MM-DD`, commits that release roll, creates
an annotated tag, and atomically pushes `main` plus the tag. If that
publication push fails, the command restores the local pre-release state and
removes the generated local tag so the release can be rerun after the cause is
fixed. Release candidates are immutable refs for deployment testing. A bad or
superseded candidate is
corrected by cutting the next `rc.N`, not by rewriting the existing tag.

## Post-Release Gate

The tag push runs `.github/workflows/release.yml`. That workflow verifies the
annotated tag, builds a local container image with `BASE_REF` set to the tag,
verifies image identity, extracts release notes from `CHANGELOG.md`, and
publishes the GitHub Release. Only `vX.Y.Z-rc.N` tags are published as GitHub
prereleases.

Manual GitHub Release creation, when needed after a workflow failure, uses the
same notes source and release classification.

```sh
./scripts/release-check notes "vX.Y.Z" > /tmp/base-release-notes.md
gh release create "vX.Y.Z" \
--title "base vX.Y.Z" \
--notes-file /tmp/base-release-notes.md \
--verify-tag
```

For release candidates:

```sh
./scripts/release-check notes "vX.Y.Z-rc.N" > /tmp/base-release-notes.md
gh release create "vX.Y.Z-rc.N" \
--title "base vX.Y.Z-rc.N" \
--notes-file /tmp/base-release-notes.md \
--verify-tag \
--prerelease
```

## Failure Modes

If a published tag points at source that violates release identity checks, the
tag is invalid. If it has no external consumers, delete it locally and
remotely and re-run the release operation. If it has external consumers, leave
the bad tag in the public record and cut the next version.

If the GitHub Release workflow fails after the tag is valid, repair the
workflow or environment and create the GitHub Release from
`scripts/release-check notes`. Do not edit release notes by hand unless the
changelog section is also corrected in source.
101 changes: 101 additions & 0 deletions scripts/checkout-runa-ref
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env sh
set -eu

default_runa_repo="https://github.com/tesserine/runa.git"

checkout_runa_ref_die() {
printf 'checkout-runa-ref: %s\n' "$*" >&2
exit 1
}

checkout_runa_ref_is_valid() {
checkout_runa_ref_ref="$1"

printf '%s\n' "$checkout_runa_ref_ref" \
| grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.(0|[1-9][0-9]*))?$|^[0-9a-f]{40}$'
}

checkout_runa_ref_is_sha() {
checkout_runa_ref_ref="$1"

printf '%s\n' "$checkout_runa_ref_ref" | grep -Eq '^[0-9a-f]{40}$'
}

checkout_runa_ref_require_commit_object() {
checkout_runa_ref_ref="$1"
checkout_runa_ref_dest="$2"

checkout_runa_ref_object_type="$(git -C "$checkout_runa_ref_dest" cat-file -t FETCH_HEAD)"
if [ "$checkout_runa_ref_object_type" = "commit" ]; then
return 0
fi

checkout_runa_ref_commit="$(git -C "$checkout_runa_ref_dest" rev-parse --verify -q 'FETCH_HEAD^{commit}' || true)"
if [ -n "$checkout_runa_ref_commit" ]; then
checkout_runa_ref_die "RUNA_REF resolves to a $checkout_runa_ref_object_type object, not a commit: $checkout_runa_ref_ref targets $checkout_runa_ref_commit"
fi

checkout_runa_ref_die "RUNA_REF resolves to a $checkout_runa_ref_object_type object, not a commit: $checkout_runa_ref_ref"
}

checkout_runa_ref_check() {
checkout_runa_ref_ref="$1"

checkout_runa_ref_is_valid "$checkout_runa_ref_ref" \
|| checkout_runa_ref_die "RUNA_REF must be an immutable tag or full commit SHA: $checkout_runa_ref_ref"
}

checkout_runa_ref_checkout() {
checkout_runa_ref_ref="$1"
checkout_runa_ref_dest="$2"
checkout_runa_ref_repo="${3:-$default_runa_repo}"

checkout_runa_ref_check "$checkout_runa_ref_ref"

if checkout_runa_ref_is_sha "$checkout_runa_ref_ref"; then
mkdir -p "$checkout_runa_ref_dest"
git -C "$checkout_runa_ref_dest" init -q
git -C "$checkout_runa_ref_dest" remote add origin "$checkout_runa_ref_repo"
git -C "$checkout_runa_ref_dest" fetch --depth 1 origin "$checkout_runa_ref_ref"
checkout_runa_ref_require_commit_object "$checkout_runa_ref_ref" "$checkout_runa_ref_dest"
git -C "$checkout_runa_ref_dest" checkout --detach FETCH_HEAD
checkout_runa_ref_head="$(git -C "$checkout_runa_ref_dest" rev-parse HEAD)"
[ "$checkout_runa_ref_head" = "$checkout_runa_ref_ref" ] \
|| checkout_runa_ref_die "RUNA_REF checkout produced $checkout_runa_ref_head, expected $checkout_runa_ref_ref"
else
mkdir -p "$checkout_runa_ref_dest"
git -C "$checkout_runa_ref_dest" init -q
git -C "$checkout_runa_ref_dest" remote add origin "$checkout_runa_ref_repo"
git -C "$checkout_runa_ref_dest" fetch --depth 1 origin "refs/tags/$checkout_runa_ref_ref:refs/tags/$checkout_runa_ref_ref" \
|| checkout_runa_ref_die "RUNA_REF tag does not exist: $checkout_runa_ref_ref"
git -C "$checkout_runa_ref_dest" checkout --detach "refs/tags/$checkout_runa_ref_ref"
fi
}

checkout_runa_ref_main() {
[ "$#" -ge 1 ] || checkout_runa_ref_die "usage: scripts/checkout-runa-ref check REF | checkout REF DEST [REPO_URL]"

case "$1" in
check)
[ "$#" -eq 2 ] || checkout_runa_ref_die "check requires exactly one REF"
checkout_runa_ref_check "$2"
;;
checkout)
if [ "$#" -ne 3 ] && [ "$#" -ne 4 ]; then
checkout_runa_ref_die "checkout requires REF DEST [REPO_URL]"
fi
shift
checkout_runa_ref_checkout "$@"
;;
-h|--help|help)
printf 'usage: scripts/checkout-runa-ref check REF | checkout REF DEST [REPO_URL]\n'
;;
*)
checkout_runa_ref_die "unknown command: $1"
;;
esac
}

if [ "${CHECKOUT_RUNA_REF_SOURCE_ONLY:-0}" != "1" ]; then
checkout_runa_ref_main "$@"
fi
Loading
Loading