From 8b64eb546127acd7f57d6fa47ddc07c3df47997a Mon Sep 17 00:00:00 2001 From: Jeremy Warren <309008057+Jeremy1844@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:00:53 -0500 Subject: [PATCH 1/4] test(reader): establish regression harness --- package-lock.json | 15 +++++ package.json | 11 ++++ test-support/load-reader.js | 112 ++++++++++++++++++++++++++++++++++++ test/reader-api.test.js | 20 +++++++ 4 files changed, 158 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test-support/load-reader.js create mode 100644 test/reader-api.test.js diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7fbbfe0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "@adventech/sabbath-school-reader", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@adventech/sabbath-school-reader", + "version": "0.1.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0518949 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "@adventech/sabbath-school-reader", + "version": "0.1.0", + "private": true, + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + } +} diff --git a/test-support/load-reader.js b/test-support/load-reader.js new file mode 100644 index 0000000..1b3e117 --- /dev/null +++ b/test-support/load-reader.js @@ -0,0 +1,112 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +function createCollection() { + const collection = {}; + const chainMethods = [ + "addClass", + "after", + "appendTo", + "click", + "css", + "each", + "focusin", + "focusout", + "height", + "hide", + "on", + "removeClass", + "show", + "trigger" + ]; + + chainMethods.forEach((method) => { + collection[method] = () => collection; + }); + + collection.attr = () => collection; + collection.find = () => collection; + collection.is = () => false; + collection.next = () => collection; + collection.parent = () => collection; + collection.val = () => collection; + + return collection; +} + +function loadReader(options = {}) { + const state = { + classAppliers: [], + rangyInitialized: false + }; + const highlighter = { + addClassApplier(applier) { + state.classAppliers.push(applier.className); + }, + deserialize() {}, + highlights: [], + removeAllHighlights() {}, + serialize() { + return ""; + }, + unhighlightSelection() {} + }; + + Object.assign(highlighter, options.highlighter); + + const context = { + Class(definition) { + return definition; + }, + SSBridge: { + focusin() {}, + focusout() {}, + onCommentsClick() {}, + onCopy() {}, + onHighlightClicked() {}, + onReady() {}, + onReceiveHighlights() {}, + onSearch() {}, + onShare() {}, + onVerseClick() {} + }, + clearTimeout, + console, + document: {}, + rangy: { + createClassApplier(className) { + return { className }; + }, + createHighlighter() { + return highlighter; + }, + init() { + state.rangyInitialized = true; + } + }, + setTimeout + }; + + context.window = context; + context.navigator = { userAgent: "node-test" }; + context.$ = (selector) => { + if (typeof selector === "function") { + selector(); + } + return createCollection(); + }; + + vm.createContext(context); + const readerSource = fs.readFileSync( + path.resolve(__dirname, "../js/ss-reader.js"), + "utf8" + ); + vm.runInContext(readerSource, context, { filename: "js/ss-reader.js" }); + + return { context, highlighter, state }; +} + +module.exports = { loadReader }; diff --git a/test/reader-api.test.js b/test/reader-api.test.js new file mode 100644 index 0000000..2264d19 --- /dev/null +++ b/test/reader-api.test.js @@ -0,0 +1,20 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { loadReader } = require("../test-support/load-reader"); + +test("initializes the reader highlighter and its supported annotation classes", () => { + const { context, state } = loadReader(); + + assert.equal(state.rangyInitialized, true); + assert.deepEqual(state.classAppliers, [ + "highlight_orange", + "highlight_yellow", + "highlight_green", + "highlight_blue", + "highlight_underline" + ]); + assert.equal(typeof context.ssReader.setHighlights, "function"); +}); From 7644fb5d60d4c87eb7ce19c66c5f28899660e23b Mon Sep 17 00:00:00 2001 From: Jeremy Warren <309008057+Jeremy1844@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:36:06 -0500 Subject: [PATCH 2/4] fix: make reader artifact reproducible --- .github/workflows/deploy.yml | 38 ++- .gitignore | 3 +- README.md | 21 +- build.js | 78 ----- package-lock.json | 579 +++++++++++++++++++++++++++++++++++ package.json | 4 + scripts/build-reader.js | 212 +++++++++++++ test/build-artifact.test.js | 214 +++++++++++++ 8 files changed, 1060 insertions(+), 89 deletions(-) delete mode 100644 build.js create mode 100644 scripts/build-reader.js create mode 100644 test/build-artifact.test.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7b4bdc1..3c11b41 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,15 +9,35 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Extract branch name + - name: Resolve deployment target + id: target shell: bash - run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" - id: extract_branch - - run: zip -9 -x .git/\* -x .github/\* -x .gitignore -x build.js -x README.md -r sabbath-school-reader-latest.zip . - - run: curl https://s3.amazonaws.com/aws-cli/awscli-bundle.zip -o awscli-bundle.zip - - run: unzip awscli-bundle.zip - - run: ./awscli-bundle/install -b ~/bin/aws - - run: aws s3 cp sabbath-school-reader-latest.zip s3://sabbath-school`[[ "${{ steps.extract_branch.outputs.branch }}" = "stage" ]] && echo '-stage'`.adventech.io/reader/ --acl "public-read" --region us-east-1 --no-progress + run: | + set -euo pipefail + case "$GITHUB_REF_NAME" in + master) bucket="sabbath-school.adventech.io" ;; + stage) bucket="sabbath-school-stage.adventech.io" ;; + *) echo "Unsupported deployment branch: $GITHUB_REF_NAME" >&2; exit 1 ;; + esac + echo "bucket=$bucket" >> "$GITHUB_OUTPUT" + - name: Install locked build dependencies + run: npm ci --ignore-scripts + - name: Test reader + run: npm test + - name: Build source-bound reader artifact + run: npm run build -- --source-sha "$GITHUB_SHA" --output dist + - name: Verify artifact and deployment client + run: | + sha256sum --check dist/sabbath-school-reader-latest.zip.sha256 + aws --version + - name: Upload verified reader artifact + shell: bash + run: | + set -euo pipefail + destination="s3://${{ steps.target.outputs.bucket }}/reader" + aws s3 cp dist/sabbath-school-reader-latest.zip "$destination/sabbath-school-reader-latest.zip" --acl "public-read" --region us-east-1 --no-progress + aws s3 cp dist/sabbath-school-reader-latest.zip.sha256 "$destination/sabbath-school-reader-latest.zip.sha256" --acl "public-read" --region us-east-1 --no-progress + aws s3 cp dist/reader-manifest.json "$destination/reader-manifest.json" --acl "public-read" --region us-east-1 --no-progress env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} \ No newline at end of file + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.gitignore b/.gitignore index 6d84185..4ef8f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .idea -node_modules \ No newline at end of file +node_modules +dist diff --git a/README.md b/README.md index bc90ec2..89c8ffc 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ -### Sabbath School Reader \ No newline at end of file +### Sabbath School Reader + +Install the locked build dependencies and run the tests: + +```sh +npm ci --ignore-scripts +npm test +``` + +Create the deployable reader artifact with the full source commit SHA: + +```sh +npm run build -- --source-sha "$(git rev-parse HEAD)" --output dist +sha256sum --check dist/sabbath-school-reader-latest.zip.sha256 +``` + +The ZIP contains only `index.html`, `css/`, `fonts/`, `js/`, and its +source-bound `reader-manifest.json`. The external manifest and checksum are +written alongside the ZIP. Building twice from the same source produces +identical bytes. diff --git a/build.js b/build.js deleted file mode 100644 index a11f183..0000000 --- a/build.js +++ /dev/null @@ -1,78 +0,0 @@ -var ARTIFACT_NAME = 'sabbath-school-reader-latest.zip', - PROD_PROJECT_ID = 'blistering-inferno-8720', - STAGE_PROJECT_ID = 'sabbath-school-stage', - PROD_KEY_FILENAME = 'deploy-creds.json', - STAGE_KEY_FILENAME = 'deploy-creds-stage.json', - PROD_BUCKET = 'blistering-inferno-8720.appspot.com', - STAGE_BUCKET = 'sabbath-school-stage.appspot.com'; - -var fs = require('fs'), - archiver = require('archiver'), - gstorage = require('@google-cloud/storage'); - -var argv = require('optimist') - .usage("Build script\n" + - "Usage: $0 -b [string]") - .alias({"b": "branch"}) - .describe({ - "b": "branch" - }) - .demand(["b"]) - .argv; - -var branch = argv.b; - -if (branch.toLowerCase() == "master"){ - projectId = PROD_PROJECT_ID; - keyFilename = PROD_KEY_FILENAME; - bucketName = PROD_BUCKET; -} else if (branch.toLowerCase() == "stage") { - projectId = STAGE_PROJECT_ID; - keyFilename = STAGE_KEY_FILENAME; - bucketName = STAGE_BUCKET; -} else { - return; -} - -storage = gstorage({ - projectId: projectId, - keyFilename: keyFilename -}); -bucket = storage.bucket(bucketName); - -// create a file to stream archive data to. -var output = fs.createWriteStream(__dirname + '/' + ARTIFACT_NAME); -var archive = archiver('zip', { - zlib: { level: 9 } // Sets the compression level. -}); - -// listen for all archive data to be written -output.on('close', function() { - console.log(archive.pointer() + ' total bytes'); - console.log('archiver has been finalized and heethe output file descriptor has closed.'); - bucket.upload("./"+ARTIFACT_NAME, function(err, file){ - if (!err){ - console.log(ARTIFACT_NAME + ' (' + archive.pointer() + ' bytes) uploaded'); - } else { - console.log(err); - } - }); -}); - -// good practice to catch this error explicitly -archive.on('error', function(err) { - throw err; -}); - -// pipe archive data to the file -archive.pipe(output); - -// Appending reader related assets -var index = __dirname + '/index.html'; -archive.append(fs.createReadStream(index), { name: 'index.html' }); -archive.directory('css/'); -archive.directory('fonts/'); -archive.directory('js/'); - -// finalize the archive (ie we are done appending files but streams have to finish yet) -archive.finalize(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7fbbfe0..b9ed64a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,585 @@ "": { "name": "@adventech/sabbath-school-reader", "version": "0.1.0", + "devDependencies": { + "archiver": "8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index 0518949..ea37163 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "version": "0.1.0", "private": true, "scripts": { + "build": "node scripts/build-reader.js", "test": "node --test" }, + "devDependencies": { + "archiver": "8.0.0" + }, "engines": { "node": ">=18" } diff --git a/scripts/build-reader.js b/scripts/build-reader.js new file mode 100644 index 0000000..53be093 --- /dev/null +++ b/scripts/build-reader.js @@ -0,0 +1,212 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { ZipArchive } = require("archiver"); + +const repositoryRoot = path.resolve(__dirname, ".."); +const runtimeRoots = ["index.html", "css", "fonts", "js"]; +const artifactName = "sabbath-school-reader-latest.zip"; +const manifestName = "reader-manifest.json"; +const checksumName = `${artifactName}.sha256`; +const stableDate = new Date(Date.UTC(1980, 0, 1, 0, 0, 0)); + +function parseArguments(argv) { + const options = { output: path.join(repositoryRoot, "dist") }; + + for (let index = 0; index < argv.length; index += 2) { + const option = argv[index]; + const value = argv[index + 1]; + if (!value || !["--source-sha", "--output"].includes(option)) { + throw new Error( + "Usage: node scripts/build-reader.js --source-sha <40-hex-sha> [--output ]", + ); + } + if (option === "--source-sha") { + options.sourceSha = value; + } else { + options.output = path.resolve(repositoryRoot, value); + } + } + + if (!/^[0-9a-f]{40}$/i.test(options.sourceSha || "")) { + throw new Error( + "--source-sha must be a full 40-character hexadecimal commit SHA", + ); + } + options.sourceSha = options.sourceSha.toLowerCase(); + return options; +} + +function sha256(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} + +function collectRuntimeFiles() { + const files = []; + + function visit(relativePath) { + const absolutePath = path.join(repositoryRoot, relativePath); + const stat = fs.lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + throw new Error(`Runtime symlinks are not supported: ${relativePath}`); + } + if (stat.isDirectory()) { + for (const entry of fs + .readdirSync(absolutePath) + .sort((left, right) => left.localeCompare(right))) { + visit(path.posix.join(relativePath, entry)); + } + return; + } + if (!stat.isFile()) { + throw new Error(`Unsupported runtime entry: ${relativePath}`); + } + + const normalizedPath = relativePath.replaceAll("\\", "/"); + if ( + normalizedPath.startsWith("/") || + normalizedPath.split("/").includes("..") + ) { + throw new Error(`Unsafe runtime path: ${normalizedPath}`); + } + const bytes = fs.readFileSync(absolutePath); + files.push({ + path: normalizedPath, + bytes, + size: bytes.length, + sha256: sha256(bytes), + }); + } + + for (const runtimeRoot of runtimeRoots) { + visit(runtimeRoot); + } + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +function manifestBytes(sourceSha, files) { + const manifest = { + schemaVersion: 1, + sourceSha, + generator: { + name: "@adventech/sabbath-school-reader", + artifactFormat: 1, + }, + files: files.map((file) => ({ + path: file.path, + bytes: file.size, + sha256: file.sha256, + })), + }; + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +} + +async function writeZip(outputPath, files, manifest) { + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(outputPath, { + flags: "wx", + mode: 0o600, + }); + const archive = new ZipArchive({ + forceLocalTime: false, + forceZip64: false, + zlib: { level: 9 }, + }); + + output.once("close", resolve); + output.once("error", reject); + archive.once("error", reject); + archive.pipe(output); + + for (const file of files) { + archive.append(file.bytes, { + name: file.path, + date: stableDate, + mode: 0o100644, + }); + } + archive.append(manifest, { + name: manifestName, + date: stableDate, + mode: 0o100644, + }); + archive.finalize().catch(reject); + }); +} + +function replaceFile(source, destination) { + fs.rmSync(destination, { force: true }); + fs.renameSync(source, destination); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const files = collectRuntimeFiles(); + const manifest = manifestBytes(options.sourceSha, files); + fs.mkdirSync(options.output, { recursive: true }); + + const temporaryPrefix = `.reader-build-${process.pid}`; + const temporaryZip = path.join(options.output, `${temporaryPrefix}.zip`); + const temporaryManifest = path.join( + options.output, + `${temporaryPrefix}.manifest.json`, + ); + const temporaryChecksum = path.join( + options.output, + `${temporaryPrefix}.sha256`, + ); + const artifactPath = path.join(options.output, artifactName); + const manifestPath = path.join(options.output, manifestName); + const checksumPath = path.join(options.output, checksumName); + + for (const temporaryPath of [ + temporaryZip, + temporaryManifest, + temporaryChecksum, + ]) { + fs.rmSync(temporaryPath, { force: true }); + } + + try { + await writeZip(temporaryZip, files, manifest); + const artifactHash = sha256(fs.readFileSync(temporaryZip)); + fs.writeFileSync(temporaryManifest, manifest, { + encoding: null, + flag: "wx", + mode: 0o600, + }); + fs.writeFileSync(temporaryChecksum, `${artifactHash} ${artifactName}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + + replaceFile(temporaryZip, artifactPath); + replaceFile(temporaryManifest, manifestPath); + replaceFile(temporaryChecksum, checksumPath); + process.stdout.write( + `${JSON.stringify({ + artifact: artifactName, + sha256: artifactHash, + sourceSha: options.sourceSha, + files: files.length, + })}\n`, + ); + } catch (error) { + for (const temporaryPath of [ + temporaryZip, + temporaryManifest, + temporaryChecksum, + ]) { + fs.rmSync(temporaryPath, { force: true }); + } + throw error; + } +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/test/build-artifact.test.js b/test/build-artifact.test.js new file mode 100644 index 0000000..f77ab1d --- /dev/null +++ b/test/build-artifact.test.js @@ -0,0 +1,214 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, ".."); +const buildScript = path.join(repositoryRoot, "scripts", "build-reader.js"); +const deployWorkflow = path.join( + repositoryRoot, + ".github", + "workflows", + "deploy.yml", +); +const sourceSha = "0123456789abcdef0123456789abcdef01234567"; + +function build(outputDirectory, sha = sourceSha) { + return spawnSync( + process.execPath, + [buildScript, "--source-sha", sha, "--output", outputDirectory], + { + cwd: repositoryRoot, + encoding: "utf8", + }, + ); +} + +function sha256(buffer) { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +function runtimeFiles() { + const files = ["index.html"]; + + function visit(relativeDirectory) { + for (const entry of fs + .readdirSync(path.join(repositoryRoot, relativeDirectory), { + withFileTypes: true, + }) + .sort((left, right) => left.name.localeCompare(right.name))) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + visit(relativePath); + } else if (entry.isFile()) { + files.push(relativePath); + } else { + throw new Error(`Unsupported runtime entry: ${relativePath}`); + } + } + } + + for (const directory of ["css", "fonts", "js"]) { + visit(directory); + } + return files.sort(); +} + +function centralDirectoryEntries(zip) { + const endSignature = 0x06054b50; + let endOffset = -1; + for (let offset = zip.length - 22; offset >= 0; offset -= 1) { + if (zip.readUInt32LE(offset) === endSignature) { + endOffset = offset; + break; + } + } + assert.notEqual(endOffset, -1, "ZIP end-of-central-directory record"); + + const entryCount = zip.readUInt16LE(endOffset + 10); + let offset = zip.readUInt32LE(endOffset + 16); + const entries = []; + + for (let index = 0; index < entryCount; index += 1) { + assert.equal(zip.readUInt32LE(offset), 0x02014b50); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + entries.push({ + name: zip + .subarray(offset + 46, offset + 46 + nameLength) + .toString("utf8"), + dosTime: zip.readUInt16LE(offset + 12), + dosDate: zip.readUInt16LE(offset + 14), + }); + offset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +test("builds a byte-for-byte deterministic, narrowly scoped reader ZIP", (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "reader-artifact-"), + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const first = path.join(temporaryRoot, "first"); + const second = path.join(temporaryRoot, "second"); + + const firstResult = build(first); + const secondResult = build(second); + assert.equal(firstResult.status, 0, firstResult.stderr); + assert.equal(secondResult.status, 0, secondResult.stderr); + + const firstZip = fs.readFileSync( + path.join(first, "sabbath-school-reader-latest.zip"), + ); + const secondZip = fs.readFileSync( + path.join(second, "sabbath-school-reader-latest.zip"), + ); + assert.equal(sha256(firstZip), sha256(secondZip)); + + const entries = centralDirectoryEntries(firstZip); + assert.deepEqual( + entries.map((entry) => entry.name), + [...runtimeFiles(), "reader-manifest.json"].sort(), + ); + for (const entry of entries) { + assert.equal(entry.dosTime, 0, `${entry.name} has a stable midnight time`); + assert.equal(entry.dosDate, 33, `${entry.name} has a 1980-01-01 date`); + } +}); + +test("emits a source-bound manifest and matching external checksum", (t) => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "reader-manifest-")); + t.after(() => fs.rmSync(output, { recursive: true, force: true })); + + const result = build(output); + assert.equal(result.status, 0, result.stderr); + const zip = fs.readFileSync( + path.join(output, "sabbath-school-reader-latest.zip"), + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(output, "reader-manifest.json"), "utf8"), + ); + const checksum = fs + .readFileSync( + path.join(output, "sabbath-school-reader-latest.zip.sha256"), + "utf8", + ) + .trim(); + + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.sourceSha, sourceSha); + assert.deepEqual( + manifest.files.map((entry) => entry.path), + runtimeFiles(), + ); + for (const entry of manifest.files) { + const bytes = fs.readFileSync(path.join(repositoryRoot, entry.path)); + assert.equal(entry.bytes, bytes.length); + assert.equal(entry.sha256, sha256(bytes)); + } + assert.equal(checksum, `${sha256(zip)} sabbath-school-reader-latest.zip`); +}); + +test("fails closed without a full source commit SHA", (t) => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "reader-invalid-")); + t.after(() => fs.rmSync(output, { recursive: true, force: true })); + + const missing = spawnSync( + process.execPath, + [buildScript, "--output", output], + { cwd: repositoryRoot, encoding: "utf8" }, + ); + const abbreviated = build(output, "0123456"); + + assert.notEqual(missing.status, 0); + assert.notEqual(abbreviated.status, 0); + assert.equal( + fs.existsSync(path.join(output, "sabbath-school-reader-latest.zip")), + false, + ); +}); + +test("exposes the deterministic builder through the package contract", () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repositoryRoot, "package.json"), "utf8"), + ); + assert.equal(packageJson.scripts.build, "node scripts/build-reader.js"); + assert.equal(packageJson.engines.node, ">=18"); +}); + +test("deploys only the tested, source-bound artifact and verifies its checksum", () => { + const workflow = fs.readFileSync(deployWorkflow, "utf8"); + + assert.match(workflow, /npm ci --ignore-scripts/); + assert.match(workflow, /npm test/); + assert.match( + workflow, + /npm run build -- --source-sha "\$GITHUB_SHA" --output dist/, + ); + assert.match( + workflow, + /sha256sum --check dist\/sabbath-school-reader-latest\.zip\.sha256/, + ); + assert.match(workflow, /aws --version/); + assert.doesNotMatch(workflow, /\bzip\s+-9\b/); + assert.doesNotMatch(workflow, /awscli-bundle/); + assert.doesNotMatch(workflow, /\bcurl\b/); + + for (const output of [ + "sabbath-school-reader-latest.zip", + "sabbath-school-reader-latest.zip.sha256", + "reader-manifest.json", + ]) { + assert.match( + workflow, + new RegExp(`aws s3 cp dist/${output.replaceAll(".", "\\.")}`), + ); + } +}); From d0f60ddbcfb1e1ee522294a666b11200ef34bbbc Mon Sep 17 00:00:00 2001 From: Jeremy Warren <309008057+Jeremy1844@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:20:42 -0500 Subject: [PATCH 3/4] test(RC-0039): reproduce checkout EOL drift --- test/build-artifact.test.js | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/test/build-artifact.test.js b/test/build-artifact.test.js index f77ab1d..9b268c5 100644 --- a/test/build-artifact.test.js +++ b/test/build-artifact.test.js @@ -19,11 +19,21 @@ const deployWorkflow = path.join( const sourceSha = "0123456789abcdef0123456789abcdef01234567"; function build(outputDirectory, sha = sourceSha) { + return buildFromRoot(repositoryRoot, outputDirectory, sha); +} + +function buildFromRoot(root, outputDirectory, sha = sourceSha) { return spawnSync( process.execPath, - [buildScript, "--source-sha", sha, "--output", outputDirectory], + [ + path.join(root, "scripts", "build-reader.js"), + "--source-sha", + sha, + "--output", + outputDirectory, + ], { - cwd: repositoryRoot, + cwd: root, encoding: "utf8", }, ); @@ -33,6 +43,25 @@ function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } +function writeSyntheticRuntime(root, eol, fontBytes) { + const textFiles = new Map([ + ["index.html", "\nReader fixture\n"], + ["css/fixture.css", "body {\n color: #123456;\n}\n"], + ["js/fixture.js", '"use strict";\nwindow.fixture = true;\n'], + ]); + + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.copyFileSync(buildScript, path.join(root, "scripts", "build-reader.js")); + for (const [relativePath, contents] of textFiles) { + const destination = path.join(root, relativePath); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, contents.replaceAll("\n", eol), "utf8"); + } + const fontPath = path.join(root, "fonts", "fixture.ttf"); + fs.mkdirSync(path.dirname(fontPath), { recursive: true }); + fs.writeFileSync(fontPath, fontBytes); +} + function runtimeFiles() { const files = ["index.html"]; @@ -123,6 +152,49 @@ test("builds a byte-for-byte deterministic, narrowly scoped reader ZIP", (t) => } }); +test("builds identical artifacts from equivalent LF and CRLF checkouts", (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(repositoryRoot, ".reader-eol-"), + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const fontBytes = Buffer.from([0x00, 0x0a, 0x0d, 0x1a, 0x80, 0xff]); + const lfRoot = path.join(temporaryRoot, "lf"); + const crlfRoot = path.join(temporaryRoot, "crlf"); + writeSyntheticRuntime(lfRoot, "\n", fontBytes); + writeSyntheticRuntime(crlfRoot, "\r\n", fontBytes); + + const variants = [lfRoot, crlfRoot].map((root) => { + const output = path.join(root, "dist"); + const result = buildFromRoot(root, output); + assert.equal(result.status, 0, result.stderr); + const zip = fs.readFileSync( + path.join(output, "sabbath-school-reader-latest.zip"), + ); + const manifestBytes = fs.readFileSync( + path.join(output, "reader-manifest.json"), + ); + const checksum = fs.readFileSync( + path.join(output, "sabbath-school-reader-latest.zip.sha256"), + ); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + const font = manifest.files.find( + (entry) => entry.path === "fonts/fixture.ttf", + ); + assert.deepEqual(font, { + path: "fonts/fixture.ttf", + bytes: fontBytes.length, + sha256: sha256(fontBytes), + }); + return { + zip: sha256(zip), + manifest: sha256(manifestBytes), + checksum: checksum.toString("utf8"), + }; + }); + + assert.deepEqual(variants[0], variants[1]); +}); + test("emits a source-bound manifest and matching external checksum", (t) => { const output = fs.mkdtempSync(path.join(os.tmpdir(), "reader-manifest-")); t.after(() => fs.rmSync(output, { recursive: true, force: true })); From 2df437bdced5f7a2b5eed8c39a99586f41d559ad Mon Sep 17 00:00:00 2001 From: Jeremy Warren <309008057+Jeremy1844@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:21:57 -0500 Subject: [PATCH 4/4] fix(RC-0039): canonicalize reader text bytes --- .gitattributes | 10 ++++++++++ scripts/build-reader.js | 38 ++++++++++++++++++++++++++++++++++++- test/build-artifact.test.js | 11 ++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6fbdad9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +*.css text eol=lf +*.html text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.ttf binary +.gitattributes text eol=lf +.gitignore text eol=lf diff --git a/scripts/build-reader.js b/scripts/build-reader.js index 53be093..6f00070 100644 --- a/scripts/build-reader.js +++ b/scripts/build-reader.js @@ -11,6 +11,7 @@ const artifactName = "sabbath-school-reader-latest.zip"; const manifestName = "reader-manifest.json"; const checksumName = `${artifactName}.sha256`; const stableDate = new Date(Date.UTC(1980, 0, 1, 0, 0, 0)); +const canonicalTextExtensions = new Set([".css", ".html", ".js"]); function parseArguments(argv) { const options = { output: path.join(repositoryRoot, "dist") }; @@ -43,6 +44,38 @@ function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } +function canonicalRuntimeBytes(relativePath, bytes) { + if (!canonicalTextExtensions.has(path.posix.extname(relativePath))) { + return bytes; + } + + const firstCarriageReturn = bytes.indexOf(0x0d); + if (firstCarriageReturn === -1) { + return bytes; + } + + const canonical = Buffer.allocUnsafe(bytes.length); + bytes.copy(canonical, 0, 0, firstCarriageReturn); + let outputIndex = firstCarriageReturn; + for ( + let inputIndex = firstCarriageReturn; + inputIndex < bytes.length; + inputIndex += 1 + ) { + if (bytes[inputIndex] === 0x0d) { + canonical[outputIndex] = 0x0a; + outputIndex += 1; + if (bytes[inputIndex + 1] === 0x0a) { + inputIndex += 1; + } + } else { + canonical[outputIndex] = bytes[inputIndex]; + outputIndex += 1; + } + } + return canonical.subarray(0, outputIndex); +} + function collectRuntimeFiles() { const files = []; @@ -71,7 +104,10 @@ function collectRuntimeFiles() { ) { throw new Error(`Unsafe runtime path: ${normalizedPath}`); } - const bytes = fs.readFileSync(absolutePath); + const bytes = canonicalRuntimeBytes( + normalizedPath, + fs.readFileSync(absolutePath), + ); files.push({ path: normalizedPath, bytes, diff --git a/test/build-artifact.test.js b/test/build-artifact.test.js index 9b268c5..0fc7921 100644 --- a/test/build-artifact.test.js +++ b/test/build-artifact.test.js @@ -221,7 +221,16 @@ test("emits a source-bound manifest and matching external checksum", (t) => { runtimeFiles(), ); for (const entry of manifest.files) { - const bytes = fs.readFileSync(path.join(repositoryRoot, entry.path)); + const sourceBytes = fs.readFileSync(path.join(repositoryRoot, entry.path)); + const bytes = /\.(?:css|html|js)$/.test(entry.path) + ? Buffer.from( + sourceBytes + .toString("utf8") + .replaceAll("\r\n", "\n") + .replaceAll("\r", "\n"), + "utf8", + ) + : sourceBytes; assert.equal(entry.bytes, bytes.length); assert.equal(entry.sha256, sha256(bytes)); }