diff --git a/.env.example b/.env.example index 4d95858..efc3cf3 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,5 @@ TRAEFIK_NETWORK=internal TRAEFIK_CERT_RESOLVER=netcup # Keep these two values paired when deploying a newer published toolbox release. -TOOLBOX_RELEASE_VERSION=0.20.2 -TOOLBOX_RELEASE_SHA256=66f02f95092ef80b9e49cda91c8401400e2ee1d2c640ab20a3a7810bf265c2c7 +TOOLBOX_RELEASE_VERSION=0.20.3 +TOOLBOX_RELEASE_SHA256=1e743fda8f6d1ca5c77798029351585bf6673a3410892d27e5ad17c9f6bcf0bf diff --git a/Containerfile.release b/Containerfile.release index 1c78ab7..4f2867a 100644 --- a/Containerfile.release +++ b/Containerfile.release @@ -2,8 +2,8 @@ ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded3 FROM ${NGINX_IMAGE} AS release -ARG TOOLBOX_RELEASE_VERSION=0.20.2 -ARG TOOLBOX_RELEASE_SHA256=66f02f95092ef80b9e49cda91c8401400e2ee1d2c640ab20a3a7810bf265c2c7 +ARG TOOLBOX_RELEASE_VERSION=0.20.3 +ARG TOOLBOX_RELEASE_SHA256=1e743fda8f6d1ca5c77798029351585bf6673a3410892d27e5ad17c9f6bcf0bf ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/lotobo/toolbox-portal/releases/download RUN set -eu; \ diff --git a/README.md b/README.md index 5dc1844..5c24d97 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,26 @@ release version, and matching checksum can be overridden through environment variables; copy `.env.example` to `.env` only when an override is needed. For example, set `TOOLBOX_HOST=staging.toolbox.add-ideas.de` for a staging host. +## One-shot release wrapper + +For release assembly and publishing, use the repository-local wrapper: + +```sh +./scripts/release.sh +``` + +That script performs (in order): + +- optional `npm test` +- `npm run build` +- `npm run assemble` (using the lockfile’s `releaseVersion`) +- tag and push (`v`) +- publish/update a Gitea release and upload + `add-ideas-toolbox-.zip` and `.sha256` + +Useful flags include `--skip-tests`, `--skip-git-push`, `--skip-publish`, +`--tag`, `--owner`, `--repo`, and `--dry-run`. + The same container also exposes only the packaged authentication app at `https://auth.toolbox.add-ideas.de/`. Traefik adds the internal `/apps/auth` prefix while the public URL remains `/`, so assets remain relative and the browser diff --git a/compose.example.yaml b/compose.example.yaml index 8ba39ee..99d2132 100644 --- a/compose.example.yaml +++ b/compose.example.yaml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Containerfile - image: git.add-ideas.de/lotobo/toolbox:0.20.2 + image: git.add-ideas.de/lotobo/toolbox:0.20.3 restart: unless-stopped read_only: true ports: diff --git a/compose.yaml b/compose.yaml index 589207a..b219e29 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,9 +6,9 @@ services: context: . dockerfile: Containerfile.release args: - TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.20.2}" + TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.20.3}" TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-66f02f95092ef80b9e49cda91c8401400e2ee1d2c640ab20a3a7810bf265c2c7}" - image: "git.add-ideas.de/lotobo/toolbox:${TOOLBOX_RELEASE_VERSION:-0.20.2}" + image: "git.add-ideas.de/lotobo/toolbox:${TOOLBOX_RELEASE_VERSION:-0.20.3}" restart: unless-stopped read_only: true tmpfs: diff --git a/package.json b/package.json index d407deb..98c9c2e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "licenses:generate": "node scripts/licenses.mjs", "assemble": "node scripts/assemble.mjs", "package:static": "node scripts/package-static.mjs", - "release:update": "node scripts/update-release-lock.mjs" + "release:update": "node scripts/update-release-lock.mjs", + "release": "scripts/release.sh" }, "dependencies": { "@add-ideas/toolbox-contract": "^0.3.0", diff --git a/public/toolbox.catalog.json b/public/toolbox.catalog.json index 2a23be8..71c09b4 100644 --- a/public/toolbox.catalog.json +++ b/public/toolbox.catalog.json @@ -109,6 +109,10 @@ "manifest": "./apps/text/toolbox-app.json", "enabled": true }, + { + "manifest": "./apps/translator/toolbox-app.json", + "enabled": true + }, { "manifest": "./apps/unicode/toolbox-app.json", "enabled": true diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs new file mode 100644 index 0000000..160cc33 --- /dev/null +++ b/scripts/publish-release.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { readFile, access } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; + +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function usage() { + return `Usage: node scripts/publish-release.mjs [options] + +Uploads prepared release artifacts to a Gitea release. + +Options: + --api-base URL Gitea API base (default: https://git.add-ideas.de/api/v1) + --owner OWNER Gitea repository owner (default: lotobo) + --repo REPO Gitea repository (default: toolbox-portal) + --tag TAG Release tag, e.g. v0.20.3 (default: from --lock-file) + --target HASH Target commit SHA for release creation + --name NAME Release name + --body BODY Optional release body + --archive PATH Release ZIP path + --checksum PATH SHA256 file path + --lock-file PATH Release lock file (default: release/toolbox.lock.json) + --token TOKEN Gitea API token; defaults to GITEA_TOKEN env + --dry-run Print actions without contacting Gitea + --help Show this help +`; +} + +function parseArguments(argv) { + if (argv.includes('--help')) { + console.log(usage()); + process.exit(0); + } + + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith('--')) throw new Error(`Unknown argument ${arg}`); + + if (arg === '--dry-run') { + args.set(arg, true); + continue; + } + + const value = argv[index + 1]; + if (value === undefined) throw new Error(`Missing value for ${arg}`); + args.set(arg, value); + index += 1; + } + + return { + apiBase: args.get('--api-base') ?? 'https://git.add-ideas.de/api/v1', + owner: args.get('--owner') ?? 'lotobo', + repo: args.get('--repo') ?? 'toolbox-portal', + tag: args.get('--tag'), + target: args.get('--target'), + name: args.get('--name') ?? 'add-ideas Toolbox release', + body: args.get('--body'), + archive: args.get('--archive'), + checksum: args.get('--checksum'), + lockFile: args.get('--lock-file') ?? 'release/toolbox.lock.json', + token: args.get('--token') ?? process.env.GITEA_TOKEN, + dryRun: args.get('--dry-run') === true, + }; +} + +async function readTokenFromEnvFile() { + const xdgConfig = process.env.XDG_CONFIG_HOME; + const homeConfig = process.env.HOME + ? `${process.env.HOME}/.config/gitea/gitea.env` + : null; + + const candidates = []; + if (xdgConfig) candidates.push(`${xdgConfig}/gitea/gitea.env`); + if (homeConfig) candidates.push(homeConfig); + + for (const candidate of candidates) { + try { + await access(candidate); + } catch { + continue; + } + + const fileContent = await readFile(candidate, 'utf8'); + const tokenLine = fileContent + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('GITEA_TOKEN=')); + + if (!tokenLine) continue; + + const token = tokenLine.split('=', 2)[1]?.trim(); + if (token) return token; + } + + return null; +} + +async function giteaRequest(url, options, { allowNotFound = false } = {}) { + const response = await fetch(url, { + ...options, + headers: { + Accept: 'application/json', + ...(options.headers ?? {}), + }, + }); + + if (allowNotFound && response.status === 404) { + return { notFound: true, status: 404 }; + } + + const text = await response.text(); + const payload = text ? (() => { + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } + })() : null; + + if (!response.ok) { + const detail = payload && typeof payload === 'object' && payload.error + ? payload.error + : payload?.raw ?? text ?? ''; + throw new Error(`Gitea API request failed (${response.status} ${response.statusText}): ${detail}`); + } + + return payload; +} + +function sha256Digest(content) { + return createHash('sha256').update(content).digest('hex'); +} + +function parseChecksumFile(raw) { + const [first] = String(raw).trim().split(/\s+/); + return first?.toLowerCase() ?? ''; +} + +function parseReleaseVersionFromLock(lockJson) { + return lockJson.releaseVersion; +} + +async function run() { + const options = parseArguments(process.argv.slice(2)); + + const lock = await readFile(options.lockFile, 'utf8'); + const lockJson = JSON.parse(lock); + if (!SEMVER.test(lockJson.releaseVersion)) { + throw new Error(`Invalid releaseVersion in lock: ${lockJson.releaseVersion}`); + } + + const releaseVersion = parseReleaseVersionFromLock(lockJson); + const releaseTag = options.tag ?? `v${releaseVersion}`; + const dryRun = options.dryRun === true; + + let token = options.token; + if (!token && !dryRun) token = await readTokenFromEnvFile(); + if (!token && !dryRun) { + throw new Error('GITEA_TOKEN is required. Set env or provide --token.'); + } + if (!token && dryRun) token = 'dry-run-token'; + + if (!options.archive) { + throw new Error('Missing --archive'); + } + if (!options.checksum) { + throw new Error('Missing --checksum'); + } + + const archive = path.resolve(options.archive); + const checksumFile = path.resolve(options.checksum); + + const [archiveData, checksumRaw] = await Promise.all([ + readFile(archive), + readFile(checksumFile, 'utf8'), + ]); + + const expectedChecksum = parseChecksumFile(checksumRaw); + const computedChecksum = sha256Digest(archiveData); + if (!expectedChecksum) { + throw new Error(`Checksum file is empty: ${checksumFile}`); + } + if (!/^[a-f0-9]{64}$/i.test(expectedChecksum)) { + throw new Error(`Invalid checksum format in ${checksumFile}`); + } + if (expectedChecksum.toLowerCase() !== computedChecksum.toLowerCase()) { + throw new Error( + `Checksum mismatch for ${options.archive}. computed=${computedChecksum} file=${expectedChecksum}` + ); + } + + if (dryRun) { + console.log(`DRY-RUN: would ensure or create release ${releaseTag} on ${options.owner}/${options.repo}.`); + console.log(`DRY-RUN: would upload assets ${path.basename(archive)} and ${path.basename(checksumFile)}.`); + return; + } + + const apiBase = options.apiBase.replace(/\/+$/, ''); + const releasePrefix = `${apiBase}/repos/${options.owner}/${options.repo}`; + const auth = `token ${token}`; + + const headers = { Authorization: auth }; + const tagUrl = `${releasePrefix}/releases/tags/${encodeURIComponent(releaseTag)}`; + const tagLookup = await giteaRequest( + tagUrl, + { method: 'GET', headers }, + { allowNotFound: true } + ); + + let release; + if (tagLookup?.notFound) { + const body = { + tag_name: releaseTag, + target_commitish: options.target || 'main', + name: options.name, + body: + options.body + || `Automated release of add-ideas toolbox ${releaseTag} from lock release ${releaseVersion}.`, + draft: false, + prerelease: false, + }; + if (dryRun) { + console.log(`DRY-RUN: would create release ${releaseTag} on ${options.owner}/${options.repo}`); + release = { id: '', assets: [] }; + } else { + release = await giteaRequest(`${releasePrefix}/releases`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + console.log(`Created release ${releaseTag} (${release.id})`); + } + } else { + release = tagLookup; + if (dryRun) { + console.log(`DRY-RUN: would reuse existing release ${releaseTag} (${release?.id ?? 'unknown'})`); + } else { + console.log(`Using existing release ${releaseTag} (${release.id})`); + } + } + + if (dryRun) { + console.log('DRY-RUN: would upload assets:', options.archive, options.checksum); + return; + } + + const existingAssets = release.assets ?? []; + const candidateAssets = [ + { path: archive, name: path.basename(archive) }, + { path: checksumFile, name: path.basename(checksumFile) }, + ]; + + for (const asset of candidateAssets) { + const duplicate = existingAssets.find((entry) => entry.name === asset.name); + if (duplicate) { + console.log(`Deleting existing asset ${asset.name} from release ${releaseTag}`); + await giteaRequest( + `${releasePrefix}/releases/assets/${duplicate.id}`, + { method: 'DELETE', headers }, + { allowNotFound: true } + ); + } + + const payload = await readFile(asset.path); + const form = new FormData(); + form.set('attachment', new Blob([payload]), asset.name); + + console.log(`Uploading ${asset.name}`); + await giteaRequest( + `${releasePrefix}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, + { + method: 'POST', + headers, + body: form, + } + ); + } + + console.log(`Release ${releaseTag} published on ${options.owner}/${options.repo}`); +} + +run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..6a3a091 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${PROJECT_DIR}" + +usage() { + cat <<'USAGE' +Usage: scripts/release.sh [options] + +Bundles the current lockfile into a portal archive and publishes the release to Gitea. + +Options: + --lock PATH Lock file to use (default: release/toolbox.lock.json) + --tag TAG Release tag to create/push (default: v) + --owner OWNER Gitea owner/org (default: lotobo) + --repo REPO Gitea repository (default: toolbox-portal) + --api-base URL Gitea API base URL (default: https://git.add-ideas.de/api/v1) + --remote REMOTE Git remote used to push the tag (default: origin) + --build-dir DIR Build directory (default: build) + --output-subdir DIR Assembled output directory under build-dir (default: toolbox) + --skip-tests Skip npm test + --skip-assemble Skip npm run assemble + --skip-build Skip npm run build + --skip-tag Skip tagging/push of the release tag + --skip-git-push Skip pushing git branch and tag + --skip-publish Skip Gitea release publish/upload + --force Force overwrite of build outputs + --dry-run Print commands without running remote publishing operations + --help Show this help + +Environment: + GITEA_TOKEN Personal token for Gitea API calls + GITEA_API_BASE Optional override for API base URL + +Examples: + scripts/release.sh + scripts/release.sh --tag v0.20.4 --skip-tests +USAGE +} + +LOCK_FILE="release/toolbox.lock.json" +OWNER="lotobo" +REPO="toolbox-portal" +API_BASE="${GITEA_API_BASE:-https://git.add-ideas.de/api/v1}" +REMOTE="origin" +BUILD_DIR="build" +OUTPUT_SUBDIR="toolbox" +SKIP_TESTS=0 +SKIP_ASSEMBLE=0 +SKIP_BUILD=0 +SKIP_TAG=0 +SKIP_GIT_PUSH=0 +SKIP_PUBLISH=0 +FORCE=0 +DRY_RUN=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --lock) + LOCK_FILE="$2" + shift 2 + ;; + --tag) + TAG_OVERRIDE="$2" + shift 2 + ;; + --owner) + OWNER="$2" + shift 2 + ;; + --repo) + REPO="$2" + shift 2 + ;; + --api-base) + API_BASE="$2" + shift 2 + ;; + --remote) + REMOTE="$2" + shift 2 + ;; + --build-dir) + BUILD_DIR="$2" + shift 2 + ;; + --output-subdir) + OUTPUT_SUBDIR="$2" + shift 2 + ;; + --skip-tests) + SKIP_TESTS=1 + shift + ;; + --skip-assemble) + SKIP_ASSEMBLE=1 + shift + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --skip-tag) + SKIP_TAG=1 + shift + ;; + --skip-git-push) + SKIP_GIT_PUSH=1 + shift + ;; + --skip-publish) + SKIP_PUBLISH=1 + shift + ;; + --force) + FORCE=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +run() { + if [ "$DRY_RUN" -eq 1 ]; then + echo "DRY-RUN: $*" + return 0 + fi + + "$@" +} + +RELEASE_VERSION="$(node -e "const fs=require('fs');const path=process.argv[1];const lock=JSON.parse(fs.readFileSync(path,'utf8'));if(!lock?.releaseVersion){process.exit(1);}console.log(lock.releaseVersion);" "$LOCK_FILE")" +if [ -z "$RELEASE_VERSION" ]; then + echo "releaseVersion could not be read from ${LOCK_FILE}" >&2 + exit 1 +fi + +if [ -n "${TAG_OVERRIDE:-}" ]; then + RELEASE_TAG="$TAG_OVERRIDE" +else + RELEASE_TAG="v${RELEASE_VERSION}" +fi + +ZIP_FILE="${BUILD_DIR}/add-ideas-toolbox-${RELEASE_VERSION}.zip" +CHECKSUM_FILE="${ZIP_FILE}.sha256" +TARGET_DIR="${BUILD_DIR}/${OUTPUT_SUBDIR}" + +if [ "$SKIP_TESTS" -eq 0 ]; then + run npm test +fi + +if [ "$SKIP_BUILD" -eq 0 ]; then + run npm run build +fi + +if [ "$SKIP_ASSEMBLE" -eq 0 ]; then + ASSEMBLE_ARGS=( + npm + run + assemble + -- + --lock + "$LOCK_FILE" + --portal-dist + dist + --output + "$TARGET_DIR" + --archive + "$ZIP_FILE" + ) + if [ "$FORCE" -eq 1 ]; then + ASSEMBLE_ARGS+=(--force) + fi + run "${ASSEMBLE_ARGS[@]}" +fi + +if [ ! -s "$ZIP_FILE" ]; then + echo "Release archive missing: $ZIP_FILE" >&2 + exit 1 +fi + +if [ ! -s "$CHECKSUM_FILE" ]; then + if [ "$DRY_RUN" -eq 1 ]; then + echo "DRY-RUN: would create ${CHECKSUM_FILE}" + else + sha256sum "$ZIP_FILE" > "$CHECKSUM_FILE" + fi +fi + +if [ "$SKIP_TAG" -eq 0 ]; then + if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then + CURRENT_COMMIT="$(git rev-parse "${RELEASE_TAG}^{commit}")" + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$CURRENT_COMMIT" != "$HEAD_COMMIT" ]; then + echo "Tag ${RELEASE_TAG} already exists at ${CURRENT_COMMIT}, but HEAD is ${HEAD_COMMIT}." >&2 + exit 1 + fi + echo "Tag exists at HEAD: ${RELEASE_TAG}" + else + echo "Creating tag ${RELEASE_TAG}" + run git tag -a "$RELEASE_TAG" -m "toolbox ${RELEASE_TAG}" + fi + + if [ "$SKIP_GIT_PUSH" -eq 0 ]; then + run git push "$REMOTE" HEAD + run git push "$REMOTE" "$RELEASE_TAG" + fi +fi + +if [ "$SKIP_PUBLISH" -eq 0 ]; then + TOKEN="${GITEA_TOKEN:-}" + if [ -z "$TOKEN" ]; then + if [ -f "${HOME}/.config/gitea/gitea.env" ]; then + # shellcheck disable=SC1091 + . "${HOME}/.config/gitea/gitea.env" + TOKEN="${GITEA_TOKEN:-}" + fi + fi + + if [ -z "$TOKEN" ]; then + echo "GITEA_TOKEN is required for publishing (or use --skip-publish)." >&2 + exit 1 + fi + + run node scripts/publish-release.mjs \ + --api-base "$API_BASE" \ + --owner "$OWNER" \ + --repo "$REPO" \ + --tag "$RELEASE_TAG" \ + --target "$(git rev-parse HEAD)" \ + --name "Toolbox ${RELEASE_TAG}" \ + --archive "$ZIP_FILE" \ + --checksum "$CHECKSUM_FILE" \ + --lock-file "$LOCK_FILE" \ + --token "$TOKEN" + + if [ "$DRY_RUN" -eq 1 ]; then + echo "Dry run complete. No assets were uploaded." + else + echo "Published ${RELEASE_TAG} to Gitea." + fi +fi + +echo "Release wrapper finished for ${RELEASE_TAG}"