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
57 changes: 49 additions & 8 deletions .github/workflows/release-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
# git tag orb-v0.1.0 && git push origin orb-v0.1.0
#
# Pull: docker pull ghcr.io/<owner>/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:
Expand All @@ -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:
Expand Down Expand Up @@ -66,14 +72,23 @@ jobs:
*) echo "expected an orb-v<semver> 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.
Expand Down Expand Up @@ -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<<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"

- 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
Expand Down Expand Up @@ -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 <<EOF
Expand All @@ -224,14 +256,23 @@ jobs:
Sentry 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
if gh release view "$REF_NAME" --repo "$GITHUB_REPOSITORY" >/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
18 changes: 17 additions & 1 deletion apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -56,6 +56,22 @@ function SelfHostingReleases() {
docker pull ghcr.io/jsonbored/gittensory-selfhost:latest`}
/>

<h2>Prerelease (beta/rc) images</h2>
<p>
A tag like <code>orb-v0.1.0-rc.1</code> or <code>orb-v0.1.0-beta.1</code> runs the identical
build/provenance/SBOM/Sentry pipeline as a stable release, but is marked prerelease on
GitHub and is never pushed under <code>latest</code>. External beta testers should pull the
exact prerelease tag, not <code>latest</code>.
</p>
<CodeBlock
lang="bash"
code={`docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.1.0-rc.1`}
/>
<Callout variant="note">
Stable release behavior is unchanged: a plain <code>X.Y.Z</code> tag still moves{" "}
<code>latest</code> and publishes an unmarked (non-prerelease) GitHub Release.
</Callout>

<h2>Upgrade flow</h2>
<ol>
<li>Read release notes for env, migration, or behavior changes.</li>
Expand Down
136 changes: 136 additions & 0 deletions test/unit/release-selfhost-prerelease.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; 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<string, string> {
const out: Record<string, string> = {};
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<semver> 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<<EOF\n...\nEOF`) needs its own parse, distinct from the
// simple `key=value` lines parseGithubOutput handles for the single-line "Resolve version" outputs.
const content = readFileSync(outputFile, "utf8");
const match = /^list<<GTORBTAGS\n([\s\S]*?)\nGTORBTAGS$/m.exec(content);
if (!match) throw new Error(`could not parse multiline "list" output from:\n${content}`);
return { list: match[1]! };
}

it("includes the latest tag for a stable version", () => {
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[@]}"');
});
});
8 changes: 7 additions & 1 deletion test/unit/selfhost-sentry-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
);
Expand Down
Loading