diff --git a/README.md b/README.md index f85b38f..dc1b881 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,12 @@ evidence belongs under ignored `build/`. `src/data/downloads.json` is a reviewed immutable catalog, not a live release feed. Its closed schema keeps a release repository separate from an artifact -role and binds exact release evidence. Drafts, prereleases, zero-asset releases, -and guessed or mutable links fail closed; an empty catalog keeps the Classic -release-page fallback useful without JavaScript or a GitHub request. +role and binds exact release evidence, including the release coordinate and the +evidenced artifact package version. Drafts, prereleases, zero-asset releases, +and guessed or mutable links fail closed. A deliberately authorized primary +artifact-version mismatch is an explicit closed-contract exception rendered as +a warning; an empty catalog keeps the Classic release-page fallback useful +without JavaScript or a GitHub request. Cloudflare Pages uses one Git-integrated project. `main` serves the canonical `atrinik.org`; `www.atrinik.org` permanently redirects to it. Cloudflare builds diff --git a/contracts/download.schema.json b/contracts/download.schema.json index 594630e..232bc98 100644 --- a/contracts/download.schema.json +++ b/contracts/download.schema.json @@ -9,6 +9,8 @@ "artifactRole", "primary", "version", + "artifactVersion", + "releaseCoordinateException", "tag", "revision", "publishedAt", @@ -45,6 +47,13 @@ "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" }, + "artifactVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + }, + "releaseCoordinateException": { + "enum": ["none", "authorized-artifact-version-mismatch"] + }, "tag": { "type": "string", "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0df485c..c0ccf2b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,11 +23,15 @@ structured identity and retains `noindex, nofollow`. Downloads remain in their owning GitHub releases. Catalog schema version 2 separates the release repository from the artifact's logical role and marks at -most one deliberately reviewed primary artifact. A record binds its semantic -version/tag, 40-character source revision, publication and review times, -immutable/attested release state, asset count, platform, architecture, archive format, filename, byte size, -SHA-256, exact versioned artifact/evidence URLs, software and bundled-asset -license boundaries, compatibility, and installation guidance. Draft, +most one deliberately reviewed primary artifact. A record binds its release +semantic version/tag and evidenced artifact package version, 40-character +source revision, publication and review times, immutable/attested release +state, asset count, platform, architecture, archive format, filename, byte +size, SHA-256, exact versioned artifact/evidence URLs, software and +bundled-asset license boundaries, compatibility, and installation guidance. A +primary `v5.34.4`/`5.34.1` artifact-version mismatch is accepted only with the +exact `authorized-artifact-version-mismatch` contract value and is rendered as +a temporary warning; every other mismatch fails closed. Draft, prerelease, zero-asset, mutable, incomplete, duplicate, or unsupported primary records fail source validation. An empty catalog remains valid and renders the release-page fallback without a direct download. diff --git a/src/components/DownloadCatalog.astro b/src/components/DownloadCatalog.astro index 5e951fc..6835006 100644 --- a/src/components/DownloadCatalog.astro +++ b/src/components/DownloadCatalog.astro @@ -26,6 +26,15 @@ const download = primaryDownload(Astro.props.catalog); artifact was deliberately selected from a complete, published, non-prerelease release in its owning repository.

