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
4 changes: 4 additions & 0 deletions .changeset/curvy-corners-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

No release note: regenerate the API client with the already-pinned generator; the API contract is unchanged.
136 changes: 135 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,113 @@ jobs:
candidate-application-image: ghcr.io/ls1intum/hephaestus/application-server@${{ needs.tag-images.outputs.application-server-digest }}
postgres-image: ghcr.io/ls1intum/hephaestus/postgres@${{ needs.tag-images.outputs.postgres-digest }}
candidate-source-sha: ${{ needs.release.outputs.sha }}

supported-host-smoke:
needs: [release, tag-images]
if: needs.release.outputs.released == 'true'
name: Host smoke (${{ matrix.architecture }})
strategy:
fail-fast: false
matrix:
include:
- architecture: amd64
runner: ubuntu-24.04
- architecture: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
permissions:
# The release is still a draft here, and draft releases are visible only to tokens with push
# access, so `gh release download` in prepare-release-lock.ts needs contents: write.
contents: write
packages: read
steps:
- name: Check out the released tree
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release.outputs.sha }}
persist-credentials: false

- uses: ./.github/actions/setup-bun

- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Smoke-test the blessed install
env:
ARCHITECTURE: ${{ matrix.architecture }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_SHA: ${{ needs.release.outputs.sha }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
cd docker/self-host
cleanup() {
status=$?
if [ "$status" -ne 0 ]; then
docker compose --env-file .env --env-file release-lock.env logs --no-color || true
fi
docker compose --env-file .env --env-file release-lock.env down -v --remove-orphans || true
exit "$status"
}
trap cleanup EXIT

actual_arch=$(docker version --format '{{.Server.Arch}}')
[ "$actual_arch" = "$ARCHITECTURE" ] || { echo "::error::Expected native $ARCHITECTURE, got $actual_arch"; exit 1; }
. /etc/os-release
[ "$ID" = ubuntu ] && [ "$VERSION_ID" = 24.04 ] || { echo "::error::Ubuntu 24.04 is required"; exit 1; }
docker_major=$(docker version --format '{{.Server.Version}}' | cut -d. -f1)
[ "$docker_major" -ge 28 ] || { echo "::error::Docker Engine 28 or newer is required"; exit 1; }
compose_version=$(docker compose version --short)
[ "$(printf '%s\n' 2.24.4 "$compose_version" | sort -V | head -n1)" = 2.24.4 ] || {
echo "::error::Docker Compose 2.24.4 or newer is required"; exit 1; }

./setup.sh
sed -i \
-e 's/^APP_HOSTNAME=$/APP_HOSTNAME=hephaestus-smoke.invalid/' \
-e 's/^ACME_EMAIL=$/ACME_EMAIL=release-smoke@example.invalid/' \
-e 's/^GH_OAUTH_CLIENT_ID=$/GH_OAUTH_CLIENT_ID=release-smoke/' \
-e 's/^GH_OAUTH_CLIENT_SECRET=$/GH_OAUTH_CLIENT_SECRET=release-smoke-secret/' \
-e 's/^HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=$/HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=github:1/' \
.env
node ../../scripts/prepare-release-lock.ts "$TAG_NAME"
docker compose --env-file .env --env-file release-lock.env config --quiet
docker compose --env-file .env --env-file release-lock.env up -d --wait --wait-timeout 600
curl --fail --insecure --silent --show-error --max-time 10 --noproxy '*' \
--resolve hephaestus-smoke.invalid:443:127.0.0.1 \
https://hephaestus-smoke.invalid/ >/dev/null

jq -n \
--arg schemaVersion "1" \
--arg release "$TAG_NAME" \
--arg commit "$RELEASE_SHA" \
--arg testedAt "$(date -u +%FT%TZ)" \
--arg os "$(. /etc/os-release; printf '%s %s' "$NAME" "$VERSION_ID")" \
--arg architecture "$actual_arch" \
--arg dockerEngine "$(docker version --format '{{.Server.Version}}')" \
--arg dockerCompose "$compose_version" \
--argjson services "$(docker compose --env-file .env --env-file release-lock.env ps --all --format json | jq -s 'map({service: .Service, state: .State, health: .Health, exitCode: .ExitCode}) | sort_by(.service)')" \
'{schemaVersion: ($schemaVersion | tonumber), release: $release, commit: $commit,
testedAt: $testedAt, host: {os: $os, architecture: $architecture,
dockerEngine: $dockerEngine, dockerCompose: $dockerCompose}, services: $services}' \
> "../../host-smoke-$ARCHITECTURE.json"

- name: Upload smoke-test record
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: host-smoke-${{ matrix.architecture }}
path: host-smoke-${{ matrix.architecture }}.json
if-no-files-found: error

publish-release:
needs: [release, tag-images, upgrade-test]
needs: [release, tag-images, upgrade-test, supported-host-smoke]
if: needs.release.outputs.released == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest
Expand Down Expand Up @@ -460,6 +565,34 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Download supported-host smoke records
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: host-smoke-*
path: host-smoke
merge-multiple: true

- name: Verify supported-host smoke records
env:
RELEASE_SHA: ${{ needs.release.outputs.sha }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
jq -s -e --arg release "$TAG_NAME" --arg commit "$RELEASE_SHA" '
length == 2 and
([.[].host.architecture] | sort) == ["amd64", "arm64"] and
all(.[];
.schemaVersion == 1 and .release == $release and .commit == $commit and
(.testedAt | fromdateiso8601) and
.host.os == "Ubuntu 24.04" and
(.host.dockerEngine | type) == "string" and
(.host.dockerCompose | type) == "string" and
(.services | length) > 0 and
([.services[].service] | length) == ([.services[].service] | unique | length) and
all(.services[];
if .state == "exited" then .exitCode == 0
else .state == "running" and .health == "healthy" end))' host-smoke/*.json >/dev/null

- name: Verify durable assets from a clean environment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down Expand Up @@ -563,6 +696,7 @@ jobs:
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
gh release upload "$TAG_NAME" host-smoke/*.json --repo "${{ github.repository }}"
gh release edit "$TAG_NAME" --repo "${{ github.repository }}" --draft=false
[ "$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = true ] || {
echo "::error::Repository immutable releases must be enabled before publishing"; exit 1; }
Expand Down
19 changes: 17 additions & 2 deletions docs/admin/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ The stack you get:

## Requirements

### Supported host matrix

Hephaestus supports the blessed single-host stack on the following native (not emulated) hosts:

| Distribution | CPU architecture |
| --- | --- |
| Ubuntu 24.04 LTS | x86-64 (`amd64`) |
| Ubuntu 24.04 LTS | ARMv8 64-bit (`arm64`) |

Both require Docker Engine 28 or newer and Docker Compose 2.24.4 or newer. Docker supports these
hosts according to its [Ubuntu installation requirements](https://docs.docker.com/engine/install/ubuntu/#os-requirements).
Each release is qualified on both matrix cells before publication.

Only the listed native hosts and the unmodified self-hosted Compose topology are supported. All other
hosts, runtimes, and topologies are outside release qualification.

- **4 vCPUs, 8 GB RAM, 40 GB SSD** recommended. Absolute floor: 2 vCPUs / **8 GB RAM** — the two JVMs
ship with container memory limits of 5 GB (`application-server`) and 2 GB (`webhook-server`), so a
smaller host does not swap, it OOM-kills. On a host below that, lower both limits before the first
Expand All @@ -30,8 +46,7 @@ The stack you get:
- **AI practice review adds real memory**: each concurrent review sandbox may use up to
4 GiB. The default caps it at 1 concurrent sandbox; raise
`SANDBOX_MAX_CONCURRENT` only with RAM to match.
- 64-bit Linux with **Docker Engine ≥ 24** and **Docker Compose ≥ 2.24.4** (`docker compose version`),
plus `git` and `openssl`.
- A host in the supported matrix, plus `git` and `openssl`.
- A **DNS A record** for your hostname pointing at the host, with ports **80 and 443**
reachable from the internet (Let's Encrypt HTTP-01, OAuth callbacks, webhooks).
- Outbound HTTPS to `ghcr.io` and `docker.io` for images, `github.com` and `api.github.com` for the
Expand Down
12 changes: 11 additions & 1 deletion docs/contributor/release-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ sequenceDiagram
M->>R: Create draft vX.Y.Z release
R->>R: Generate and verify digest-bound evidence
R->>R: Upgrade seeded data from the previous stable release
R->>R: Qualify supported hosts
R->>R: Promote X.Y.Z / X.Y / latest image tags
R->>R: Publish release and deploy staging
Note over R: Production still requires approval
Expand All @@ -56,10 +57,19 @@ sequenceDiagram
ruleset, so the absent required checks don't block the merge. Versioning also moves any
`### Next release` section in `MIGRATION.md` under the version being cut.
3. **Merging the Version PR cuts the release.** The workflow creates a draft release at the merge
commit. After its evidence and seeded-upgrade gates pass, it promotes the CI-built images to
commit. After its evidence, seeded-upgrade, and supported-host gates pass, it promotes the CI-built images to
`X.Y.Z`, `X.Y`, and `latest`, publishes the release, deploys staging, and requests production
approval.

## Supported-host qualification

Every release boots its signed, immutable image lock through the self-hosted topology on native Ubuntu
24.04 `amd64` and `arm64` hosts. The gate enforces the [published support matrix](../admin/install#supported-host-matrix),
waits for stack readiness, and exercises HTTPS ingress. Either cell can block publication.

Each cell's status record is attached to the immutable GitHub release. This qualification covers a
clean boot; the seeded-upgrade gate separately proves the supported migration path.

## Supply-chain evidence

A release remains a draft until every first-party and upstream production image in
Expand Down
22 changes: 20 additions & 2 deletions scripts/release-deployment-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ await test("deployment uses Compose metadata, waits for readiness, and preserves
await test("release publication requires the seeded upgrade gate", () => {
const upgrade = readFileSync(".github/workflows/release-upgrade.yml", "utf8");

assert.match(release, /publish-release:\n\s+needs: \[release, tag-images, upgrade-test\]/);
assert.match(
release,
/publish-release:\n\s+needs: \[release, tag-images, upgrade-test, supported-host-smoke\]/,
);
assert.match(
release,
/candidate-application-image: .*@\$\{\{ needs\.tag-images\.outputs\.application-server-digest \}\}/,
Expand All @@ -36,6 +39,21 @@ await test("release publication requires the seeded upgrade gate", () => {
upgrade,
/ref: \$\{\{ inputs\.candidate-source-sha \|\| inputs\.candidate-sha \|\| github\.sha \}\}/,
);
const upgradeGate = release.match(/ {2}upgrade-test:[\s\S]*?\n {2}publish-release:/)?.[0] ?? "";
const upgradeGate =
release.match(/ {2}upgrade-test:[\s\S]*?\n {2}supported-host-smoke:/)?.[0] ?? "";
assert.doesNotMatch(upgradeGate, /secrets: inherit/);
});

await test("release publication requires native smoke tests for every supported host", () => {
const smokeGate =
release.match(/ {2}supported-host-smoke:[\s\S]*?\n {2}publish-release:/)?.[0] ?? "";

assert.match(smokeGate, /architecture: amd64\n\s+runner: ubuntu-24\.04/);
assert.match(smokeGate, /architecture: arm64\n\s+runner: ubuntu-24\.04-arm/);
assert.match(smokeGate, /up -d --wait --wait-timeout 600/);
assert.match(smokeGate, /prepare-release-lock\.ts/);
// Draft releases are visible only to tokens with push access; a read-only token cannot
// download the still-draft release lock and would fail every smoke run.
assert.match(smokeGate, /contents: write/);
assert.match(release, /gh release upload "\$TAG_NAME" host-smoke\/\*\.json/);
});
6 changes: 3 additions & 3 deletions webapp/src/api/@tanstack/react-query.gen.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions webapp/src/api/client.gen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts

import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client/index.js';
import type { ClientOptions as ClientOptions2 } from './types.gen.js';

/**
* The `createClientConfig()` function will be called on client initialization
Expand Down
10 changes: 5 additions & 5 deletions webapp/src/api/client/client.gen.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts

import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
import { createSseClient } from '../core/serverSentEvents.gen.js';
import type { HttpMethod } from '../core/types.gen.js';
import { getValidRequestBody } from '../core/utils.gen.js';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen.js';
import {
buildUrl,
createConfig,
Expand All @@ -12,7 +12,7 @@ import {
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
} from './utils.gen.js';

type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
Expand Down
20 changes: 10 additions & 10 deletions webapp/src/api/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
// This file is auto-generated by @hey-api/openapi-ts

export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export type { Auth } from '../core/auth.gen.js';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen.js';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export type { ServerSentEventsResult } from '../core/serverSentEvents.gen';
export type { ClientMeta } from '../core/types.gen';
export { createClient } from './client.gen';
} from '../core/bodySerializer.gen.js';
export { buildClientParams } from '../core/params.gen.js';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen.js';
export type { ServerSentEventsResult } from '../core/serverSentEvents.gen.js';
export type { ClientMeta } from '../core/types.gen.js';
export { createClient } from './client.gen.js';
export type {
Client,
ClientOptions,
Expand All @@ -23,5 +23,5 @@ export type {
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
} from './types.gen.js';
export { createConfig, mergeHeaders } from './utils.gen.js';
8 changes: 4 additions & 4 deletions webapp/src/api/client/types.gen.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// This file is auto-generated by @hey-api/openapi-ts

import type { Auth } from '../core/auth.gen';
import type { Auth } from '../core/auth.gen.js';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
import type { Middleware } from './utils.gen';
} from '../core/serverSentEvents.gen.js';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen.js';
import type { Middleware } from './utils.gen.js';

export type ResponseStyle = 'data' | 'fields';

Expand Down
12 changes: 6 additions & 6 deletions webapp/src/api/client/utils.gen.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// This file is auto-generated by @hey-api/openapi-ts

import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import { getAuthToken } from '../core/auth.gen.js';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen.js';
import { jsonBodySerializer } from '../core/bodySerializer.gen.js';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
} from '../core/pathSerializer.gen.js';
import { getUrl } from '../core/utils.gen.js';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen.js';

export const createQuerySerializer = <T = unknown>({
parameters = {},
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/api/core/bodySerializer.gen.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts

import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen.js';

export type QuerySerializer = (query: Record<string, unknown>) => string;

Expand Down
2 changes: 1 addition & 1 deletion webapp/src/api/core/serverSentEvents.gen.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts

import type { Config } from './types.gen';
import type { Config } from './types.gen.js';

export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/api/core/types.gen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts

import type { Auth, AuthToken } from './auth.gen';
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
import type { Auth, AuthToken } from './auth.gen.js';
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen.js';

export type HttpMethod =
| 'connect'
Expand Down
Loading
Loading