From 7245db3feea5f49222cd186ab5a3be7d14db2660 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:54:41 -0700 Subject: [PATCH] ci(release): support prerelease self-host image tags The release-orb workflow accepted strict X.Y.Z semver only, while a beta/RC posture needs a prerelease image tag (orb-v0.1.0-rc.1 / orb-v0.1.0-beta.1) rather than an overclaiming stable orb-v0.1.0. Widened the "Resolve version" step's regex to accept an optional -rc.N / -beta.N suffix and compute a new `prerelease` output. Two downstream steps consume it: - "Resolve image tags" (new): builds the docker/metadata-action tags list in bash instead of a static multi-line literal, so `latest` is only included for a stable version -- a prerelease build is pushed under its own version tag + sha only, never latest. - "GitHub Release": passes --prerelease --latest=false to gh release create/edit only when PRERELEASE=true, so the release is visibly marked prerelease and never becomes the repo's "Latest release". Stable release behavior (both flags omitted) is unchanged. Documented the prerelease tag policy on the self-hosting-releases docs page: which tag beta testers should pull, and that latest/GitHub Release marking stays untouched for stable tags. 15 new tests execute the ACTUAL bash extracted from the committed workflow YAML against a real GITHUB_OUTPUT file (not a re-derived copy), covering stable/rc/beta/workflow_dispatch acceptance, rejection of malformed versions and unsupported prerelease kinds (e.g. -alpha), and the tags/GitHub-Release steps' prerelease branching. Updated one pre-existing assertion in selfhost-sentry-release.test.ts that string-matched the old inline tags literal. Closes #1937 --- .github/workflows/release-selfhost.yml | 57 ++++++-- .../src/routes/docs.self-hosting-releases.tsx | 18 ++- test/unit/release-selfhost-prerelease.test.ts | 136 ++++++++++++++++++ test/unit/selfhost-sentry-release.test.ts | 8 +- 4 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 test/unit/release-selfhost-prerelease.test.ts diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index 38698be7d1..fc721b43c6 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -4,6 +4,12 @@ # git tag orb-v0.1.0 && git push origin orb-v0.1.0 # # Pull: docker pull ghcr.io//gittensory-selfhost:orb-v0.1.0 +# +# 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: @@ -13,7 +19,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to publish (e.g. 0.1.0)" + description: "Version to publish (e.g. 0.1.0, or a prerelease 0.1.0-rc.1 / 0.1.0-beta.1)" required: true permissions: @@ -66,14 +72,23 @@ jobs: *) echo "expected an orb-v tag, got $REF_NAME" >&2; exit 1 ;; esac fi - if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "expected semver version X.Y.Z, got $VERSION" >&2 + # #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/ + # Sentry 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 { echo "v=${VERSION}" echo "tag=orb-v${VERSION}" echo "release=gittensory-orb@${VERSION}" + echo "prerelease=${PRERELEASE}" } >> "$GITHUB_OUTPUT" # Release jobs receive publishing/Sentry credentials, so avoid shared dependency caches here. @@ -139,15 +154,31 @@ jobs: 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<> "$GITHUB_OUTPUT" + - name: Image metadata id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: ghcr.io/${{ github.repository_owner }}/gittensory-selfhost - tags: | - type=raw,value=${{ steps.version.outputs.tag }} - type=raw,value=latest - type=sha,format=short + tags: ${{ steps.tags.outputs.list }} labels: | org.opencontainers.image.title=gittensory-orb org.opencontainers.image.description=Self-hostable Gittensory review engine @@ -210,6 +241,7 @@ jobs: 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 NOTES="$(cat </dev/null 2>&1; then gh release edit "$REF_NAME" --repo "$GITHUB_REPOSITORY" \ --title "gittensory-orb ${RELEASE_TAG}" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${PRERELEASE_ARGS[@]}" else gh release create "$REF_NAME" --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "gittensory-orb ${RELEASE_TAG}" \ + "${PRERELEASE_ARGS[@]}" \ --notes "$NOTES" \ --generate-notes fi diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx index 70f67befc3..91e1fc1d98 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx @@ -42,7 +42,7 @@ function SelfHostingReleases() { { title: "latest", description: - "Moves with the newest release. Useful for trials, not for controlled production.", + "Moves with the newest STABLE release only — never a prerelease. Useful for trials, not for controlled production.", }, { title: "sha", @@ -56,6 +56,22 @@ function SelfHostingReleases() { docker pull ghcr.io/jsonbored/gittensory-selfhost:latest`} /> +