+ {download.releaseCoordinateException !== "none" && ( +

+ Temporary release-coordinate exception: the {download.tag}{" "} + release coordinate currently serves a Classic client package + labeled v{download.artifactVersion}. This one-off catalog entry + is authorized while the upstream publication is corrected; + verify the filename and SHA-256 below. +

+ )}
@@ -42,9 +51,13 @@ const download = primaryDownload(Astro.props.catalog);
-
Version
+
Release version
{download.version}
+
+
Artifact version
+
{download.artifactVersion}
+
Platform
diff --git a/src/data/downloads.json b/src/data/downloads.json index 200e491..1bbaf36 100644 --- a/src/data/downloads.json +++ b/src/data/downloads.json @@ -5,11 +5,13 @@ "releaseRepository": "atrinik/classic", "artifactRole": "client", "primary": true, - "version": "5.8.2", - "tag": "v5.8.2", - "revision": "6d5932b48a019353955d393f59cdb3fea54dd45d", - "publishedAt": "2026-08-10T00:32:43Z", - "verifiedAt": "2026-08-10T05:09:00Z", + "version": "5.34.4", + "artifactVersion": "5.34.1", + "releaseCoordinateException": "authorized-artifact-version-mismatch", + "tag": "v5.34.4", + "revision": "12b1211dd44690476c3f060d7395ccc1250c85b4", + "publishedAt": "2026-08-23T03:18:15Z", + "verifiedAt": "2026-08-24T06:51:57Z", "draft": false, "prerelease": false, "immutable": true, @@ -18,17 +20,17 @@ "platform": "windows", "architecture": "x86_64", "archiveFormat": "zip", - "artifact": "atrinik-classic-client-5.8.2-windows-x86_64.zip", - "bytes": 87406395, - "sha256": "1a7699f3ea5be50fc83b40dce8097c18cd0a892f98a2b0d462f851c2822689c0", - "url": "https://github.com/atrinik/classic/releases/download/v5.8.2/atrinik-classic-client-5.8.2-windows-x86_64.zip", - "releaseNotesUrl": "https://github.com/atrinik/classic/releases/tag/v5.8.2", - "manifestUrl": "https://github.com/atrinik/classic/releases/download/v5.8.2/release-manifest.json", - "checksumsUrl": "https://github.com/atrinik/classic/releases/download/v5.8.2/SHA256SUMS", - "sbomUrl": "https://github.com/atrinik/classic/releases/download/v5.8.2/atrinik-classic-5.8.2.spdx.json", + "artifact": "atrinik-classic-client-5.34.1-windows-x86_64.zip", + "bytes": 87663737, + "sha256": "06b2dcf13d1b4fe5e024dd5d921d8102c4b7521c4fcf988e654cadd7a45f2376", + "url": "https://github.com/atrinik/classic/releases/download/v5.34.4/atrinik-classic-client-5.34.1-windows-x86_64.zip", + "releaseNotesUrl": "https://github.com/atrinik/classic/releases/tag/v5.34.4", + "manifestUrl": "https://github.com/atrinik/classic/releases/download/v5.34.4/release-manifest.json", + "checksumsUrl": "https://github.com/atrinik/classic/releases/download/v5.34.4/SHA256SUMS", + "sbomUrl": "https://github.com/atrinik/classic/releases/download/v5.34.4/atrinik-classic-5.34.1.spdx.json", "softwareLicense": "GPL-2.0-or-later", "bundledAssetsLicense": "Bundled sound and authored assets retain their exact per-file terms; inspect the packaged LICENSE.md, ATTRIBUTIONS.md, and any license files accompanying individual assets. Use the SPDX SBOM for dependency and provenance evidence.", - "compatibility": "This client uses gameplay protocol revision 1072 and connects only to servers using the same revision; the coordinated Classic 5.8.2 server uses revision 1072.", + "compatibility": "This 5.34.1 client uses gameplay protocol revision 1079 and connects only to servers using the same revision; the v5.34.4 release line is temporarily serving this stable package.", "installation": "Extract the ZIP, keep every packaged file and directory together, and run atrinik.exe from inside the extracted package." } ] diff --git a/src/lib/downloads.ts b/src/lib/downloads.ts index 0a556f4..b9e3639 100644 --- a/src/lib/downloads.ts +++ b/src/lib/downloads.ts @@ -3,6 +3,8 @@ export interface DownloadRecord { artifactRole: string; primary: boolean; version: string; + artifactVersion: string; + releaseCoordinateException: string; tag: string; revision: string; publishedAt: string; diff --git a/src/styles/global.css b/src/styles/global.css index 3e74383..8abf0ec 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1019,6 +1019,19 @@ main { font-size: 1.08rem; } +.download-coordinate-warning { + margin-top: 1rem; + margin-bottom: 0; + padding: 1rem 1.2rem; + border: 1px solid var(--line-gold); + background: rgba(114, 221, 255, 0.08); + color: var(--text-soft); +} + +.download-coordinate-warning strong { + color: var(--text); +} + .download-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/tools/fixtures/download-adversarial-catalog.json b/tools/fixtures/download-adversarial-catalog.json index 9fef8d2..ca10d32 100644 --- a/tools/fixtures/download-adversarial-catalog.json +++ b/tools/fixtures/download-adversarial-catalog.json @@ -6,6 +6,8 @@ "artifactRole": "client", "primary": true, "version": "5.9.0", + "artifactVersion": "5.9.0", + "releaseCoordinateException": "none", "tag": "v5.9.0", "revision": "0123456789abcdef0123456789abcdef01234567", "publishedAt": "2026-08-10T01:00:00Z", diff --git a/tools/fixtures/download-valid.json b/tools/fixtures/download-valid.json index 428bca7..1b6a8d6 100644 --- a/tools/fixtures/download-valid.json +++ b/tools/fixtures/download-valid.json @@ -3,6 +3,8 @@ "artifactRole": "client", "primary": true, "version": "1.2.3", + "artifactVersion": "1.2.3", + "releaseCoordinateException": "none", "tag": "v1.2.3", "revision": "0123456789abcdef0123456789abcdef01234567", "publishedAt": "2026-01-02T03:04:05Z", diff --git a/tools/site-contract.mjs b/tools/site-contract.mjs index 1d79592..a7aa4c3 100644 --- a/tools/site-contract.mjs +++ b/tools/site-contract.mjs @@ -24,6 +24,14 @@ const versionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; const releaseRepositoryPattern = /^atrinik\/[a-z][a-z0-9-]*$/u; const artifactRolePattern = /^[a-z][a-z0-9-]*$/u; const artifactPattern = /^[A-Za-z0-9][A-Za-z0-9._-]+$/u; +const releaseCoordinateExceptionValues = new Set([ + "none", + "authorized-artifact-version-mismatch", +]); +const authorizedReleaseCoordinateException = Object.freeze({ + releaseVersion: "5.34.4", + artifactVersion: "5.34.1", +}); const utcTimestampPattern = /^(?:(?:\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8])))T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\dZ$/u; const mediaPathPattern = /^\/media\/[a-z0-9][a-z0-9-]*\.([0-9a-f]{8})\.webp$/u; @@ -89,6 +97,8 @@ export function validateDownload(record) { "artifactRole", "primary", "version", + "artifactVersion", + "releaseCoordinateException", "tag", "revision", "publishedAt", @@ -127,6 +137,26 @@ export function validateDownload(record) { record.tag !== `v${record.version}` ) throw new Error("download version/tag mismatch"); + if ( + !versionPattern.test(record.artifactVersion) || + !releaseCoordinateExceptionValues.has(record.releaseCoordinateException) + ) + throw new Error("invalid download artifact version binding"); + const artifactVersionMismatch = record.artifactVersion !== record.version; + const authorizedArtifactVersionMismatch = + record.primary === true && + record.version === authorizedReleaseCoordinateException.releaseVersion && + record.artifactVersion === + authorizedReleaseCoordinateException.artifactVersion && + record.releaseCoordinateException === + "authorized-artifact-version-mismatch"; + if ( + (artifactVersionMismatch && !authorizedArtifactVersionMismatch) || + (!artifactVersionMismatch && record.releaseCoordinateException !== "none") + ) + throw new Error( + "download artifact version mismatch is not explicitly authorized", + ); if ( !revisionPattern.test(record.revision) || !sha256Pattern.test(record.sha256) @@ -227,9 +257,9 @@ export function validateDownloadCatalog(catalog) { record.archiveFormat !== "zip" || record.releaseAssets !== 12 || record.artifact !== - `atrinik-classic-client-${record.version}-windows-x86_64.zip` || + `atrinik-classic-client-${record.artifactVersion}-windows-x86_64.zip` || record.sbomUrl !== - `https://github.com/atrinik/classic/releases/download/${record.tag}/atrinik-classic-${record.version}.spdx.json` || + `https://github.com/atrinik/classic/releases/download/${record.tag}/atrinik-classic-${record.artifactVersion}.spdx.json` || record.softwareLicense !== "GPL-2.0-or-later", ) ) @@ -243,7 +273,10 @@ export function validateDownloadSchemaDefinition(schema, fixture) { schema?.additionalProperties !== false || schema.required?.toSorted().join("\n") !== expectedFields || properties.version?.pattern !== versionPatternSource || + properties.artifactVersion?.pattern !== versionPatternSource || properties.tag?.pattern !== tagPatternSource || + properties.releaseCoordinateException?.enum?.join("\n") !== + "none\nauthorized-artifact-version-mismatch" || new RegExp(properties.releaseRepository?.pattern ?? "").source !== releaseRepositoryPattern.source || new RegExp(properties.artifactRole?.pattern ?? "").source !== @@ -335,6 +368,7 @@ export function validateDownloadsPresentation(html, catalog) { } const requiredEvidence = [ primary.version, + primary.artifactVersion, primary.revision, primary.sha256, primary.artifact, @@ -353,12 +387,22 @@ export function validateDownloadsPresentation(html, catalog) { throw new Error("primary download presentation omits catalog evidence"); const structuralEvidence = [ `
Platform
${primary.platform === "windows" ? "Windows" : primary.platform} ${primary.architecture}
`, + `
Artifact version
${primary.artifactVersion}
`, `
Archive
${primary.archiveFormat.toUpperCase()}
`, `

Install and compatibility

${primary.installation}

${primary.compatibility}

`, `

License boundary

Classic software is licensed under ${primary.softwareLicense}. ${primary.bundledAssetsLicense}

`, ]; if (structuralEvidence.some((value) => !html.includes(value))) throw new Error("primary download presentation misplaces catalog evidence"); + if ( + primary.releaseCoordinateException !== "none" && + !html.includes( + `Temporary release-coordinate exception: the ${primary.tag} release coordinate currently serves a Classic client package labeled v${primary.artifactVersion}.`, + ) + ) + throw new Error( + "primary download presentation omits its release exception", + ); if ( downloadReleaseUrls(primary).some( (url) => !html.includes(`href="${url}"`), diff --git a/tools/site-contract.test.mjs b/tools/site-contract.test.mjs index e7f2401..bd11dc2 100644 --- a/tools/site-contract.test.mjs +++ b/tools/site-contract.test.mjs @@ -177,6 +177,51 @@ test("download coordinates are closed and immutable", () => { validateDownloadSchemaDefinition(downloadSchema, validDownload), ); assert.doesNotThrow(() => validateDownload(validDownload)); + const authorizedArtifactMismatch = { + ...validDownload, + version: "5.34.4", + artifactVersion: "5.34.1", + releaseCoordinateException: "authorized-artifact-version-mismatch", + tag: "v5.34.4", + artifact: "atrinik-classic-client-5.34.1-windows-x86_64.zip", + url: "https://github.com/atrinik/classic/releases/download/v5.34.4/atrinik-classic-client-5.34.1-windows-x86_64.zip", + releaseNotesUrl: "https://github.com/atrinik/classic/releases/tag/v5.34.4", + manifestUrl: + "https://github.com/atrinik/classic/releases/download/v5.34.4/release-manifest.json", + checksumsUrl: + "https://github.com/atrinik/classic/releases/download/v5.34.4/SHA256SUMS", + sbomUrl: + "https://github.com/atrinik/classic/releases/download/v5.34.4/atrinik-classic-5.34.1.spdx.json", + }; + assert.doesNotThrow(() => validateDownload(authorizedArtifactMismatch)); + assert.throws( + () => + validateDownload({ + ...authorizedArtifactMismatch, + artifactVersion: "5.34.2", + artifact: "atrinik-classic-client-5.34.2-windows-x86_64.zip", + url: "https://github.com/atrinik/classic/releases/download/v5.34.4/atrinik-classic-client-5.34.2-windows-x86_64.zip", + sbomUrl: + "https://github.com/atrinik/classic/releases/download/v5.34.4/atrinik-classic-5.34.2.spdx.json", + }), + /explicitly authorized/u, + ); + assert.throws( + () => + validateDownload({ + ...validDownload, + releaseCoordinateException: "authorized-artifact-version-mismatch", + }), + /explicitly authorized/u, + ); + assert.throws( + () => + validateDownload({ + ...authorizedArtifactMismatch, + primary: false, + }), + /explicitly authorized/u, + ); assert.throws( () => validateDownload({ ...validDownload, url: "https://example.com/latest" }), @@ -240,6 +285,8 @@ test("download coordinates are closed and immutable", () => { test("download schema and executable constraints reject the same edge forms", () => { const rejected = [ ["version", "01.2.3"], + ["artifactVersion", "01.2.3"], + ["releaseCoordinateException", "unreviewed-mismatch"], ["tag", "v01.2.3"], ["publishedAt", "2026-99-99T99:99:99Z"], ["publishedAt", "2026-02-31T00:00:00Z"], @@ -359,6 +406,7 @@ test("download catalogs stay empty safely and reject ineligible releases", () => test("download presentation structurally preserves all catalog evidence", () => { const evidence = [ validDownload.version, + validDownload.artifactVersion, validDownload.revision, validDownload.sha256, validDownload.artifact, @@ -385,6 +433,7 @@ test("download presentation structurally preserves all catalog evidence", () => .join(" "); const structures = [ `
Platform
Windows ${validDownload.architecture}
`, + `
Artifact version
${validDownload.artifactVersion}
`, `
Archive
${validDownload.archiveFormat.toUpperCase()}
`, `

Install and compatibility

${validDownload.installation}

${validDownload.compatibility}

`, `

License boundary

Classic software is licensed under ${validDownload.softwareLicense}. ${validDownload.bundledAssetsLicense}

`,