diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7eb6789..cf76455 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -153,6 +153,7 @@ jobs: docker buildx build \ --platform linux/amd64,linux/arm64 \ --provenance=false --sbom=false \ + --build-arg INBUXA_REVISION="${{ github.sha }}" \ $(for t in $TAGS; do printf '%s %s:%s ' --tag "$IMAGE" "$t"; done) \ --push . docker buildx imagetools inspect "$IMAGE:$VERSION" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 479f128..039efc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,7 @@ jobs: # `imagetools create` below expects the two entries pushed here. provenance: false sbom: false + build-args: INBUXA_REVISION=${{ github.sha }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true diff --git a/Dockerfile b/Dockerfile index ae1cff5..caf4735 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,10 @@ WORKDIR /build COPY package.json package-lock.json ./ RUN npm ci COPY . . +# The commit being built, for /.well-known/source. The release workflow passes +# it; a build without it writes no source document. +ARG INBUXA_REVISION="" +ENV INBUXA_REVISION=${INBUXA_REVISION} # The version comes from inbuxa-version.json, which the release commits before # this builds, so there is nothing to pass in here. RUN npm run build diff --git a/docker/nginx.conf b/docker/nginx.conf index 1e337c4..0469aba 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -19,6 +19,15 @@ server { add_header Cache-Control "public, immutable"; } + # Where this build's source is (https://coffeylabs.org/specs/well-known-source/). + # Exact, so a build without one is a 404, not the app. + location = /.well-known/source { + default_type application/json; + add_header Cache-Control "no-cache"; + add_header Access-Control-Allow-Origin "*"; + try_files $uri =404; + } + location = /index.html { add_header Cache-Control "no-cache"; } diff --git a/tsconfig.node.json b/tsconfig.node.json index 8b47bc9..a153b81 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -20,5 +20,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts", "source-archive.ts", "sieve-playground.ts"] + "include": ["vite.config.ts", "sieve-playground.ts", "well-known-source.ts"] } diff --git a/vite.config.ts b/vite.config.ts index e9faf5a..fa83492 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ import { configDefaults, defineConfig } from 'vitest/config' import inbuxa from './inbuxa-version.json' with { type: 'json' } import { sievePlayground } from './sieve-playground.ts' +import { wellKnownSource } from './well-known-source.ts' export default defineConfig({ // Relative, so one build works at any mount point (/admin, /account). @@ -18,7 +19,7 @@ export default defineConfig({ // inbuxa's: package.json's is a leftover nothing shows. __APP_VERSION__: JSON.stringify(inbuxa.version), }, - plugins: [react(), tailwindcss(), sievePlayground()], + plugins: [react(), tailwindcss(), sievePlayground(), wellKnownSource(inbuxa.version)], resolve: { alias: { '@': path.resolve(import.meta.dirname, './src') } }, test: { environment: 'happy-dom', diff --git a/well-known-source.test.ts b/well-known-source.test.ts new file mode 100644 index 0000000..aec020e --- /dev/null +++ b/well-known-source.test.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { describe, expect, it } from 'vitest' + +import { REPOSITORY, sourceDocument } from './well-known-source' + +const COMMIT = '808e6e4c2a1d5e8b7f6a9c0d1e2f3a4b5c6d7e8f' + +describe('.well-known/source', () => { + it('names the repository and the commit', () => { + const doc = JSON.parse(sourceDocument('27.1-rc.2', COMMIT)!) + expect(doc).toEqual({ + version: 1, + software: [ + { name: 'inbuxa Admin', version: '27.1-rc.2', license: 'AGPL-3.0-only', repository: REPOSITORY, revision: COMMIT }, + ], + }) + }) + + it('says nothing without a full commit id', () => { + for (const revision of [undefined, '', '808e6e4', 'v27.1-rc.2', COMMIT.replace(/f/g, 'g')]) { + expect(sourceDocument('27.1-rc.2', revision)).toBeUndefined() + } + }) +}) diff --git a/well-known-source.ts b/well-known-source.ts new file mode 100644 index 0000000..d0fd426 --- /dev/null +++ b/well-known-source.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs LLC +// SPDX-License-Identifier: AGPL-3.0-only +/** + * inbuxa: writes `.well-known/source` into the build, in the form + * https://coffeylabs.org/specs/well-known-source/ gives it: the console's + * public repository and the commit it was built from. + * + * The commit comes from INBUXA_REVISION, which the image build and the + * deploy pass in; neither has a .git to ask. A build without a full commit id + * writes nothing, since a repository without a revision doesn't name the + * build. + */ +import type { Plugin } from 'vite' + +export const REPOSITORY = 'https://git.coffeylabs.org/inbuxa/inbuxa-admin' + +/** A full commit id: 40 hex digits (SHA-1) or 64 (SHA-256). */ +export function isCommitId(revision: string | undefined): revision is string { + return revision !== undefined && /^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(revision) +} + +/** The document for a build of `version` at `revision`, if it names one. */ +export function sourceDocument(version: string, revision: string | undefined): string | undefined { + if (!isCommitId(revision)) return undefined + return `${JSON.stringify( + { + version: 1, + software: [ + { name: 'inbuxa Admin', version, license: 'AGPL-3.0-only', repository: REPOSITORY, revision }, + ], + }, + null, + 2, + )}\n` +} + +export function wellKnownSource(version: string): Plugin { + return { + name: 'inbuxa-well-known-source', + apply: 'build', + generateBundle() { + const source = sourceDocument(version, process.env.INBUXA_REVISION) + if (source) this.emitFile({ type: 'asset', fileName: '.well-known/source', source }) + }, + } +}