Prerelease (beta/rc) images

+

+ A tag like orb-v0.1.0-rc.1 or orb-v0.1.0-beta.1 runs the identical + build/provenance/SBOM/Sentry pipeline as a stable release, but is marked prerelease on + GitHub and is never pushed under latest. External beta testers should pull the + exact prerelease tag, not latest. +

+ + + Stable release behavior is unchanged: a plain X.Y.Z tag still moves{" "} + latest and publishes an unmarked (non-prerelease) GitHub Release. + +

Upgrade flow

  1. Read release notes for env, migration, or behavior changes.
  2. diff --git a/test/unit/release-selfhost-prerelease.test.ts b/test/unit/release-selfhost-prerelease.test.ts new file mode 100644 index 0000000000..ac51098b91 --- /dev/null +++ b/test/unit/release-selfhost-prerelease.test.ts @@ -0,0 +1,136 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; + +const tmpDirs: string[] = []; +afterEach(() => { + for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function readWorkflowStep(name: string): { run: string } { + const workflow = parse(readFileSync(".github/workflows/release-selfhost.yml", "utf8")) as { + jobs: { release: { steps: Array<{ name?: string; run?: string }> } }; + }; + const step = workflow.jobs.release.steps.find((s) => s.name === name); + if (!step?.run) throw new Error(`step "${name}" not found or has no run: block`); + return { run: step.run }; +} + +// #1937: executes the ACTUAL bash from the "Resolve version" step (extracted straight out of the committed +// workflow YAML, not a re-derived copy) against a real GITHUB_OUTPUT file, so a regex/logic regression here +// fails this test rather than only surfacing on an actual tag push. +function resolveVersion(env: { EVENT_NAME: string; INPUT_VERSION: string; REF_NAME: string }): { status: number; outputs: Record; stderr: string } { + const { run } = readWorkflowStep("Resolve version"); + const dir = mkdtempSync(join(tmpdir(), "gtorb-version-")); + tmpDirs.push(dir); + const outputFile = join(dir, "github_output"); + writeFileSync(outputFile, ""); + try { + execFileSync("bash", ["-c", run], { + encoding: "utf8", + env: { ...process.env, ...env, GITHUB_OUTPUT: outputFile }, + }); + const outputs = parseGithubOutput(readFileSync(outputFile, "utf8")); + return { status: 0, outputs, stderr: "" }; + } catch (err) { + const e = err as { status?: number; stderr?: string }; + return { status: e.status ?? 1, outputs: {}, stderr: e.stderr ?? "" }; + } +} + +function parseGithubOutput(content: string): Record { + const out: Record = {}; + for (const line of content.split("\n")) { + const eq = line.indexOf("="); + if (eq === -1) continue; + out[line.slice(0, eq)] = line.slice(eq + 1); + } + return out; +} + +describe("release-selfhost.yml \"Resolve version\" step (#1937)", () => { + it("accepts a stable semver tag and marks it non-prerelease", () => { + const r = resolveVersion({ EVENT_NAME: "push", INPUT_VERSION: "", REF_NAME: "orb-v0.1.0" }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ v: "0.1.0", tag: "orb-v0.1.0", release: "gittensory-orb@0.1.0", prerelease: "false" }); + }); + + it.each(["rc", "beta"])("accepts an -%s.N prerelease tag and marks it prerelease", (kind) => { + const r = resolveVersion({ EVENT_NAME: "push", INPUT_VERSION: "", REF_NAME: `orb-v0.1.0-${kind}.1` }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ v: `0.1.0-${kind}.1`, tag: `orb-v0.1.0-${kind}.1`, prerelease: "true" }); + }); + + it("resolves the version from workflow_dispatch input instead of the ref when dispatched manually", () => { + const r = resolveVersion({ EVENT_NAME: "workflow_dispatch", INPUT_VERSION: "0.2.0-rc.3", REF_NAME: "main" }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ v: "0.2.0-rc.3", tag: "orb-v0.2.0-rc.3", prerelease: "true" }); + }); + + it("rejects a non-orb-v tag on push", () => { + const r = resolveVersion({ EVENT_NAME: "push", INPUT_VERSION: "", REF_NAME: "v0.1.0" }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain("expected an orb-v tag"); + }); + + it.each(["0.1", "0.1.0.0", "0.1.0-alpha.1", "0.1.0-rc", "0.1.0-rc.", "not-a-version"])( + "rejects a malformed or unsupported-prerelease-kind version: %s", + (version) => { + const r = resolveVersion({ EVENT_NAME: "push", INPUT_VERSION: "", REF_NAME: `orb-v${version}` }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain("expected semver version"); + }, + ); +}); + +describe("release-selfhost.yml \"Resolve image tags\" step (#1937)", () => { + function resolveTags(env: { PRERELEASE: string; VERSION_TAG: string }): { list: string } { + const { run } = readWorkflowStep("Resolve image tags"); + const dir = mkdtempSync(join(tmpdir(), "gtorb-tags-")); + tmpDirs.push(dir); + const outputFile = join(dir, "github_output"); + writeFileSync(outputFile, ""); + execFileSync("bash", ["-c", run], { encoding: "utf8", env: { ...process.env, ...env, GITHUB_OUTPUT: outputFile } }); + // GITHUB_OUTPUT's heredoc multiline form (`key< { + const { list } = resolveTags({ PRERELEASE: "false", VERSION_TAG: "orb-v0.1.0" }); + expect(list).toBe("type=raw,value=orb-v0.1.0\ntype=raw,value=latest\ntype=sha,format=short"); + }); + + it("omits the latest tag for a prerelease version", () => { + const { list } = resolveTags({ PRERELEASE: "true", VERSION_TAG: "orb-v0.1.0-rc.1" }); + expect(list).toBe("type=raw,value=orb-v0.1.0-rc.1\ntype=sha,format=short"); + expect(list).not.toContain("latest"); + }); +}); + +describe("release-selfhost.yml GitHub Release step (#1937)", () => { + it("computes --prerelease --latest=false gh flags only when PRERELEASE is true", () => { + const { run } = readWorkflowStep("GitHub Release"); + expect(run).toContain("PRERELEASE_ARGS=(--prerelease --latest=false)"); + expect(run).toContain('if [ "$PRERELEASE" = "true" ]; then'); + // Both gh invocations expand the resolved args array rather than hard-coding the flags inline, so a + // stable release's empty PRERELEASE_ARGS produces byte-identical behavior to before this change. + expect(run).toContain('"${PRERELEASE_ARGS[@]}"'); + }); + + it("threads the prerelease flag into both gh release create and gh release edit", () => { + const { run } = readWorkflowStep("GitHub Release"); + const createIndex = run.indexOf("gh release create"); + const editIndex = run.indexOf("gh release edit"); + expect(editIndex).toBeGreaterThan(-1); + expect(createIndex).toBeGreaterThan(editIndex); + expect(run.slice(editIndex, createIndex)).toContain('"${PRERELEASE_ARGS[@]}"'); + expect(run.slice(createIndex)).toContain('"${PRERELEASE_ARGS[@]}"'); + }); +}); diff --git a/test/unit/selfhost-sentry-release.test.ts b/test/unit/selfhost-sentry-release.test.ts index c6428a890f..6f5c61b494 100644 --- a/test/unit/selfhost-sentry-release.test.ts +++ b/test/unit/selfhost-sentry-release.test.ts @@ -19,7 +19,13 @@ describe("self-host Sentry release wiring", () => { expect(releaseWorkflow).toContain('"orb-v*"'); expect(releaseWorkflow).toContain('orb-v*) VERSION="${REF_NAME#orb-v}"'); expect(releaseWorkflow).toContain("tag=orb-v${VERSION}"); - expect(releaseWorkflow).toContain("type=raw,value=${{ steps.version.outputs.tag }}"); + // #1937: the resolved version tag flows steps.version.outputs.tag -> VERSION_TAG env -> the "Resolve + // image tags" step's bash, not inlined directly into docker/metadata-action's `tags:` anymore (that + // step now needs to conditionally omit `latest` for a prerelease, which a plain multi-line literal + // can't express). + expect(releaseWorkflow).toContain("VERSION_TAG: ${{ steps.version.outputs.tag }}"); + expect(releaseWorkflow).toContain("type=raw,value=${VERSION_TAG}"); + expect(releaseWorkflow).toContain("tags: ${{ steps.tags.outputs.list }}"); expect(releaseWorkflow).toContain( "docker pull ghcr.io/${REPOSITORY_OWNER}/gittensory-selfhost:${RELEASE_TAG}", );