-
-
Notifications
You must be signed in to change notification settings - Fork 89
396 lines (369 loc) · 21.5 KB
/
Copy pathrelease-selfhost.yml
File metadata and controls
396 lines (369 loc) · 21.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# Orb image releases (#980). Cutting an `orb-v<semver>` tag builds the multi-arch image, pushes it
# to GHCR with version + latest + sha tags (with provenance + SBOM), and opens a GitHub Release.
#
# git tag orb-v0.1.0 && git push origin orb-v0.1.0
#
# Pull: docker pull ghcr.io/<owner>/loopover-selfhost:orb-v0.1.0
#
# #4777: publishing under the pre-rename "gittensory-selfhost" name stopped -- see the "Image metadata"
# step below. Already-published tags/digests under that name are untouched and keep resolving forever
# (GHCR has no server-side alias); only new pushes moved to "loopover-selfhost" exclusively.
#
# Prerelease tags (#1937): orb-v0.1.0-rc.1 / orb-v0.1.0-beta.1 run the identical pipeline but never move
# `latest` and are marked prerelease on the GitHub Release -- for beta-testing an image before it becomes
# the stable/latest recommendation.
#
# git tag orb-v0.1.0-rc.1 && git push origin orb-v0.1.0-rc.1
name: release-orb
on:
push:
tags:
- "orb-v*"
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.1.0, or a prerelease 0.1.0-rc.1 / 0.1.0-beta.1)"
required: true
create_github_release:
description: "Also create/update the GitHub Release for this tag (the tag must already exist and be pushed). Used by the automated orb-beta-release dispatch; leave unchecked for an ad-hoc image rebuild."
type: boolean
default: false
permissions:
contents: write # create the GitHub Release
packages: write # push to GHCR
concurrency:
group: release-orb-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 40
# Environment gate. Only the trusted orb-beta-release automation may use `release-beta` (no
# required reviewers): it dispatches this workflow as github-actions[bot] on a tag ref, asks to
# create the GitHub Release, and the validation below requires that exact beta tag to already point
# at this commit. Human workflow_dispatch runs, branch dispatches, and direct beta tag pushes stay on
# `release`, which requires reviewer approval under repo Settings > Environments. This expression
# reads only event fields because a job's `environment:` is resolved before any step runs.
environment: ${{ github.event_name == 'workflow_dispatch' && github.actor == 'github-actions[bot]' && github.ref_type == 'tag' && inputs.create_github_release && contains(inputs.version, '-beta.') && 'release-beta' || 'release' }}
env:
POSTHOG_CLI_PACKAGE: "@posthog/cli@0.9.1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Verify release commit is on main
env:
RELEASE_SHA: ${{ github.sha }}
run: |
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then
echo "::error::Self-host releases must be cut from a commit reachable from main."
exit 1
fi
- name: Resolve version
id: version
env:
CREATE_GITHUB_RELEASE: ${{ github.event.inputs.create_github_release || 'false' }}
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ github.event.inputs.version }}
REF_NAME: ${{ github.ref_name }}
RELEASE_SHA: ${{ github.sha }}
RUN_ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
VERSION="$INPUT_VERSION"
else
case "$REF_NAME" in
orb-v*) VERSION="${REF_NAME#orb-v}" ;;
*) echo "expected an orb-v<semver> tag, got $REF_NAME" >&2; exit 1 ;;
esac
fi
# #1937: a stable X.Y.Z tag is the only kind that ever moved `latest` or an unmarked GitHub
# Release; a prerelease tag (X.Y.Z-rc.N / X.Y.Z-beta.N) publishes the SAME image/provenance/SBOM/
# PostHog pipeline below, just flagged as prerelease and never pushed under `latest` (see the
# "Resolve image tags" and "GitHub Release" steps).
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta)\.[0-9]+)?$'; then
echo "expected semver version X.Y.Z, or a prerelease X.Y.Z-rc.N / X.Y.Z-beta.N, got $VERSION" >&2
exit 1
fi
PRERELEASE=false
if printf '%s' "$VERSION" | grep -Eq -- '-(rc|beta)\.[0-9]+$'; then
PRERELEASE=true
fi
if printf '%s' "$VERSION" | grep -Eq -- '-beta\.[0-9]+$' \
&& [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ "$RUN_ACTOR" = "github-actions[bot]" ] \
&& [ "$CREATE_GITHUB_RELEASE" = "true" ]; then
git fetch --force --tags origin "refs/tags/orb-v${VERSION}:refs/tags/orb-v${VERSION}"
TAG_SHA="$(git rev-list -n 1 "refs/tags/orb-v${VERSION}")"
if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then
echo "automated beta releases must dispatch the tag that was just created for this commit" >&2
exit 1
fi
fi
{
echo "v=${VERSION}"
echo "tag=orb-v${VERSION}"
echo "release=loopover-orb@${VERSION}"
echo "prerelease=${PRERELEASE}"
} >> "$GITHUB_OUTPUT"
# Release jobs receive publishing/PostHog credentials, so avoid shared dependency caches here.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc
- name: Install deps
run: npm ci --ignore-scripts
# #ci-engine-build-order (release pipeline): --all bundles EVERYTHING self-contained, including
# packages/loopover-miner/lib/*.js (committed, pre-built) which imports @loopover/engine.
# That package's dist/ is gitignored and only exists after this build step -- the regular CI smoke
# test's narrower (non --all) build never hits this import chain, so it never caught the gap that
# ci.yml's own validate-code job hit for ordinary backend PRs (fixed there separately).
# Built before the engine for the same reason the Dockerfile does: src/'s import graph reaches
# @loopover/contract, whose package exports resolve to dist/, so anything type-checking or
# bundling src/ needs it emitted first. Zod-only leaf, no workspace dependencies of its own.
- name: Build contract package
run: npm run build --workspace @loopover/contract
- name: Build engine package
run: npm run build --workspace @loopover/engine
- name: Build self-host bundle for release
run: node --experimental-strip-types scripts/build-selfhost.ts --all
- name: Validate release source map
run: node --experimental-strip-types scripts/validate-selfhost-sourcemap.ts
- name: Detect PostHog release token
id: posthog
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
run: |
if [ -n "$POSTHOG_CLI_API_KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Require PostHog token for official release
if: github.repository == 'JSONbored/loopover' && steps.posthog.outputs.enabled != 'true'
run: |
echo "::error::Configure POSTHOG_CLI_API_KEY (a personal API key) in the release environment before publishing official Orb images."
exit 1
- name: Upload PostHog source maps
if: steps.posthog.outputs.enabled == 'true'
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ secrets.POSTHOG_CLI_PROJECT_ID }}
POSTHOG_CLI_HOST: ${{ secrets.POSTHOG_CLI_HOST }}
# Passed as separate --release-name/--release-version flags below rather than one combined
# --release-version string: posthog-cli otherwise auto-derives its own release-name from git/
# package.json (this repo resolves to "loopover") and prepends it, so an unqualified
# --release-version "loopover-orb@$VERSION" silently becomes the stored release
# "loopover@loopover-orb@$VERSION" -- never matching what "Validate PostHog release" below looks up.
POSTHOG_RELEASE_NAME: loopover-orb
POSTHOG_RELEASE_VERSION: ${{ steps.version.outputs.v }}
run: |
set -euo pipefail
test -n "$POSTHOG_CLI_API_KEY"
test -n "$POSTHOG_CLI_PROJECT_ID"
# posthog-cli treats a PRESENT-but-empty POSTHOG_CLI_HOST as an explicit (invalid) URL, not as
# "unset, use the default" -- unset it outright when blank, same guard this pipeline's old
# Sentry-org-URL override once needed for the identical reason.
if [ -z "${POSTHOG_CLI_HOST:-}" ]; then unset POSTHOG_CLI_HOST; fi
# No separate "create release" step -- PostHog release metadata is a byproduct of the inject/
# upload calls below, unlike Sentry's releases/commits/deploys/finalize lifecycle this replaces.
npx -y "$POSTHOG_CLI_PACKAGE" sourcemap inject --directory dist --release-name "$POSTHOG_RELEASE_NAME" --release-version "$POSTHOG_RELEASE_VERSION"
node --experimental-strip-types scripts/validate-selfhost-sourcemap.ts
npx -y "$POSTHOG_CLI_PACKAGE" sourcemap upload --directory dist --release-name "$POSTHOG_RELEASE_NAME" --release-version "$POSTHOG_RELEASE_VERSION"
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to GHCR
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# #1937: `latest` must never move to a prerelease build -- an operator who blindly pulls `latest`
# for a trial should always land on the newest STABLE image, not an in-flight rc/beta.
- name: Resolve image tags
id: tags
env:
PRERELEASE: ${{ steps.version.outputs.prerelease }}
VERSION_TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
{
echo "list<<GTORBTAGS"
echo "type=raw,value=${VERSION_TAG}"
if [ "$PRERELEASE" != "true" ]; then
echo "type=raw,value=latest"
fi
echo "type=sha,format=short"
echo "GTORBTAGS"
} >> "$GITHUB_OUTPUT"
# #4777: publishes only under the "loopover-selfhost" name now. The pre-rename "gittensory-selfhost"
# name is no longer pushed to; GHCR has no server-side alias, so its already-published exact-version
# and digest pins keep resolving forever regardless -- this only stops new tags from landing there.
- name: Image metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
ghcr.io/${{ github.repository_owner }}/loopover-selfhost
tags: ${{ steps.tags.outputs.list }}
labels: |
org.opencontainers.image.title=loopover-orb
org.opencontainers.image.description=Self-hostable LoopOver review engine
org.opencontainers.image.version=${{ steps.version.outputs.tag }}
org.opencontainers.image.revision=${{ github.sha }}
- name: Build + push (linux/amd64 + linux/arm64)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: runtime-prebuilt
platforms: linux/amd64,linux/arm64
push: true
# Visual-capture convergence (#3607/#4111): every official image ships puppeteer-core so a
# self-hoster can turn on before/after screenshot capture with just BROWSER_WS_ENDPOINT +
# LOOPOVER_REVIEW_SCREENSHOTS=true at runtime -- no custom image build required. Inert either
# way until those runtime flags are set (Dockerfile's INSTALL_VISUAL_REVIEW only controls
# whether the dependency is installed, never whether the feature runs).
build-args: |
LOOPOVER_VERSION=${{ steps.version.outputs.release }}
INSTALL_VISUAL_REVIEW=true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true
# #2502 originally shared selfhost.yml's default (unscoped) GHA cache bucket here for speed --
# reverted once a security scan correctly flagged it: that bucket is written to by regular CI on
# every push/PR to this repo, so a publish/release path reading from it is a cache-poisoning
# vector into an officially published, public image. `scope=release-orb` fixes this properly
# instead of just going cold forever: it's a completely separate GHA cache namespace that ONLY
# this workflow (tag pushes on `main` / `workflow_dispatch`, both already gated by "Verify release
# commit is on main" + the `release` environment) ever reads from or writes to -- selfhost.yml's
# CI never touches it. The first release still builds cold; every release after that (rc.2, a
# later stable, a patch) reuses the arm64/QEMU-emulated apt-get/npm-install layers from the PRIOR
# release build in this same isolated scope, with zero exposure to anything a regular CI run ever
# produced.
cache-from: type=gha,scope=release-orb
cache-to: type=gha,mode=max,scope=release-orb
- name: Validate PostHog release
if: steps.posthog.outputs.enabled == 'true'
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ secrets.POSTHOG_CLI_PROJECT_ID }}
POSTHOG_CLI_HOST: ${{ secrets.POSTHOG_CLI_HOST }}
POSTHOG_RELEASE: ${{ steps.version.outputs.release }}
run: |
set -euo pipefail
if [ -z "${POSTHOG_CLI_HOST:-}" ]; then unset POSTHOG_CLI_HOST; fi
# PostHog's release-read path can lag briefly behind the upload write above.
# review-enrichment/src/upload-sourcemaps.ts's runReleaseValidation() already retry-polls this
# same script for REES's own deploy path -- mirror that here instead of failing the whole
# release build on a transient read-lag.
attempts=5
delay_secs=10
for attempt in $(seq 1 "$attempts"); do
if node --experimental-strip-types review-enrichment/scripts/validate-posthog-release.ts; then
exit 0
fi
if [ "$attempt" -lt "$attempts" ]; then
echo "::warning::PostHog release validation attempt ${attempt}/${attempts} failed; retrying in ${delay_secs}s"
sleep "$delay_secs"
fi
done
echo "::error::PostHog release validation failed after ${attempts} attempts"
exit 1
- name: GitHub Release
# A dispatch-triggered run only reaches here when the caller explicitly opted in (used by the
# automated orb-beta-release dispatch, which pushes the tag itself before dispatching --
# `--verify-tag` below needs the tag to already exist). A plain manual dispatch (image rebuild,
# flag left off) skips this step, same as before.
if: github.event_name == 'push' || inputs.create_github_release == true
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ steps.version.outputs.v }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_ID: ${{ steps.version.outputs.release }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
run: |
set -euo pipefail
# GHCR (like every Docker registry) rejects a mixed-case repository path client-side --
# `docker pull ghcr.io/JSONbored/...` fails with "must be lowercase" before it even reaches the
# registry. `github.repository_owner` preserves the org's actual casing ("JSONbored"), so the
# notes must lowercase it themselves -- docker/metadata-action (used for the actual image tags
# above) does this automatically, but this hand-written notes block doesn't go through it.
REPOSITORY_OWNER_LOWER="${REPOSITORY_OWNER,,}"
NOTES="$(cat <<EOF
LoopOver Orb container image:
\`\`\`bash
docker pull ghcr.io/${REPOSITORY_OWNER_LOWER}/loopover-selfhost:${RELEASE_TAG}
\`\`\`
Multi-arch (linux/amd64 + linux/arm64). See https://loopover.ai/docs/maintainer-self-hosting for setup.
Includes the Claude Code / Codex subscription CLIs by default; credentials stay runtime-only.
PostHog release id baked into the image: \`${RELEASE_ID}\`.
EOF
)"
# #1937: a prerelease tag never becomes the repo's "Latest release" and is visibly marked as such
# on GitHub -- distinct from the image-tag `latest` decision above (Resolve image tags), which
# this flag also drives at the version-resolution step.
PRERELEASE_ARGS=()
if [ "$PRERELEASE" = "true" ]; then
PRERELEASE_ARGS=(--prerelease --latest=false)
fi
# Find the orb-v tag immediately preceding this one, by the best available local tag-date
# ordering (`--sort=-creatordate`: the tag's own date for an annotated tag, or its tagged
# commit's date for a lightweight one) -- NOT a semver sort, which mis-orders a prerelease
# against its own later stable tag (e.g. 0.1.0-beta.2 vs 0.1.0), and NOT the Release Notes
# API's own "previous release" auto-detection either (this repo's release list also carries
# mcp-v* releases on an independent cadence, which could get picked up by mistake once the two
# schemes' timestamps interleave). This is NOT a guarantee of true creation order for two
# lightweight orb-v tags pointing at the SAME commit (e.g. promoting an -rc straight to stable
# with no new commits) -- that tie is broken by git's own secondary sort, not by which tag was
# actually pushed first. Not a correctness problem in practice: the changelog for that specific
# release would just come out emptier than expected, never wrong or release-blocking. Excludes
# the tag being released so a workflow re-run never diffs a tag against itself.
PREV_TAG=$(git tag -l 'orb-v*' --sort=-creatordate | grep -vF -x "$RELEASE_TAG" | head -1 || true)
# The very first orb release here hit GitHub's 125000-character release-body limit
# generating notes across the ENTIRE repo history, because no prior orb-v tag existed yet to
# bound the diff -- that's why `--generate-notes` was dropped entirely rather than just
# pinning its range. Every release from orb-v0.1.0 onward has a real previous tag (confirmed:
# orb-v0.1.0 -> orb-v0.2.0 generates ~11K chars, comfortably under the limit), so fetch the
# PR changelog explicitly scoped to that range via the same API `--generate-notes` uses,
# categorized by `.github/release.yml`. PREV_TAG empty (no prior orb-v tag) skips this and
# falls back to the plain notes below, the same as the original safe behavior.
CHANGELOG=""
if [ -n "$PREV_TAG" ]; then
if ! CHANGELOG=$(gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$RELEASE_TAG" \
-f previous_tag_name="$PREV_TAG" \
--jq '.body' 2>/dev/null); then
# Fails open below (plain notes, no changelog) either way -- this warning just tells an
# operator reading the run log that the section is missing because the API call itself
# failed, not because the range between PREV_TAG and this release genuinely had no PRs.
echo "::warning::Fetching the release changelog (${PREV_TAG}...${RELEASE_TAG}) failed; publishing without it."
CHANGELOG=""
fi
fi
FULL_NOTES="$NOTES"
if [ -n "$CHANGELOG" ]; then
FULL_NOTES="${NOTES}"$'\n\n'"${CHANGELOG}"
fi
# Never let an outlier changelog (an unusually large PR history, or a future change to what
# the categorization API returns) block publishing the image itself -- fall back to the plain
# pull-command notes plus a compare link if the combined body would exceed GitHub's limit.
if [ "${#FULL_NOTES}" -gt 120000 ]; then
echo "::warning::Generated release notes would be ${#FULL_NOTES} chars, near GitHub's 125000 release-body limit -- falling back to the plain notes without the changelog."
FULL_NOTES="${NOTES}"$'\n\n'"_Changelog omitted (too large for a GitHub Release body) -- see https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...${RELEASE_TAG}_"
fi
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--title "loopover-orb ${RELEASE_TAG}" \
--notes "$FULL_NOTES" \
"${PRERELEASE_ARGS[@]}"
else
gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "loopover-orb ${RELEASE_TAG}" \
"${PRERELEASE_ARGS[@]}" \
--notes "$FULL_NOTES"
fi