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
151 changes: 151 additions & 0 deletions .github/workflows/accept-release-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
name: accept-release-candidate

on:
workflow_dispatch:
inputs:
candidate_tag:
description: Exact stage prerelease tag, for example stage-rc-0123456789ab
required: true
type: string
accept:
description: I installed and tested this exact release candidate and approve production promotion
required: true
default: false
type: boolean

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

concurrency:
group: accept-release-candidate-${{ inputs.candidate_tag }}
cancel-in-progress: false

jobs:
accept:
name: record release candidate acceptance
if: inputs.accept == true
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Verify published stage prerelease
id: candidate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CANDIDATE_TAG: ${{ inputs.candidate_tag }}
shell: bash
run: |
if [[ ! "$CANDIDATE_TAG" =~ ^stage-rc-[0-9a-f]{12}$ ]]; then
echo "Candidate tag must use stage-rc-<12 lowercase hex commit characters>."
exit 1
fi

git fetch --no-tags origin "+refs/heads/stage:refs/remotes/origin/stage"
git fetch origin "refs/tags/${CANDIDATE_TAG}:refs/tags/${CANDIDATE_TAG}"
candidate_sha="$(git rev-list -n 1 "$CANDIDATE_TAG")"
expected_tag="stage-rc-${candidate_sha:0:12}"
if [[ "$CANDIDATE_TAG" != "$expected_tag" ]]; then
echo "Candidate tag suffix does not match its exact commit."
exit 1
fi
if ! git merge-base --is-ancestor "$candidate_sha" origin/stage; then
echo "Candidate commit is not part of the protected stage history."
exit 1
fi

release_json="$(gh release view "$CANDIDATE_TAG" --json isDraft,isPrerelease,tagName,targetCommitish,assets)"
node -e '
const release = JSON.parse(process.argv[1]);
const expected = process.argv[2];
if (release.isDraft || !release.isPrerelease || release.tagName !== expected) {
throw new Error("Candidate must be a published GitHub prerelease");
}
const names = release.assets.map((asset) => asset.name).sort();
const short = expected.slice("stage-rc-".length);
const required = [
`role-model-stage-${short}-linux-x64.tar.gz`,
`role-model-stage-${short}-darwin-x64.tar.gz`,
`role-model-stage-${short}-darwin-arm64.tar.gz`,
`role-model-stage-${short}-win32-x64.zip`,
"SHA256SUMS.txt",
].sort();
if (JSON.stringify(names) !== JSON.stringify(required)) {
throw new Error(`Candidate asset set mismatch: ${JSON.stringify(names)}`);
}
' "$release_json" "$CANDIDATE_TAG"

assets="$RUNNER_TEMP/release-candidate-assets"
mkdir -p "$assets"
gh release download "$CANDIDATE_TAG" --dir "$assets"
(cd "$assets" && sha256sum -c SHA256SUMS.txt)

echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "candidate_tag=$CANDIDATE_TAG" >> "$GITHUB_OUTPUT"

