Pinning

Where each dataset stands on tags and releases, how to pin one, and how to prove the bytes you hold are the bytes the annotations were computed against.

Time
About 8 minutes

For what you may do with those bytes, see Licensing.

The state today

Checked against the repositories on 11 September 2026. This table will age; treat it as a snapshot, not a contract.

PackageTagsReleasesHow you pin today
Quran Textnonenonecommit SHA
Qiraat Ayah Mapnonenonecommit SHA
Quran Assetsnonenonecommit SHA
QuranTech skillnonenonecommit SHA
Quran SVGv1.0.0v1.0.0, no attached filestag or commit SHA
Quran SVG Elementsv1.0.0v1.0.0 + a 108 MB tarball with its SHA-256release tag, then the digest
Quran Enginev0.1.0v0.1.0 + a page-data tarball with its SHA-256release tag, then the digest
Quran Tajweedv0.2.0, v0.3.0, v0.4.0three, each with versioned JSON filesrelease tag, then the edition digest
Quran PNGartwork-v1.0.0one artwork bundlenot pinnable — see below

Quran PNG is a hosted service rather than a dataset, and its v query parameter does not select a renderer version: ?v=1 and ?v=9 both answer with x-render-version: 3 today. It is a cache key, not a pin, so a URL you froze a year ago serves whatever the renderer produces now. Watch x-render-version in the response if that matters to you.

Nothing is published to npm, PyPI, Packagist, pub.dev or Maven Central. Quran Text's own changelog says it: "This project has not had a tagged release yet; everything below is unreleased."

A manifest version is not a release

The QuranTech skill’s plugin.json declares "version": "1.0.0", and Qiraat Ayah Map stamps _version: "0.1.0" into every generated file from a package.json marked "private": true. Neither number is attached to anything published. Two people who both say they are on 1.0.0 can be holding different files, and nothing in either repository will tell them so. Until a tag exists, the commit is the version (Versioning and corrections, section 8).

Three things you can pin

PinProvesWhere you get it
a commit SHAthe repository tree you readgh api repos/OWNER/REPO/commits/HEAD --jq .sha
a release asset digestthe exact archive you downloadedthe .sha256 published beside the tarball
a content digest inside the datathe exact text an annotation set was computed againsta field in the data — see what a digest proves

They answer different questions; the third is the one that catches the bug below.

Pin a dataset

This site does this in scripts/sync-demo-data.mjs; the record it keeps is the worked example.

  1. Resolve the commit once

    gh api repos/quran-ws/quran-text/commits/HEAD --jq .sha
    

    Do this once, by hand, when you adopt the dataset — not on every build. A script that resolves HEAD at build time is not pinned to anything.

  2. Read every file at that ref

    gh api "repos/quran-ws/quran-text/contents/data/catalog.json?ref=$SHA" --jq .content | base64 -d
    

    Quote the URL. In zsh the ? is a glob character and the command fails without quotes. For files over 1 MB the API returns an empty content; read .download_url and fetch that instead.

  3. Commit the SHA to your repository

    Put it in a file your reviewers see, so that changing the data is a diff someone approves. Ours lives in public/demo/SOURCES.json:

    Payload built fromRepository readCommit pinned
    quran-svgquran-ws/quran-svg9fc0c87aebd7
    quran-textquran-ws/quran-text2aac0b1feefb
    qiraat-ayah-mapquran-ws/qiraat-ayah-map7f38fc0e3c49
    quran-tajweedquran-ws/quran-tajweed992d1484a41e
    quran-svg-elementsquran-ws/quran-svg-elementsde4a3b8c6b94
    quran-assetsquran-ws/quran-assets591e8f515228
    quran-enginequran-ws/quran-enginecf021bbe3831

    Read from public/demo/SOURCES.json, recorded 2026-09-12. Commits are shown to twelve characters; the file holds the full forty.

  4. Verify what you got

    Every dataset publishes digests. The next section is which one to check for each.

What a digest proves

A digest proves one thing exactly: the bytes you hold are the bytes that were hashed. It says nothing about whether those bytes are correct, current, or the ones you wanted. The failure it catches does not announce itself.

The wrong-but-plausible text

Editions of the Uthmānī script that any reader would call identical are not identical as data. They disagree about where a small high seen sits, whether a hamzah is carried on a tatwīl, how tanwīn is drawn at a word end. Any one of those shifts every character offset after it. Apply Quran Tajweed spans to a text one character out and nothing throws; the colouring lands on the wrong letters and the error is found by a user who knows the text. Check the digest at load time and it becomes an exception instead.

