Start standalone frontend service
Build and publish frontend / publish (push) Failing after 9s

This commit is contained in:
Ein Anderssono
2026-10-14 09:46:00 +02:00
parent 9c23371363
commit 6335f86f65
16 changed files with 572 additions and 2 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitea
deploy
scripts
README.md
AGENTS.md
VERSION
+147
View File
@@ -0,0 +1,147 @@
name: Build and publish frontend
on:
push:
branches:
- main
tags:
- "v*"
permissions:
contents: write
jobs:
publish:
runs-on: large
if: ${{ !startsWith(github.ref, 'refs/heads/') || !contains(github.event.head_commit.message, '[auto-version-bump]') }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: true
- name: Verify release version and frontend source
shell: bash
run: |
set -euo pipefail
python3 scripts/bump-version.py --check
scripts/check-frontend.sh
- name: Ensure Helm is available
shell: bash
env:
HELM_VERSION: v3.17.3
run: |
set -euo pipefail
if command -v helm >/dev/null 2>&1; then
helm version --short
exit 0
fi
mkdir -p "$HOME/.local/bin" "$RUNNER_TEMP/helm"
archive="$RUNNER_TEMP/helm/helm-${HELM_VERSION}-linux-amd64.tar.gz"
curl -fsSLo "$archive" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz"
curl -fsSLo "$archive.sha256sum" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum"
(cd "$RUNNER_TEMP/helm" && sha256sum -c "$(basename "$archive").sha256sum")
tar -xzf "$archive" -C "$RUNNER_TEMP/helm"
install "$RUNNER_TEMP/helm/linux-amd64/helm" "$HOME/.local/bin/helm"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Lint and render chart
shell: bash
run: |
set -euo pipefail
helm lint deploy/helm/ai-spel-frontend
helm template ai-spel-frontend deploy/helm/ai-spel-frontend \
--namespace ai-spel > "$RUNNER_TEMP/frontend-manifest.yaml"
grep -q 'kind: Deployment' "$RUNNER_TEMP/frontend-manifest.yaml"
grep -q 'kind: Service' "$RUNNER_TEMP/frontend-manifest.yaml"
- name: Build and push commit image
if: startsWith(github.ref, 'refs/heads/')
shell: bash
env:
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
run: |
set -euo pipefail
printf '%s' "$HARBOR_PASSWORD" | docker login harbor.k3s.k0.nu \
--username "$HARBOR_USERNAME" --password-stdin
unset HARBOR_PASSWORD
version="$(cat VERSION)"
tag="harbor.k3s.k0.nu/ai-spel/frontend:edge-${GITHUB_SHA:0:12}"
docker build --platform linux/amd64 --pull \
--build-arg "FRONTEND_VERSION=$version" --tag "$tag" .
docker image push "$tag"
- name: Bump patch version and create release tag
id: bump
if: startsWith(github.ref, 'refs/heads/')
shell: bash
run: |
set -euo pipefail
git fetch origin main --quiet
test "$(git rev-parse origin/main)" = "$GITHUB_SHA" || {
echo 'main advanced during this workflow; a newer push will run the release step'
exit 1
}
version="$(python3 scripts/bump-version.py)"
git config user.name "Gitea Actions"
git config user.email "actions@users.noreply.gitea"
git add VERSION deploy/helm/ai-spel-frontend/Chart.yaml
git commit -m "Release $version [auto-version-bump]"
git tag -a "v$version" -m "v$version"
git push origin HEAD:main "refs/tags/v$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Publish versioned image, chart, and manifest
if: ${{ startsWith(github.ref, 'refs/tags/') || steps.bump.outputs.version != '' }}
shell: bash
env:
AUTO_RELEASE_VERSION: ${{ steps.bump.outputs.version }}
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
ORAS_VERSION: 1.2.3
run: |
set -euo pipefail
version="$(cat VERSION)"
test -n "$version"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
test "$GITHUB_REF_NAME" = "v$version"
else
test "$AUTO_RELEASE_VERSION" = "$version"
fi
python3 scripts/bump-version.py --check
printf '%s' "$HARBOR_PASSWORD" | docker login harbor.k3s.k0.nu \
--username "$HARBOR_USERNAME" --password-stdin
image="harbor.k3s.k0.nu/ai-spel/frontend:$version"
docker build --platform linux/amd64 --pull \
--build-arg "FRONTEND_VERSION=$version" --tag "$image" .
docker image push "$image"
publish_dir="$(mktemp -d)"
trap 'rm -rf "$publish_dir"' EXIT
helm lint deploy/helm/ai-spel-frontend
helm package deploy/helm/ai-spel-frontend --destination "$publish_dir" \
--version "$version" --app-version "$version"
helm push "$publish_dir/ai-spel-frontend-$version.tgz" \
oci://harbor.k3s.k0.nu/ai-spel
helm template ai-spel-frontend "$publish_dir/ai-spel-frontend-$version.tgz" \
--namespace ai-spel > "$publish_dir/frontend-manifest.yaml"
mkdir -p "$RUNNER_TEMP/oras"
archive="$RUNNER_TEMP/oras/oras_${ORAS_VERSION}_linux_amd64.tar.gz"
curl -fsSLo "$archive" \
"https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz"
echo 'b4efc97a91f471f323f193ea4b4d63d8ff443ca3aab514151a30751330852827 '"$archive" | sha256sum -c -
tar -xzf "$archive" -C "$RUNNER_TEMP/oras" oras
export PATH="$RUNNER_TEMP/oras:$PATH"
printf '%s' "$HARBOR_PASSWORD" | oras login harbor.k3s.k0.nu \
--username "$HARBOR_USERNAME" --password-stdin
unset HARBOR_PASSWORD
(
cd "$publish_dir"
oras push --artifact-type application/vnd.ai-spel.frontend-manifest.v1 \
"harbor.k3s.k0.nu/ai-spel/frontend-manifest:$version" \
"frontend-manifest.yaml:application/yaml"
)
+9
View File
@@ -0,0 +1,9 @@
# AI-spel frontend instructions
- This repository owns only the static, accessible web client and its container/chart packaging.
- Rust remains the sole authority for game state. The frontend calls the backend through same-origin `/api/...` and `/version` paths.
- Keep frontend and backend behind `ai-spel.k3s.k0.nu` so session cookies remain same-origin.
- `VERSION` is the only release-version source in this repository. Use `scripts/bump-version.py`; do not manually edit chart versions.
- Changes are written locally and built, checked, published, and exercised by Gitea Actions on the existing `large` runner and Kubernetes. Never run apps, tests, containers, images, or binaries locally. Do not create or register runners.
- Never commit secrets, generated credentials, kubeconfigs, or environment files.
- Helm charts are published to `harbor.k3s.k0.nu/ai-spel`; Argo CD uses the existing `ai-spel` project and namespace.
+9
View File
@@ -0,0 +1,9 @@
FROM nginxinc/nginx-unprivileged:1.27-alpine
ARG FRONTEND_VERSION
RUN test -n "$FRONTEND_VERSION" \
&& sed -i "s/__FRONTEND_VERSION__/${FRONTEND_VERSION}/g" /usr/share/nginx/html/index.html
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
+14 -2
View File
@@ -1,3 +1,15 @@
# ai-spel-frontend # AI-spel frontend
Statisk webbfrontend för den AI-drivna svenska livssimulatorn This repository contains the static Swedish web client for the AI-driven life simulator. The intended deployment is a small Nginx pod in the `ai-spel` namespace. The existing Rust service remains authoritative for accounts, game state, and all `/api/...` routes.
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
## Release flow
`VERSION` is the only version source for this repository. Gitea Actions on the existing `large` runner runs the static checks, Helm lint/template, publishes a commit image, bumps the patch version through `scripts/bump-version.py`, creates the matching release commit and Git tag, and publishes the release image, Helm chart, and rendered frontend manifest to Harbor. Argo CD tracks the newest published frontend chart. The image and chart releases use the same version as the commit and tag.
CI needs repository Action secrets named `HARBOR_USERNAME` and `HARBOR_PASSWORD`. The Harbor pull secret `harbor-pull` is already present in the Kubernetes namespace. Argo CD uses the existing `ai-spel` project and Harbor OCI credentials.
The first Argo Application is described in `deploy/argocd/application.yaml`. The backend ingress must be switched to the frontend Service before public traffic uses this pod. That cutover is intentionally coordinated with a backend release so the API routes and login session remain on the same host.
Builds, checks, and runtime verification happen in the pipeline and Kubernetes. Do not run the app, tests, container images, or binaries locally.
+1
View File
@@ -0,0 +1 @@
0.1.13
+1
View File
@@ -0,0 +1 @@
Apply `application.yaml` once to bootstrap the frontend Argo CD Application. It tracks the latest published `ai-spel-frontend` chart in Harbor and auto-syncs it into namespace `ai-spel`. The chart creates only the frontend Deployment and Service; the existing backend Helm release owns the shared ingress and routes `/` to the frontend Service after the frontend pod is ready.
+22
View File
@@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ai-spel-frontend
namespace: argocd
spec:
project: ai-spel
source:
repoURL: harbor.k3s.k0.nu/ai-spel
chart: ai-spel-frontend
targetRevision: "*"
helm:
releaseName: ai-spel-frontend
destination:
server: https://kubernetes.default.svc
namespace: ai-spel
syncPolicy:
automated:
prune: false
selfHeal: true
syncOptions:
- CreateNamespace=false
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator
type: application
version: 0.1.13
appVersion: "0.1.13"
@@ -0,0 +1,50 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- if .Values.image.pullSecretName }}
imagePullSecrets:
- name: {{ .Values.image.pullSecretName }}
{{- end }}
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: frontend
image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.resources | nindent 12 }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
+15
View File
@@ -0,0 +1,15 @@
replicaCount: 1
image:
repository: harbor.k3s.k0.nu/ai-spel/frontend
pullPolicy: IfNotPresent
pullSecretName: harbor-pull
service:
port: 80
targetPort: 8080
resources:
requests:
cpu: 10m
memory: 24Mi
limits:
cpu: 250m
memory: 96Mi
+175
View File
@@ -0,0 +1,175 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Svensk livssimulator</title>
<style>
:root { font: 1rem/1.55 system-ui, sans-serif; }
body { max-width: 52rem; margin: 0 auto; padding: 1.25rem; }
h1, h2, h3 { line-height: 1.2; }
footer { border-top: 1px solid; margin-top: 2rem; padding-top: .5rem; color: GrayText; font-size: .875rem; }
form { display: grid; gap: .75rem; max-width: 40rem; }
label { display: grid; gap: .2rem; font-weight: 600; }
input, textarea, button { font: inherit; padding: .6rem; }
button { justify-self: start; cursor: pointer; }
#status { min-height: 1.5em; }
.card { border: 1px solid; border-radius: .5rem; padding: 1rem; margin-block: 1rem; }
.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.row button { margin-block: .4rem; }
#transcript { min-height: 5rem; max-height: 28rem; overflow: auto; }
#transcript li { margin-block: .6rem; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>En värld du formar</h1>
<p>Skapa en vuxen person och forma en egen vardag. AI-förslag sparas inte förrän du har granskat och godkänt dem.</p>
<p id="status" role="status" aria-live="polite"></p>
<section id="auth" aria-labelledby="auth-heading">
<h2 id="auth-heading">Logga in eller skapa konto</h2>
<form id="auth-form">
<label>Användarnamn <input name="username" required minlength="3" maxlength="40" autocomplete="username"></label>
<label>Lösenord <input name="password" type="password" required minlength="12" maxlength="256" autocomplete="current-password"></label>
<div class="row"><button type="submit" name="intent" value="login">Logga in</button><button type="submit" name="intent" value="register">Skapa konto</button></div>
</form>
</section>
<section id="new-character" aria-labelledby="character-heading" hidden>
<h2 id="character-heading">Vem är du?</h2>
<p>Du väljer själv bakgrund. Karaktären ska vara vuxen, men du behöver inte välja yrke eller startpaket.</p>
<form id="character-form">
<label>Namn <input name="name" required maxlength="120" autocomplete="name"></label>
<label>Ålder (18 år eller äldre) <input name="age" type="number" min="18" required value="30"></label>
<label>Biografi <textarea name="biography" required maxlength="20000" rows="6" placeholder="Berätta fritt om personens liv och nuläge."></textarea></label>
<button type="submit">Börja spela</button>
</form>
</section>
<section id="world-setup" aria-labelledby="world-setup-heading" hidden>
<h2 id="world-setup-heading">Skapa din startvärld</h2>
<p>Beskriv vad du vill börja med. AI:n föreslår en plats och en person; du kan redigera allt innan världen sparas.</p>
<form id="world-idea-form">
<label>Din idé <textarea name="idea" required maxlength="4000" rows="4" placeholder="Till exempel: en småstad vid kusten där min karaktär just har flyttat in."></textarea></label>
<button type="submit">Skapa ett förslag</button>
</form>
<form id="world-draft-form" hidden>
<label>Världens namn <input name="name" required maxlength="120"></label>
<label>Plats <input name="location_name" required maxlength="120"></label>
<label>Beskrivning av platsen <textarea name="location_description" required maxlength="2000" rows="4"></textarea></label>
<label>Person du kan möta <input name="npc_name" required maxlength="120"></label>
<label>Om personen <textarea name="npc_biography" required maxlength="2000" rows="4"></textarea></label>
<div class="row"><button type="submit">Godkänn och börja spela</button><button id="discard-world-draft" type="button">Gör ett nytt förslag</button></div>
</form>
</section>
<section id="game" hidden>
<div class="row"><h2 id="world-heading"></h2><button id="logout" type="button">Logga ut</button></div>
<section class="card" aria-labelledby="place-heading">
<h3 id="place-heading"></h3><p id="place-description"></p>
<h3 id="npc-heading"></h3><p id="npc-description"></p>
<h3 id="player-heading"></h3><p id="player-biography"></p>
</section>
<section class="card" aria-labelledby="conversation-heading">
<h3 id="conversation-heading">Samtal</h3>
<ol id="transcript" aria-label="Sparat samtal"></ol>
<form id="message-form">
<label id="message-label">Skriv ett meddelande <textarea name="message" required maxlength="4000" rows="3"></textarea></label>
<button type="submit">Skicka</button>
</form>
</section>
</section>
</main>
<footer>Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · API <span id="api-version">hämtar version…</span></footer>
<script>
const status = document.querySelector('#status');
const auth = document.querySelector('#auth');
const characterSection = document.querySelector('#new-character');
const worldSetup = document.querySelector('#world-setup');
const game = document.querySelector('#game');
let gameState = null;
async function request(path, options = {}) {
const response = await fetch(path, { credentials: 'same-origin', ...options });
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
return body;
}
request('/version').then(({version}) => {
document.querySelector('#api-version').textContent = version;
}).catch(() => {
document.querySelector('#api-version').textContent = 'otillgänglig';
});
function showAuth() { auth.hidden = false; characterSection.hidden = true; worldSetup.hidden = true; game.hidden = true; }
function showCharacter() { auth.hidden = true; characterSection.hidden = false; worldSetup.hidden = true; game.hidden = true; }
function showWorldSetup() { auth.hidden = true; characterSection.hidden = true; worldSetup.hidden = false; game.hidden = true; }
function renderConversation(lines, npcName) {
const transcript = document.querySelector('#transcript');
transcript.replaceChildren(...lines.map((line) => {
const entry = document.createElement('li');
entry.textContent = `${line.role === 'user' ? 'Du' : npcName}: ${line.content}`;
return entry;
}));
}
async function openGame() {
gameState = await request('/api/world');
if (!gameState.character) { showCharacter(); return; }
if (!gameState.world) { showWorldSetup(); return; }
const world = gameState.world;
auth.hidden = true; characterSection.hidden = true; game.hidden = false;
worldSetup.hidden = true;
document.querySelector('#world-heading').textContent = world.name;
document.querySelector('#place-heading').textContent = world.location.name;
document.querySelector('#place-description').textContent = world.location.description;
document.querySelector('#npc-heading').textContent = world.npc.name;
document.querySelector('#npc-description').textContent = world.npc.biography;
document.querySelector('#conversation-heading').textContent = `Samtal med ${world.npc.name}`;
document.querySelector('#message-label').firstChild.textContent = `Skriv till ${world.npc.name} `;
document.querySelector('#player-heading').textContent = `${gameState.character.name}, ${gameState.character.age} år`;
document.querySelector('#player-biography').textContent = gameState.character.biography;
renderConversation(await request('/api/npc/conversation'), world.npc.name);
}
document.querySelector('#auth-form').addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const intent = event.submitter?.value || 'login';
status.textContent = intent === 'register' ? 'Skapar konto…' : 'Loggar in…';
try {
await request(`/api/${intent}`, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({username:data.get('username'),password:data.get('password')}) });
form.reset(); await openGame(); status.textContent = 'Du är inloggad.';
} catch (error) { status.textContent = `Kunde inte logga in: ${error.message}`; }
});
document.querySelector('#character-form').addEventListener('submit', async (event) => {
event.preventDefault(); const data = new FormData(event.currentTarget); status.textContent = 'Sparar din karaktär…';
try { await request('/api/character', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:data.get('name'),age:Number(data.get('age')),biography:data.get('biography')})}); await openGame(); status.textContent='Karaktären har sparats.'; }
catch(error){status.textContent=`Kunde inte spara karaktären: ${error.message}`;}
});
document.querySelector('#world-idea-form').addEventListener('submit', async (event) => {
event.preventDefault(); const data = new FormData(event.currentTarget); status.textContent='Skapar ett redigerbart förslag…';
try {
const proposal = await request('/api/world/draft', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({idea:data.get('idea')})});
const form = document.querySelector('#world-draft-form');
for (const key of ['name','location_name','location_description','npc_name','npc_biography']) form.elements[key].value = proposal[key];
form.hidden = false; status.textContent='Förslaget är klart. Granska och redigera det innan du godkänner.';
} catch(error) { status.textContent=`Kunde inte skapa ett förslag: ${error.message}`; }
});
document.querySelector('#discard-world-draft').addEventListener('click', () => {
document.querySelector('#world-draft-form').hidden = true; status.textContent='Förslaget har inte sparats. Ändra din idé och försök igen.';
});
document.querySelector('#world-draft-form').addEventListener('submit', async (event) => {
event.preventDefault(); const data = new FormData(event.currentTarget); status.textContent='Sparar din godkända värld…';
try {
await request('/api/world', {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(data.entries()))});
await openGame(); status.textContent='Din värld har sparats.';
} catch(error) { status.textContent=`Kunde inte spara världen: ${error.message}`; }
});
document.querySelector('#message-form').addEventListener('submit', async (event) => {
event.preventDefault(); const form=event.currentTarget; const field=form.elements.message; const message=field.value.trim(); if(!message)return;
status.textContent='Din samtalspartner funderar på sitt svar…';
try { const result=await request('/api/npc/messages',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,idempotency_key:crypto.randomUUID()})}); field.value=''; await openGame(); status.textContent='Samtalet har sparats.'; }
catch(error){status.textContent=`Samtalet kunde inte skickas: ${error.message}`;}
});
document.querySelector('#logout').addEventListener('click', async () => { try { await request('/api/logout',{method:'POST'}); showAuth(); status.textContent='Du har loggat ut.'; } catch(error){status.textContent=`Kunde inte logga ut: ${error.message}`;} });
request('/api/me').then(async () => { await openGame(); status.textContent='Din sparade spelvärld har lästs in.'; }).catch(() => { showAuth(); });
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
access_log off;
return 204;
}
location / {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /index.html;
}
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Use VERSION as the single source for frontend release metadata."""
import argparse
from pathlib import Path
import re
VERSION_FILE = Path("VERSION")
CHART_FILE = Path("deploy/helm/ai-spel-frontend/Chart.yaml")
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def read_version() -> str:
version = VERSION_FILE.read_text().strip()
if not SEMVER.fullmatch(version):
raise SystemExit(f"Invalid semantic version in {VERSION_FILE}: {version}")
return version
def update_one(contents: str, pattern: str, replacement: str, description: str) -> str:
updated, count = re.subn(pattern, replacement, contents, count=1, flags=re.MULTILINE)
if count != 1:
raise SystemExit(f"Expected one {description} in {CHART_FILE}, found {count}")
return updated
def check(version: str) -> str:
chart = CHART_FILE.read_text()
chart_version = re.findall(r"(?m)^version:\s*(\S+)\s*$", chart)
app_version = re.findall(r'(?m)^appVersion:\s*"(\S+)"\s*$', chart)
if chart_version != [version] or app_version != [version]:
raise SystemExit(
f"Version drift: VERSION={version}, chart version={chart_version}, "
f"appVersion={app_version}"
)
return version
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="verify metadata without editing")
args = parser.parse_args()
current = check(read_version())
if args.check:
print(current)
return
major, minor, patch = map(int, current.split("."))
next_version = f"{major}.{minor}.{patch + 1}"
chart = CHART_FILE.read_text()
chart = update_one(
chart,
r"(?m)^version:\s*" + re.escape(current) + r"\s*$",
f"version: {next_version}",
"chart version",
)
chart = update_one(
chart,
r'(?m)^appVersion:\s*"' + re.escape(current) + r'"\s*$',
f'appVersion: "{next_version}"',
"chart appVersion",
)
VERSION_FILE.write_text(f"{next_version}\n")
CHART_FILE.write_text(chart)
print(next_version)
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
test -s index.html
grep -q '<html lang="sv">' index.html
grep -q '<h1>En värld du formar</h1>' index.html
grep -q 'id="frontend-version">__FRONTEND_VERSION__' index.html
grep -q "request('/version')" index.html
grep -q 'id="api-version"' index.html
grep -Fq "credentials: 'same-origin'" index.html
grep -q 'location = /healthz' nginx/default.conf
echo 'Frontend source checks passed.'