- name: Verify packaged stage identities
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.candidate_sha }}
CANDIDATE_TAG: ${{ steps.candidate.outputs.candidate_tag }}
shell: bash
run: |
assets="$RUNNER_TEMP/release-candidate-assets"
extracted="$RUNNER_TEMP/release-candidate-extracted"
mkdir -p "$extracted"
for archive in "$assets"/*.tar.gz "$assets"/*.zip; do
target="$(basename "$archive")"
target="${target%.tar.gz}"
target="${target%.zip}"
mkdir -p "$extracted/$target"
if [[ "$archive" == *.zip ]]; then
unzip -q "$archive" -d "$extracted/$target"
else
tar -xzf "$archive" -C "$extracted/$target"
fi
manifest="$extracted/$target/manifest.json"
test -f "$manifest"
node -e '
const manifest = require(process.argv[1]);
const candidateSha = process.argv[2];
if (
manifest.channel !== "stage" ||
manifest.name !== "role-model-stage" ||
manifest.endpoint !== "http://127.0.0.1:3457" ||
manifest.commit !== candidateSha ||
manifest.track_b_runtime?.extension_count !== 13
) {
throw new Error(`Stage manifest identity mismatch: ${process.argv[1]}`);
}
' "$manifest" "$CANDIDATE_SHA"
done

- name: Create immutable acceptance receipt
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.candidate_sha }}
CANDIDATE_TAG: ${{ steps.candidate.outputs.candidate_tag }}
shell: bash
run: |
approval_tag="rc-approved/$CANDIDATE_SHA"
remote="$(git ls-remote --tags origin "refs/tags/$approval_tag" | awk '{print $1}')"
if [[ -n "$remote" ]]; then
if [[ "$remote" != "$CANDIDATE_SHA" ]]; then
echo "Existing acceptance receipt points to a different commit."
exit 1
fi
echo "Acceptance receipt already exists for $CANDIDATE_TAG."
else
git tag "$approval_tag" "$CANDIDATE_SHA"
git push origin "refs/tags/$approval_tag"
fi
{
echo "### Accepted release candidate"
echo "- Candidate: \`$CANDIDATE_TAG\`"
echo "- Commit: \`$CANDIDATE_SHA\`"
echo "- Receipt: \`$approval_tag\`"
echo "- Approved by: \`$GITHUB_ACTOR\`"
echo "- The stable release workflow will accept only this exact stage candidate."
} >> "$GITHUB_STEP_SUMMARY"
130 changes: 130 additions & 0 deletions .github/workflows/build-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Validate stable production tag
if: github.ref_type == 'tag'
shell: bash
run: |
if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Stable release tag must be exact SemVer (vMAJOR.MINOR.PATCH)."
exit 1
fi
git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "Production tag must point to a commit promoted through main."
exit 1
fi

- name: Resolve public source tree
id: public_source
shell: bash
Expand Down Expand Up @@ -128,6 +142,35 @@ jobs:
}
}
if (-not $artifact) { throw "No tested stage candidate found for $artifactName" }
$stageSha = $artifact.workflow_run.head_sha
$candidateTag = "stage-rc-$($stageSha.Substring(0, 12))"
$approvalRefUrl = "https://api.github.com/repos/$env:GITHUB_REPOSITORY/git/ref/tags/rc-approved/$stageSha"
try {
$approval = Invoke-RestMethod -Headers $headers -Uri $approvalRefUrl
} catch {
throw "Tested stage candidate $stageSha has no explicit rc-approved acceptance receipt"
}
if ($approval.object.type -ne "commit" -or $approval.object.sha -ne $stageSha) {
throw "Release candidate acceptance receipt does not point to the tested stage commit"
}
$candidateReleaseUrl = "https://api.github.com/repos/$env:GITHUB_REPOSITORY/releases/tags/$candidateTag"
try {
$candidateRelease = Invoke-RestMethod -Headers $headers -Uri $candidateReleaseUrl
} catch {
throw "Accepted stage candidate has no published prerelease: $candidateTag"
}
if ($candidateRelease.draft -or -not $candidateRelease.prerelease -or $candidateRelease.tag_name -ne $candidateTag) {
throw "Accepted stage candidate release is not a published prerelease"
}
$candidateRefUrl = "https://api.github.com/repos/$env:GITHUB_REPOSITORY/git/ref/tags/$candidateTag"
try {
$candidateRef = Invoke-RestMethod -Headers $headers -Uri $candidateRefUrl
} catch {
throw "Published stage prerelease tag cannot be resolved: $candidateTag"
}
if ($candidateRef.object.type -ne "commit" -or $candidateRef.object.sha -ne $stageSha) {
throw "Published stage prerelease tag does not point to the tested stage commit"
}
$outerZip = Join-Path $env:RUNNER_TEMP "stage-candidate.zip"
$outerDir = Join-Path $env:RUNNER_TEMP "stage-candidate"
$innerDir = Join-Path $env:RUNNER_TEMP "stage-package"
Expand Down Expand Up @@ -156,6 +199,7 @@ jobs:
"stage_manifest_path=$stageManifestPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"release_id=$($stage.release_id)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"private_source_commit=$($stage.private_source_commit)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"stage_sha=$stageSha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append

- name: Select exact paired release identity
id: release_identity
Expand Down Expand Up @@ -442,6 +486,92 @@ jobs:
role-model-router/dist/**
role-model-router/vendor/llama-swap/dist-assets/**

publish-stage-prerelease:
name: publish stage prerelease
if: github.event_name == 'push' && github.ref == 'refs/heads/stage'
needs: build-runtime
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- name: Download stage archives
uses: actions/download-artifact@v4
with:
pattern: role-model-stage-candidate-*
path: stage-release-assets
merge-multiple: true

- name: Verify complete stage archive set
shell: bash
run: |
short_sha="${GITHUB_SHA:0:12}"
expected=(
"role-model-stage-${short_sha}-linux-x64.tar.gz"
"role-model-stage-${short_sha}-darwin-x64.tar.gz"
"role-model-stage-${short_sha}-darwin-arm64.tar.gz"
"role-model-stage-${short_sha}-win32-x64.zip"
)
for archive in "${expected[@]}"; do
test -f "stage-release-assets/$archive" || {
echo "Missing stage prerelease archive: $archive"
exit 1
}
done
archive_count="$(find stage-release-assets -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) | wc -l | tr -d ' ')"
if [[ "$archive_count" != "4" ]]; then
echo "Stage prerelease must contain exactly four platform archives."
exit 1
fi

- name: Write stage checksum manifest
shell: bash
run: |
cd stage-release-assets
sha256sum * > SHA256SUMS.txt

- name: Create or update stage prerelease
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
short_sha="${GITHUB_SHA:0:12}"
candidate_tag="stage-rc-${short_sha}"
cat > stage-release-notes.md <<EOF
# role-model stage release candidate ${short_sha}

This is a **prerelease for human acceptance testing**, built from the exact public stage commit \`${GITHUB_SHA}\` and its paired private stage revision.

It runs as \`role-model-stage\` on \`http://127.0.0.1:3457\` with isolated stage state. It is not a production release and is not selected by the stable installers.

1. Download the archive for your platform.
2. Verify it against \`SHA256SUMS.txt\`.
3. Extract and run the stage launcher/runtime.
4. Exercise the intended behavior and persistent state on port 3457.
5. If accepted, run the **accept-release-candidate** workflow for \`${candidate_tag}\` before promoting stage to main or creating a stable tag.
EOF
if gh release view "$candidate_tag" >/dev/null 2>&1; then
git fetch origin "refs/tags/${candidate_tag}:refs/tags/${candidate_tag}"
existing_sha="$(git rev-list -n 1 "$candidate_tag")"
if [[ "$existing_sha" != "$GITHUB_SHA" ]]; then
echo "Existing stage prerelease tag points to a different commit."
exit 1
fi
gh release upload "$candidate_tag" stage-release-assets/* --clobber
gh release edit "$candidate_tag" \
--title "Stage RC ${short_sha}" \
--notes-file stage-release-notes.md \
--prerelease
else
gh release create "$candidate_tag" stage-release-assets/* \
--target "$GITHUB_SHA" \
--title "Stage RC ${short_sha}" \
--notes-file stage-release-notes.md \
--prerelease
fi

publish-release:
name: publish release
if: github.ref_type == 'tag'
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ If you do not want to use installer scripts, download the matching archive from
| macOS arm64 | `role-model-darwin-arm64.tar.gz` | `role-model` |
| Linux x64 | `role-model-linux-x64.tar.gz` | `role-model` |

### Test a release candidate

Stage builds are published separately as GitHub **prereleases** named `stage-rc-<stage-sha>`. They run as
`role-model-stage` on `http://127.0.0.1:3457` and use isolated stage state, so they can be tested beside the stable
runtime on port `3456`.

Download the candidate archive and `SHA256SUMS.txt` from its prerelease page, verify the checksum, extract it, and run
the stage launcher. Prereleases are never selected by the normal installer. A candidate is promoted to `main` and a
stable `vMAJOR.MINOR.PATCH` release only after a maintainer explicitly records that the exact package was installed
and tested.

### Update an installed runtime

Updates are currently manual. Stop the running runtime, back up its persistent state, and then re-run the
Expand Down
26 changes: 21 additions & 5 deletions docs/operations/02-ci-and-release-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

1. Ordinary `feature/*`, `fix/*`, dependency, and `recursive/*` pull requests target `dev` and normally squash merge.
2. A maintainer promotes a tested integration with a merge-commit PR from `dev` to `stage`.
3. A maintainer promotes a tested stage candidate with a merge-commit PR from `stage` to `main`.
3. The `stage` push publishes a GitHub prerelease named `stage-rc-<12-character-stage-sha>`. A human installs and
tests that exact package on the isolated stage channel, then explicitly accepts it with the
`accept-release-candidate` workflow.
4. Only after that acceptance does a maintainer promote the exact stage candidate with a merge-commit PR from
`stage` to `main` and create the stable version tag.

`main` is production truth. A production-only emergency starts as `hotfix/*` from `main`, requires review, and is
forwarded to `stage` and `dev` after merge. Never force-push a promotion branch to resynchronize it; use reviewed
Expand Down Expand Up @@ -36,7 +40,18 @@ Reviewed `hotfix/* -> main` is the explicit emergency exception.
## Runtime build channels

`.github/workflows/build-binaries.yml` builds the full matrix for stage candidates and production tags. Development
packages are available by explicit manual dispatch. Only a `v*` tag publishes a stable GitHub Release.
packages are available by explicit manual dispatch. Every successful `stage` push publishes a GitHub prerelease with
all four stage-channel archives and `SHA256SUMS.txt`; prereleases are never selected by the stable installers.

After testing, a maintainer runs `.github/workflows/accept-release-candidate.yml` with the exact prerelease tag and
checks the explicit acceptance input. That workflow re-downloads the candidate, validates all checksums and stage
manifests, and creates the immutable `rc-approved/<full-stage-sha>` receipt. Do not approve a candidate based only on
green CI: install it, exercise the intended user paths on port `3457`, restart it against its stage state, and inspect
the behavior that motivated the release.

Only an exact stable SemVer tag (`vMAJOR.MINOR.PATCH`) publishes a production GitHub Release. Production packaging
fails closed unless its matching successful stage artifact also has both the published stage prerelease and the
explicit acceptance receipt. The stable tag must point to a commit promoted through `main`.

| Channel | Identity | Endpoint | State root | Scope |
| --- | --- | --- | --- | --- |
Expand All @@ -46,9 +61,10 @@ packages are available by explicit manual dispatch. Only a `v*` tag publishes a

Manifests report channel, endpoint, commit, source tree, executable SHA-256, channel-neutral core payload SHA-256,
the exact private source commit, Run 88 release identity, private distribution manifest and sidecar digests, and the
canonical extension count. A production tag must find the matching successful stage candidate, rebuild its exact
private commit, verify that the artifact came from a successful push build of public `stage`, verify that the private
commit has subsequently passed through private `main`, and prove the complete public/private pair is identical.
canonical extension count. A production tag must find the matching successful and explicitly accepted stage
candidate, rebuild its exact private commit, verify that the artifact came from a successful push build of public
`stage`, verify that the private commit has subsequently passed through private `main`, and prove the complete
public/private pair is identical.
Source-tree or core-payload equality alone is insufficient.

Run 88's `v0.0.8` release-toolchain recovery and the exact stage/production payload diagnosis are recorded in
Expand Down
Loading
Loading