Quran Tajweed pins the text, not the file

The annotations record the digest of the text they were computed against, and the digest is taken over the content in muṣḥaf order rather than over the JSON file — so re-serialising, re-indenting or reordering keys does not look like a different edition. The check is the same one shown on the Quran Tajweed page.

// Verified against tajweed-annotations-uthmani-hafs-v0.4.0.json and
// editions/uthmani-hafs.json at tag v0.4.0.
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";

const edition = JSON.parse(readFileSync("editions/uthmani-hafs.json", "utf8"));
const annotations = JSON.parse(
  readFileSync("tajweed-annotations-uthmani-hafs-v0.4.0.json", "utf8"),
);

const refs = Object.keys(edition.ayahs).sort((a, b) => {
  const [sa, aa] = a.split(":").map(Number);
  const [sb, ab] = b.split(":").map(Number);
  return sa - sb || aa - ab;
});

const FIELD = String.fromCharCode(0);   // separates a reference from its text
const RECORD = String.fromCharCode(1);  // separates one ayah from the next

const canonical = refs.map((r) => `${r}${FIELD}${edition.ayahs[r]}`).join(RECORD);
const digest = createHash("sha256").update(Buffer.from(canonical, "utf8")).digest("hex");

digest === annotations.edition.sha256;
// true — b5d29736bb3ef49d9d331c4e60a59d83fe899b921e9e8dc35911bd4a18ce55f3

The package exports this as editionDigest() and assertEdition(). If you reimplement it in another language, the two control characters are part of the contract.

Quran Text ships a manifest of every file

data/manifest.json lists the SHA-256 and byte length of every emitted file — 43 of them at the commit this page was written against — and, separately, the SHA-256 of every KFGQPC source package the build read.

// Verified against data/manifest.json at commit fea25cd.
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";

const manifest = JSON.parse(readFileSync("data/manifest.json", "utf8"));

for (const f of manifest.files) {
  let bytes;
  try {
    bytes = readFileSync(f.path);
  } catch {
    continue;                       // a file you did not download is not a failure
  }
  const got = createHash("sha256").update(bytes).digest("hex");
  console.log(`${got === f.sha256 ? "ok  " : "BAD "} ${f.path}`);
}
// ok   data/catalog.json

One line per file you actually hold; files listed in the manifest but absent from your copy are skipped rather than failing, so the check works on a partial download too.

The fonts carry their own digest in each muṣḥaf file's font block, which is what this site checks before converting one — a font swapped for a similar-looking one renders text that is wrong in ways a non-reader cannot see.

Quran SVG Elements publishes two digests, and one command that lies

The release publishes quran-svg-hafs-kfgqpc.tar.gz.sha256 beside the tarball. Check that first; it is the cheap one and it covers everything.

shasum -a 256 -c quran-svg-hafs-kfgqpc.tar.gz.sha256
# quran-svg-hafs-kfgqpc.tar.gz: OK

Inside the bundle, CHECKSUMS.txt covers every file. The obvious invocation fails on a bundle that is completely intact:

cd quran-svg-hafs-kfgqpc
shasum -a 256 -c CHECKSUMS.txt > /dev/null
# shasum: WARNING: 2424 listed files could not be read
# exit 1

shasum -a 256 --ignore-missing -c CHECKSUMS.txt | grep -c ': OK$'
# 1223       — and the command exits 0
3,647 listed, 1,223 shipped

CHECKSUMS.txt lists 3,647 files, including 2,424 pre-compressed .svg.br and .svg.gz copies that the tarball does not contain. Without --ignore-missing the check exits non-zero on a good bundle, so a CI job that treats non-zero as corruption will reject every download. Verified by extracting the published v1.0.0 tarball on 11 September 2026; the tarball digest matched.

The rules behind this

Which number means what, and what pinning cannot promise, are in Versioning and corrections, section 8, alongside the rule that the commit is the version until a dataset is tagged.

Re-fetching HEAD in CI is not a pin

A build that resolves HEAD, or downloads “latest”, produces a different dataset on a day you did not change anything, and because Qurʾānic text renders identically whether or not a boundary moved, nothing in your test suite is likely to notice. Pin the SHA, check the digest, and let the diff be a review.