From c140da5890f963f8911d4cc9ecc6827e124ac6a5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 28 Jul 2026 20:13:43 +0700 Subject: [PATCH 01/11] fix(dashmate)!: give Debian packages versions apt can order oclif builds the Debian version as .-1, which discards the semver prerelease tag and makes the git sha an ordering component. Under dpkg's comparison digits sort below letters, so ordering between builds is effectively random: of the four real transitions in the 4.1.0 series, apt reads two as downgrades. A same-version security rebuild is worse still, since it compares lower and apt reports the package as already newest while the operator believes they are patched. Versions are now Debian-idiomatic and monotonic: 4.1.0~rc.3-1 sorts below 4.1.0-1, rebuilds bump the Debian revision, and the sha moves into the package description where it cannot affect ordering. Verified against real dpkg, which also confirms this ordering agrees with semver precedence for every prerelease form the mapping can produce. Filenames deliberately diverge from the control version: GitHub rewrites the tilde and colon in release asset names, so both are stripped from the filename while the control field keeps them. dpkg-name strips epochs from filenames for the same reason. BREAKING CHANGE: Debian package filenames and versions change shape. A rebuild of an already published version needs DASHMATE_DEB_REVISION, and re-releasing a version published under the old scheme needs DASHMATE_DEB_EPOCH. Test would have caught this in CI: 4 of the new specs fail before the fix. Co-Authored-By: Claude Opus 5 --- packages/dashmate/docs/installation.md | 12 +- .../test/unit/packaging/debVersion.spec.js | 301 ++++++++++++++++++ scripts/check_deb_version.sh | 59 ++++ scripts/deb_version.js | 128 ++++++++ scripts/pack_dashmate.sh | 138 +++++++- 5 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 packages/dashmate/test/unit/packaging/debVersion.spec.js create mode 100755 scripts/check_deb_version.sh create mode 100644 scripts/deb_version.js diff --git a/packages/dashmate/docs/installation.md b/packages/dashmate/docs/installation.md index c367b811cb9..f35e198095a 100644 --- a/packages/dashmate/docs/installation.md +++ b/packages/dashmate/docs/installation.md @@ -20,22 +20,26 @@ Installing the Linux, MacOS, or Windows packages from the [GitHub releases page] ### Debian package -Download the newest dashmate installation package for your architecture from the [GitHub releases page](https://github.com/dashpay/platform/releases/latest): +Download the newest dashmate installation package for your architecture from the [GitHub releases page](https://github.com/dashpay/platform/releases/latest). +The file name contains the version, so it changes with every release; this downloads the one matching the architecture you are on: ```bash -wget https://github.com/dashpay/platform/releases/download/v1.8.0/dashmate_1.8.0.e4e156c86-1_amd64.deb +curl -fsSL https://api.github.com/repos/dashpay/platform/releases/latest \ + | grep -o "https://[^\"]*_$(dpkg --print-architecture)\.deb" \ + | head -n 1 \ + | xargs curl -fLO ``` Install dashmate using apt: ```bash sudo apt update -sudo apt install ./dashmate_1.8.0.e4e156c86-1_amd64.deb +sudo apt install ./dashmate_*.deb ``` > **Note:** At the end of the installation process, apt may display an error due to installing a downloaded package. > You can ignore this error message: -> N: Download is performed unsandboxed as root as file '/home/ubuntu/dashmate_1.8.0.e4e156c86-1_amd64.deb' couldn't be accessed by user '_apt'. - pkgAcquire::Run (13: Permission denied) +> N: Download is performed unsandboxed as root as file '/home/ubuntu/dashmate_4.1.0-1_amd64.deb' couldn't be accessed by user '_apt'. - pkgAcquire::Run (13: Permission denied) ### Node package diff --git a/packages/dashmate/test/unit/packaging/debVersion.spec.js b/packages/dashmate/test/unit/packaging/debVersion.spec.js new file mode 100644 index 00000000000..34141a4e8c9 --- /dev/null +++ b/packages/dashmate/test/unit/packaging/debVersion.spec.js @@ -0,0 +1,301 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { debVersionFromSemver, debFileNameVersion } from '../../../../../scripts/deb_version.js'; + +const SCRIPT_PATH = fileURLToPath(new URL('../../../../../scripts/deb_version.js', import.meta.url)); + +/** + * Independent port of dpkg's version comparison (`verrevcmp` in dpkg's version.c), + * used as the oracle here so the expectations below describe what apt actually does + * rather than whatever the mapping under test happens to produce. + */ +function order(char) { + if (char === undefined) { + return 0; + } + + if (char >= '0' && char <= '9') { + return 0; + } + + if ((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')) { + return char.charCodeAt(0); + } + + if (char === '~') { + return -1; + } + + return char.charCodeAt(0) + 256; +} + +function isDigit(char) { + return char !== undefined && char >= '0' && char <= '9'; +} + +function verrevcmp(left, right) { + let i = 0; + let j = 0; + + while (i < left.length || j < right.length) { + let firstDiff = 0; + + while ((i < left.length && !isDigit(left[i])) || (j < right.length && !isDigit(right[j]))) { + const leftOrder = order(left[i]); + const rightOrder = order(right[j]); + + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + i += 1; + j += 1; + } + + while (left[i] === '0') { + i += 1; + } + + while (right[j] === '0') { + j += 1; + } + + while (isDigit(left[i]) && isDigit(right[j])) { + if (firstDiff === 0) { + firstDiff = left.charCodeAt(i) - right.charCodeAt(j); + } + + i += 1; + j += 1; + } + + if (isDigit(left[i])) { + return 1; + } + + if (isDigit(right[j])) { + return -1; + } + + if (firstDiff !== 0) { + return firstDiff; + } + } + + return 0; +} + +function parseDebVersion(version) { + const [, epoch = '0', rest] = /^(?:(\d+):)?(.*)$/.exec(version); + const revisionAt = rest.lastIndexOf('-'); + + return { + epoch: Number(epoch), + revision: revisionAt === -1 ? '' : rest.slice(revisionAt + 1), + upstream: revisionAt === -1 ? rest : rest.slice(0, revisionAt), + }; +} + +/** + * @returns {number} negative when left sorts below right, 0 when equal, positive above + */ +function compareDebVersions(left, right) { + const a = parseDebVersion(left); + const b = parseDebVersion(right); + + if (a.epoch !== b.epoch) { + return a.epoch - b.epoch; + } + + const upstream = verrevcmp(a.upstream, b.upstream); + + return upstream === 0 ? verrevcmp(a.revision, b.revision) : upstream; +} + +describe('deb_version.js', () => { + describe('version comparison oracle', () => { + it('should order versions the way dpkg does', () => { + expect(compareDebVersions('4.1.0', '4.1.0')).to.equal(0); + expect(compareDebVersions('4.1.0', '4.2.0')).to.be.below(0); + expect(compareDebVersions('4.1.0', '4.1.10')).to.be.below(0); + // `~` sorts below everything, including the end of the string + expect(compareDebVersions('4.1.0~rc.1', '4.1.0')).to.be.below(0); + // letters sort above digits, which is what makes a git sha unusable as a version part + expect(compareDebVersions('4.1.0.a', '4.1.0.9')).to.be.above(0); + // the Debian revision breaks ties on an identical upstream version + expect(compareDebVersions('4.1.0-2', '4.1.0-1')).to.be.above(0); + // an epoch outranks any upstream version + expect(compareDebVersions('1:1.0.0', '99.0.0')).to.be.above(0); + }); + }); + + describe('versions produced by the legacy `.-1` scheme', () => { + // Exactly what was published for the 4.1.0 series. + const published = { + 'v4.1.0': '4.1.0.bfc80249b9-1', + 'v4.1.0-beta.2': '4.1.0.ae554fdd83-1', + 'v4.1.0-rc.1': '4.1.0.08152ea51e-1', + 'v4.1.0-rc.2': '4.1.0.3de436123d-1', + 'v4.1.0-rc.3': '4.1.0.61be67f7bf-1', + }; + + it('should make half of the real 4.1.0 releases look like downgrades to apt', () => { + expect(compareDebVersions(published['v4.1.0-rc.1'], published['v4.1.0-beta.2'])).to.be.below(0); + expect(compareDebVersions(published['v4.1.0-rc.2'], published['v4.1.0-rc.1'])).to.be.below(0); + // and let the other half through, so the ordering is effectively arbitrary + expect(compareDebVersions(published['v4.1.0-rc.3'], published['v4.1.0-rc.2'])).to.be.above(0); + expect(compareDebVersions(published['v4.1.0'], published['v4.1.0-rc.3'])).to.be.above(0); + }); + + it('should never ship a rebuild of an already published version', () => { + const hotfix = '4.1.0.284f02fabb-1'; + + expect(compareDebVersions(hotfix, published['v4.1.0'])).to.be.below(0); + }); + }); + + describe('#debVersionFromSemver', () => { + it('should give a stable release the first Debian revision', () => { + expect(debVersionFromSemver('4.1.0')).to.equal('4.1.0-1'); + expect(debVersionFromSemver('v4.1.0')).to.equal('4.1.0-1'); + }); + + it('should keep a prerelease below its final release', () => { + expect(debVersionFromSemver('4.1.0-rc.3')).to.equal('4.1.0~rc.3-1'); + + expect(compareDebVersions( + debVersionFromSemver('4.1.0-rc.3'), + debVersionFromSemver('4.1.0'), + )).to.be.below(0); + }); + + it('should order every release of the 4.1.0 series upward', () => { + const releases = ['4.1.0-beta.2', '4.1.0-rc.1', '4.1.0-rc.2', '4.1.0-rc.3', '4.1.0', '4.1.1']; + + releases.slice(1).forEach((release, index) => { + const previous = debVersionFromSemver(releases[index]); + const next = debVersionFromSemver(release); + + expect(compareDebVersions(next, previous)).to.be.above( + 0, + `${next} must sort above ${previous}`, + ); + }); + }); + + // Inherent to any correct scheme, semver included, and the reason prereleases cannot + // share a suite with stable: apt offers whatever sorts highest, so a stable node would + // be walked onto a release candidate. + it('should sort a prerelease of the next release above the current stable one', () => { + expect(compareDebVersions( + debVersionFromSemver('4.2.0-rc.1'), + debVersionFromSemver('4.1.0'), + )).to.be.above(0); + }); + + it('should let a rebuild of the same version overtake the published one', () => { + expect(debVersionFromSemver('4.1.0', { revision: '2' })).to.equal('4.1.0-2'); + + expect(compareDebVersions( + debVersionFromSemver('4.1.0', { revision: '2' }), + debVersionFromSemver('4.1.0'), + )).to.be.above(0); + }); + + it('should overtake a legacy git sha version with an epoch', () => { + const legacy = '4.1.0.bfc80249b9-1'; + + // Without an epoch the legacy version wins, because `.bfc80249b9` extends `4.1.0` + expect(compareDebVersions(debVersionFromSemver('4.1.0'), legacy)).to.be.below(0); + + expect(debVersionFromSemver('4.1.0', { epoch: '1' })).to.equal('1:4.1.0-1'); + expect(compareDebVersions(debVersionFromSemver('4.1.0', { epoch: '1' }), legacy)).to.be.above(0); + }); + + it('should treat an empty epoch as no epoch at all', () => { + expect(debVersionFromSemver('4.1.0', { epoch: '' })).to.equal('4.1.0-1'); + }); + + it('should keep build metadata out of the upstream version', () => { + // Semver gives build metadata no weight when ordering, so putting it in the upstream + // version would let `4.1.0+build.5` outrank the plain `4.1.0` release + expect(debVersionFromSemver('4.1.0+build.5')).to.equal('4.1.0-1+build.5'); + + const repackaged = debVersionFromSemver('4.1.0+build.5'); + + // it is still a distinct build, so it has to be installable over the plain one + expect(compareDebVersions(repackaged, debVersionFromSemver('4.1.0'))).to.be.above(0); + // but it must not overtake a revision bump or the next release + expect(compareDebVersions(repackaged, debVersionFromSemver('4.1.0', { revision: '2' }))).to.be.below(0); + expect(compareDebVersions(repackaged, debVersionFromSemver('4.1.1'))).to.be.below(0); + }); + + it('should refuse anything that is not a version it can translate', () => { + expect(() => debVersionFromSemver('4.1')).to.throw('not a version'); + expect(() => debVersionFromSemver('4.1.0-rc.3; rm -rf /')).to.throw('not a version'); + // a `-` inside the prerelease would move where dpkg splits off the Debian revision + expect(() => debVersionFromSemver('4.1.0-rc-3')).to.throw('not a version'); + expect(() => debVersionFromSemver('4.1.0', { revision: '1; id' })).to.throw('revision'); + expect(() => debVersionFromSemver('4.1.0', { epoch: 'x' })).to.throw('epoch'); + }); + + it('should refuse leading zeroes, which dpkg would read as the same version', () => { + expect(compareDebVersions('4.1.0~rc.01-1', '4.1.0~rc.1-1')).to.equal(0); + expect(compareDebVersions('04.1.0-1', '4.1.0-1')).to.equal(0); + + expect(() => debVersionFromSemver('4.1.0-rc.01')).to.throw('not a version'); + expect(() => debVersionFromSemver('04.1.0')).to.throw('not a version'); + expect(() => debVersionFromSemver('4.01.0')).to.throw('not a version'); + }); + }); + + describe('#debFileNameVersion', () => { + it('should leave a version that is already safe to publish alone', () => { + expect(debFileNameVersion('4.1.0-1')).to.equal('4.1.0-1'); + }); + + // GitHub rewrites characters like `~` when a release asset is uploaded, which would + // leave the published file name disagreeing with the index that points at it. + it('should drop the tilde a prerelease is published with', () => { + expect(debFileNameVersion('4.1.0~rc.3-1')).to.equal('4.1.0.rc.3-1'); + expect(debFileNameVersion(debVersionFromSemver('4.1.0-beta.2'))).to.equal('4.1.0.beta.2-1'); + }); + + // Debian leaves the epoch out of file names, and `:` would not survive publishing either + it('should drop the epoch', () => { + expect(debFileNameVersion('1:4.1.0-1')).to.equal('4.1.0-1'); + expect(debFileNameVersion(debVersionFromSemver('4.1.0-rc.3', { epoch: '2' }))).to.equal('4.1.0.rc.3-1'); + }); + + it('should not change the version apt installs', () => { + // the file name is cosmetic; ordering comes from the control field, which keeps `~` + expect(debVersionFromSemver('4.1.0-rc.3')).to.equal('4.1.0~rc.3-1'); + expect(compareDebVersions( + debVersionFromSemver('4.1.0-rc.3'), + debVersionFromSemver('4.1.0'), + )).to.be.below(0); + }); + }); + + describe('command line', () => { + function run(version, env) { + return execFileSync(process.execPath, [SCRIPT_PATH, version], { + encoding: 'utf8', + env: { ...process.env, ...env }, + }).trim(); + } + + it('should take the revision and the epoch from the environment', () => { + expect(run('v4.1.0', { DASHMATE_DEB_REVISION: '2' })).to.equal('4.1.0-2'); + expect(run('v4.1.0', { DASHMATE_DEB_EPOCH: '1' })).to.equal('1:4.1.0-1'); + }); + + // The release workflow declares both variables in one place so that the version gate + // and the packaging job cannot read different values. That exports the epoch as set + // but empty, which has to mean the same thing as not setting it at all. + it('should ignore an epoch that is set to an empty value', () => { + expect(run('v4.1.0', { DASHMATE_DEB_REVISION: '1', DASHMATE_DEB_EPOCH: '' })).to.equal('4.1.0-1'); + expect(run('v4.1.0-rc.3', { DASHMATE_DEB_REVISION: '1', DASHMATE_DEB_EPOCH: '' })).to.equal('4.1.0~rc.3-1'); + }); + }); +}); diff --git a/scripts/check_deb_version.sh b/scripts/check_deb_version.sh new file mode 100755 index 00000000000..c878cc3f9ca --- /dev/null +++ b/scripts/check_deb_version.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +set -e + +cmd_usage="Usage: check_deb_version.sh NEW_VERSION PREVIOUS_VERSION + +Exits successfully only when NEW_VERSION sorts strictly above PREVIOUS_VERSION under +dpkg's version comparison, which is what decides whether apt offers a release as an +upgrade at all. + +Both arguments are Debian package versions ([EPOCH:]UPSTREAM[-REVISION]), not semver +tags. Translate a semver version first: + + scripts/check_deb_version.sh \\ + \"\$(node scripts/deb_version.js 4.1.0-rc.4)\" \\ + \"\$(node scripts/deb_version.js 4.1.0-rc.3)\" + + EXIT CODES: + 0 new version sorts above the previous one + 1 new version is equal to or below the previous one + 2 wrong arguments + 3 dpkg is unavailable, so the comparison could not be made +" + +NEW_VERSION="$1" +PREVIOUS_VERSION="$2" + +if [ -z "$NEW_VERSION" ] || [ -z "$PREVIOUS_VERSION" ] +then + echo "$cmd_usage" >&2 + exit 2 +fi + +# dpkg is the only authority on its own ordering rules, and the rules are subtle enough +# (`~` below the empty string, digits and letters ordered differently) that guessing here +# would defeat the point of the check. Refuse to answer instead of answering wrongly. +if ! command -v dpkg > /dev/null 2>&1 +then + echo "check_deb_version.sh: dpkg not found, cannot compare Debian versions." >&2 + echo "Run this on a Debian based host or inside a container that has dpkg." >&2 + exit 3 +fi + +if dpkg --compare-versions "$NEW_VERSION" gt "$PREVIOUS_VERSION" +then + echo "$NEW_VERSION sorts above $PREVIOUS_VERSION" + exit 0 +fi + +if dpkg --compare-versions "$NEW_VERSION" eq "$PREVIOUS_VERSION" +then + echo "$NEW_VERSION is the version that is already published." >&2 + echo "Set DASHMATE_DEB_REVISION to the next Debian revision to rebuild it." >&2 + exit 1 +fi + +echo "$NEW_VERSION sorts below $PREVIOUS_VERSION, so apt would refuse it as a downgrade." >&2 +echo "Set DASHMATE_DEB_EPOCH to overtake a version published under a different scheme." >&2 +exit 1 diff --git a/scripts/deb_version.js b/scripts/deb_version.js new file mode 100644 index 00000000000..4b7c56d0e5e --- /dev/null +++ b/scripts/deb_version.js @@ -0,0 +1,128 @@ +/** + * Translate a semver version into a Debian package version, and into the file name that + * version is published under. + * + * oclif builds the deb version as `.-1`. That drops the + * semver prerelease tag and turns the git sha into an ordering component: dpkg orders + * digits as equal and letters by their character code, so a sha starting with a digit + * sorts below one starting with a letter. Roughly half of the releases published that + * way look like downgrades to apt, and a rebuild of an already published version can + * never be installed at all. + * + * Debian's own idiom is used instead. `~` sorts below everything, including the end of + * the string, so a prerelease stays below its final release, and rebuilds of the same + * upstream version are distinguished by the Debian revision: + * + * 4.1.0 -> 4.1.0-1 + * 4.1.0-rc.3 -> 4.1.0~rc.3-1 + * 4.1.0, second build -> 4.1.0-2 + * + * The git sha is not part of the version; it is carried in the package description. + * + * An epoch is available for the one case the scheme cannot express: an upstream version + * that was already published under the git sha scheme, where `4.1.0-1` sorts below the + * published `4.1.0.bfc80249b9-1` because the sha extends the upstream version. + */ + +// Version identifiers follow semver: no leading zeros, because `4.1.0-rc.01` and +// `4.1.0-rc.1` are two distinct tags that dpkg considers the same version. +const NUMERIC_IDENTIFIER = '(?:0|[1-9]\\d*)'; +// The prerelease is deliberately restricted to alphanumerics and dots. A `-` there would +// end up in the Debian upstream version and move where dpkg splits off the revision. +const PRERELEASE_IDENTIFIER = '(?:0|[1-9]\\d*|\\d*[A-Za-z][0-9A-Za-z]*)'; +const BUILD_IDENTIFIER = '[0-9A-Za-z]+'; + +const VERSION_REGEX = new RegExp(`^v?(${NUMERIC_IDENTIFIER}\\.${NUMERIC_IDENTIFIER}\\.${NUMERIC_IDENTIFIER})` + + `(?:-(${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))?` + + `(?:\\+(${BUILD_IDENTIFIER}(?:\\.${BUILD_IDENTIFIER})*))?$`); + +// Debian revisions are alphanumerics plus `+ . ~`; starting with a digit keeps them sortable. +const REVISION_REGEX = /^\d[0-9A-Za-z.+~]*$/; +const EPOCH_REGEX = /^\d+$/; + +/** + * @param {string} version - semver version, with or without a leading `v` + * @param {object} [options] + * @param {string} [options.revision] - Debian revision, bumped for rebuilds of one version + * @param {string} [options.epoch] - Debian epoch, omitted when empty + * @returns {string} + */ +function debVersionFromSemver(version, options = {}) { + const { revision = '1', epoch = '' } = options; + + const parsed = VERSION_REGEX.exec(String(version).trim()); + + if (parsed === null) { + throw new Error(`"${version}" is not a version that can be translated to a Debian version.` + + ' Expected MAJOR.MINOR.PATCH with an optional alphanumeric prerelease, for example' + + ' 4.1.0 or 4.1.0-rc.3'); + } + + if (!REVISION_REGEX.test(String(revision))) { + throw new Error(`"${revision}" is not a valid Debian revision. Expected a number, optionally` + + ' followed by alphanumerics, dots, pluses or tildes'); + } + + if (epoch !== '' && !EPOCH_REGEX.test(String(epoch))) { + throw new Error(`"${epoch}" is not a valid Debian epoch. Expected a number`); + } + + const [, release, prerelease, build] = parsed; + + const upstream = release + (prerelease === undefined ? '' : `~${prerelease}`); + + // Semver gives build metadata no weight when ordering versions, so it must not reach the + // upstream version, where dpkg would sort `4.1.0+build.5` above plain `4.1.0`. It marks a + // repackaging of one upstream release, which is what the Debian revision is for. + const debianRevision = build === undefined ? revision : `${revision}+${build}`; + + return `${epoch === '' ? '' : `${epoch}:`}${upstream}-${debianRevision}`; +} + +/** + * The version as it appears in the package file name. + * + * Debian leaves the epoch out of file names. `~` is dropped as well, because GitHub + * rewrites characters like it when a release asset is uploaded, and the renamed asset + * would no longer match the `Filename:` field of the apt index that points at it. Only + * the name changes; the version apt installs comes from the control file. + * + * @param {string} debVersion - as returned by debVersionFromSemver + * @returns {string} + */ +function debFileNameVersion(debVersion) { + return debVersion.replace(/^\d+:/, '').replace(/~/g, '.'); +} + +module.exports.debVersionFromSemver = debVersionFromSemver; +module.exports.debFileNameVersion = debFileNameVersion; + +if (require.main === module) { + const args = process.argv.slice(2); + const forFileName = args[0] === '--file-name'; + const version = forFileName ? args[1] : args[0]; + + if (!version) { + console.error('Usage: deb_version.js [--file-name] SEMVER_VERSION\n\n' + + ' Prints the Debian package version for a semver version, or the version as it\n' + + ' appears in the package file name.\n\n' + + ' DASHMATE_DEB_REVISION Debian revision, default 1. Bump it to rebuild a version\n' + + ' that was already published.\n' + + ' DASHMATE_DEB_EPOCH Debian epoch, unset by default.\n'); + + process.exit(1); + } + + try { + const debVersion = debVersionFromSemver(version, { + revision: process.env.DASHMATE_DEB_REVISION || '1', + epoch: process.env.DASHMATE_DEB_EPOCH || '', + }); + + console.log(forFileName ? debFileNameVersion(debVersion) : debVersion); + } catch (e) { + console.error(e.message); + + process.exit(1); + } +} diff --git a/scripts/pack_dashmate.sh b/scripts/pack_dashmate.sh index 063239b7b1d..d78de7353d1 100755 --- a/scripts/pack_dashmate.sh +++ b/scripts/pack_dashmate.sh @@ -39,6 +39,136 @@ FULL_PATH=$(realpath "$0") DIR_PATH=$(dirname "$FULL_PATH") ROOT_PATH=$(dirname "$DIR_PATH") +# oclif hardcodes the deb version as `.-1`. That drops the +# semver prerelease tag and makes the git sha an ordering component: dpkg orders digits +# as equal and letters by character code, so a sha starting with a digit sorts below one +# starting with a letter. Apt then reads about half of the releases as downgrades, and a +# rebuild of an already published version is never offered at all. +# +# Rebuild the packages with the Debian idiom instead (4.1.0-1, 4.1.0~rc.3-1, rebuilds +# bumping the Debian revision through DASHMATE_DEB_REVISION) and carry the git sha in the +# package description, where it has no ordering weight. The packages go back through +# dpkg-deb rather than being patched in place so they keep whatever archive format and +# compression the system dpkg produces. Nothing else in the control file has to be kept in +# step with the payload, because oclif's template declares neither md5sums nor +# Installed-Size. +# +# The file name is not the version: it carries no epoch, and no `~`, so that the name +# survives being uploaded as a release asset and still matches the index that points at it. +rewrite_deb_versions() { + DIST_PATH="$1" + STAGING_PATH="tmp/rewritten" + + if ! ls "$DIST_PATH"/*.deb > /dev/null 2>&1 + then + echo "No deb packages to rewrite in $DIST_PATH" + exit 1 + fi + + SEMVER_VERSION=$(node -p "require('./package.json').version") + DEB_VERSION=$(node "$DIR_PATH/deb_version.js" "$SEMVER_VERSION") + DEB_FILE_VERSION=$(node "$DIR_PATH/deb_version.js" --file-name "$SEMVER_VERSION") + + rm -rf "$STAGING_PATH" + mkdir -p "$STAGING_PATH" + + # Every package is rebuilt into a staging directory and only swapped in once they have + # all succeeded, so a failure part way through cannot leave the indexes describing a + # mix of old and new file names. + for DEB_PATH in "$DIST_PATH"/*.deb + do + PACKAGE=$(dpkg-deb --field "$DEB_PATH" Package) + ARCH=$(dpkg-deb --field "$DEB_PATH" Architecture) + OCLIF_VERSION=$(dpkg-deb --field "$DEB_PATH" Version) + + GIT_SHA=${OCLIF_VERSION##*.} + GIT_SHA=${GIT_SHA%%-*} + + if ! echo "$GIT_SHA" | grep -Eq '^[0-9a-f]{7,40}$' + then + echo "Expected a git sha in the version $OCLIF_VERSION built by oclif" + exit 1 + fi + + WORKSPACE_PATH="tmp/repack/$ARCH" + rm -rf "$WORKSPACE_PATH" + # dpkg-deb creates the last path component only. + mkdir -p "$(dirname "$WORKSPACE_PATH")" + dpkg-deb --raw-extract "$DEB_PATH" "$WORKSPACE_PATH" + + # The sha is appended to the end of the description, after any lines continuing it. + # It is the only record of which commit the package was built from now that it is out + # of the version, so a control file without a description to attach it to is an error + # rather than something to skip quietly. + if ! awk -v version="$DEB_VERSION" -v sha="$GIT_SHA" ' + /^Version: / { print "Version: " version; next } + /^Description: / { found = 1; in_description = 1; print; next } + in_description && /^[ \t]/ { print; next } + in_description { print " Built from git commit " sha "."; in_description = 0 } + { print } + END { + if (in_description) print " Built from git commit " sha "." + if (!found) exit 1 + } + ' "$WORKSPACE_PATH/DEBIAN/control" > "$WORKSPACE_PATH/DEBIAN/control.rewritten" + then + echo "No Description field in the control file of $DEB_PATH to record the git sha in" + exit 1 + fi + + mv "$WORKSPACE_PATH/DEBIAN/control.rewritten" "$WORKSPACE_PATH/DEBIAN/control" + + # oclif builds the payload as root, so restore that after extracting and rebuilding + # it as the current user. + dpkg-deb --root-owner-group --build "$WORKSPACE_PATH" "$STAGING_PATH/${PACKAGE}_${DEB_FILE_VERSION}_${ARCH}.deb" + + rm -rf "$WORKSPACE_PATH" + done + + rm -f "$DIST_PATH"/*.deb + mv "$STAGING_PATH"/*.deb "$DIST_PATH" + rm -rf "$STAGING_PATH" + + # Apt takes the version and the file name from these indexes, so they have to be built + # again around the renamed packages. + ORIGIN=$(sed -n 's/^Origin: //p' "$DIST_PATH/Release") + SUITE=$(sed -n 's/^Suite: //p' "$DIST_PATH/Release") + + if [ -z "$ORIGIN" ] || [ -z "$SUITE" ] + then + echo "Could not read Origin and Suite from $DIST_PATH/Release" + exit 1 + fi + + # Kept beside the build rather than in a temporary directory so it goes away with the + # rest of the build even if the script stops early, and out of the indexed directory so + # apt-ftparchive does not list it. + FTPARCHIVE_CONF="$PWD/tmp/apt-ftparchive.conf" + + printf 'APT::FTPArchive::Release {\n Origin "%s";\n Suite "%s";\n};\n' "$ORIGIN" "$SUITE" > "$FTPARCHIVE_CONF" + + ( + cd "$DIST_PATH" || exit 1 + rm -f Packages Packages.gz Packages.bz2 Packages.xz Release InRelease Release.gpg + apt-ftparchive packages . > Packages + gzip -c Packages > Packages.gz + bzip2 -c Packages > Packages.bz2 + xz -c Packages > Packages.xz + apt-ftparchive -c "$FTPARCHIVE_CONF" release . > Release + + # The signatures oclif made cover the metadata from before the rewrite. + if [ -n "$DASHMATE_DEB_KEY" ] + then + gpg --digest-algo SHA512 --clearsign -u "$DASHMATE_DEB_KEY" -o InRelease Release + gpg --digest-algo SHA512 -abs -u "$DASHMATE_DEB_KEY" -o Release.gpg Release + fi + ) + + rm -f "$FTPARCHIVE_CONF" + + echo "Rewrote deb packages as $DEB_VERSION, published as ${DEB_FILE_VERSION}" +} + cd $ROOT_PATH/packages/dashmate || exit 1 yarn pack --install-if-needed tar zxvf package.tgz -C . @@ -49,10 +179,14 @@ echo "nodeLinker: node-modules" > .yarnrc.yml yarn install --no-immutable yarn oclif manifest yarn oclif pack $COMMAND $FLAGS + +if [ "$COMMAND" = "deb" ] +then + rewrite_deb_versions "dist/deb" +fi + cd .. || exit 1 rm package.tgz -rm -rf package/dist/Release -rm -rf package/dist/Packages cp -R package/dist "$ROOT_PATH/packages/dashmate" # fix for deb package build From 33e4e84b3996d0e7594e1643c86b1bc6ff57efe2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 28 Jul 2026 20:14:00 +0700 Subject: [PATCH 02/11] ci(dashmate): make releases verifiable and safe to hold a signing key Nothing published today can be verified: there are no checksums, no signatures beyond the macOS notarisation, and the apt metadata oclif generates is uploaded unsigned and unserved. This adds the pieces that do not depend on where the repository will eventually be hosted. Every published asset is now hashed into a deterministic SHA256SUMS, in a job that checks out nothing and installs nothing so no dependency lifecycle script can run beside the signing key that job will later hold. Each packaging leg records the hashes it produced and the checksum job refuses any asset it did not build, so the file attests what was built rather than whatever is attached. npm packages publish with provenance where the registry will accept it, which required correcting dashmate's own repository field; the rest publish as before with a warning naming the manifest to fix, so a metadata gap cannot fail a release mid-loop. The Debian version gate refuses a release apt would read as a downgrade. It reads every version from the published package rather than deriving it from a tag, and the packaging job asserts the built package carries the version that was gated, so the check binds to the bytes that ship. Third-party actions are pinned to commit SHAs, Binaryen is checksummed, and the jobs holding credentials name environments so tag and reviewer policies can be attached to them. Note the npm publish job cannot avoid running install scripts, because packing runs prepack and prepublishOnly hooks. That exposure is documented in the workflow rather than claimed to be solved. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 501 ++++++++++++++++++++++++++++++--- packages/dashmate/package.json | 2 +- 2 files changed, 458 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed7ca77e7c7..904897bb789 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,33 +18,45 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -# Required for NPM publish +# The OIDC token that publishes to npm is granted to the publish job alone. +# Anywhere else it is a standing credential for republishing every package, +# readable by any action or dependency lifecycle script running in that job. permissions: - id-token: write contents: read +# Debian version knobs, read by scripts/deb_version.js in both the version gate +# and the packaging job. They have to stay in lockstep: a value seen by only one +# of them makes the gate validate a version that is not the one that ships. +# Bump the revision to rebuild an already published version; set the epoch only +# to outrank a version whose upstream part carried a git sha. +env: + DASHMATE_DEB_REVISION: "1" + DASHMATE_DEB_EPOCH: "" + jobs: release-npm: - name: Release NPM packages + name: Build NPM packages runs-on: ubuntu-24.04 timeout-minutes: 60 if: github.event_name != 'workflow_dispatch' + permissions: + contents: read steps: - name: Check out repo uses: actions/checkout@v4 - name: Check package version matches tag - uses: geritol/match-tag-to-package-version@0.2.0 + uses: geritol/match-tag-to-package-version@dd6acafe4382a73f4282687d5ee384b238ea1df7 # 0.2.0 env: TAG_PREFIX: v - - uses: softwareforgood/check-artifact-v4-existence@v0 + - uses: softwareforgood/check-artifact-v4-existence@f988c59be23773bdf226dffa5720d85a13501fba # v0.4.3 id: check-artifact with: name: js-build-${{ github.sha }} - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -74,7 +86,7 @@ jobs: uses: ./.github/actions/nodejs - name: Install Cargo binstall - uses: cargo-bins/cargo-binstall@v1.3.1 + uses: cargo-bins/cargo-binstall@82e5fc4781666bf57476d025f1b6e7a5719da4c9 # v1.3.1 if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Install wasm-bindgen-cli @@ -85,11 +97,22 @@ jobs: run: cargo binstall wasm-pack if: ${{ steps.check-artifact.outputs.exists != 'true' }} + # Binaryen rewrites the wasm that ships inside the published packages, so + # the download is a build input we have to verify. The checksum is pinned + # here instead of read from the release's .sha256 sidecar, which an + # attacker able to replace the archive could replace as well. - name: Install Binaryen + env: + BINARYEN_VERSION: version_121 + BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17 run: | - wget https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz -P /tmp - tar -xzf /tmp/binaryen-version_121-x86_64-linux.tar.gz -C /tmp - sudo cp -r /tmp/binaryen-version_121/* /usr/local/ + set -euo pipefail + archive="/tmp/binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz" + wget -q -O "${archive}" \ + "https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz" + echo "${BINARYEN_SHA256} ${archive}" | sha256sum --check --strict - + tar -xzf "${archive}" -C /tmp + sudo cp -r "/tmp/binaryen-${BINARYEN_VERSION}"/* /usr/local/ if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Build packages @@ -98,6 +121,74 @@ jobs: CARGO_BUILD_PROFILE: release if: ${{ steps.check-artifact.outputs.exists != 'true' }} + - name: Ignore only already cached artifacts + run: | + find . -name '.gitignore' -exec rm -f {} + + echo ".yarn" >> .gitignore + echo "target" >> .gitignore + echo "node_modules" >> .gitignore + echo ".nyc_output" >> .gitignore + echo ".idea" >> .gitignore + echo ".ultra.cache.json" >> .gitignore + echo "db/*" >> .gitignore + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Get modified files + id: diff + run: | + echo "files<> $GITHUB_OUTPUT + git ls-files --others --exclude-standard >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Upload the archive of built files + uses: actions/upload-artifact@v4 + with: + name: js-build-${{ github.sha }} + path: ${{ steps.diff.outputs.files }} + retention-days: 1 + if-no-files-found: error + include-hidden-files: true + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + # Publishing is split from the build so the npm OIDC token is confined to one + # job instead of being present for the whole cargo and wasm build. + # + # The confinement is partial, and knowingly so. Setting up Node runs + # `yarn install`, so dependency lifecycle scripts execute here, and any of them + # can read the runner's OIDC request variables and mint a token that publishes + # as this repository. Removing the install is not currently possible: dashmate + # runs `oclif manifest` from `prepack`, and four other workspaces build from + # their publish hooks, so packing needs the dependency graph. Closing this + # properly means packing in the build job and publishing the resulting + # tarballs, which needs a publish path that can be tested first. + publish-npm: + name: Publish NPM packages + runs-on: ubuntu-24.04 + timeout-minutes: 30 + if: github.event_name != 'workflow_dispatch' + needs: + - release-npm + - check-dashmate-deb-version + permissions: + id-token: write # npm trusted publishing + contents: read + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Download JS build artifacts + uses: actions/download-artifact@v4 + with: + name: js-build-${{ github.sha }} + path: packages + + # Composite action defaults to Node 24+ which ships npm 11.5.1+; + # required for trusted-publishers OIDC at the publish step below. + # See https://docs.npmjs.com/trusted-publishers. + - name: Setup Node.JS + uses: ./.github/actions/nodejs + - name: Set suffix uses: actions/github-script@v6 id: suffix @@ -128,38 +219,59 @@ jobs: echo "NPM suffix: ${{ steps.suffix.outputs.result }}" echo "NPM release tag: ${{ steps.tag.outputs.result }}" + # npm rejects a provenance attestation unless the package declares a public + # `repository` matching, case-sensitively, the repository the build runs + # in, so the flag is applied per workspace rather than to the whole set: a + # manifest that does not qualify publishes exactly as it does today, with a + # warning naming the file to fix, instead of failing the release. + # Attested packages go first: publishing cannot be undone, so the batch + # carrying the newer machinery is the one to fail early. - name: Publish NPM packages - run: yarn workspaces foreach --all --no-private --parallel npm publish --tolerate-republish --access public --tag ${{ steps.tag.outputs.result }} - - - name: Ignore only already cached artifacts - run: | - find . -name '.gitignore' -exec rm -f {} + - echo ".yarn" >> .gitignore - echo "target" >> .gitignore - echo "node_modules" >> .gitignore - echo ".nyc_output" >> .gitignore - echo ".idea" >> .gitignore - echo ".ultra.cache.json" >> .gitignore - echo "db/*" >> .gitignore - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Get modified files - id: diff + env: + NPM_RELEASE_TAG: ${{ steps.tag.outputs.result }} run: | - echo "files<> $GITHUB_OUTPUT - git ls-files --others --exclude-standard >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Upload the archive of built files - uses: actions/upload-artifact@v4 - with: - name: js-build-${{ github.sha }} - path: ${{ steps.diff.outputs.files }} - retention-days: 1 - if-no-files-found: error - include-hidden-files: true - if: ${{ steps.check-artifact.outputs.exists != 'true' }} + set -euo pipefail + if [ -z "${CI:-}" ]; then + echo "Refusing to publish outside CI" + exit 1 + fi + + # Written to a file rather than piped into the loop: a process + # substitution hides its own failure, so an empty or failed listing + # would publish nothing and still report success. + workspaces="${RUNNER_TEMP}/publishable-workspaces" + yarn workspaces list --no-private --json | jq -r '.location' > "${workspaces}" + if [ ! -s "${workspaces}" ]; then + echo "::error::Found no publishable workspaces" + exit 1 + fi + + expected="https://github.com/${GITHUB_REPOSITORY}" + attested=() + plain=() + while IFS= read -r location; do + manifest="${location}/package.json" + name="$(jq -r '.name' "${manifest}")" + url="$(jq -r 'if (.repository | type) == "string" then .repository else .repository.url // "" end' "${manifest}")" + normalized="${url#git+}" + normalized="${normalized%.git}" + if [ "${normalized}" = "${expected}" ]; then + attested+=(--include "${name}") + else + plain+=(--include "${name}") + echo "::warning file=${manifest}::published without provenance: repository must be \"${expected}\", found \"${url:-}\"" + fi + done < "${workspaces}" + + if [ "${#attested[@]}" -gt 0 ]; then + yarn workspaces foreach --all --no-private --parallel "${attested[@]}" \ + npm publish --tolerate-republish --access public --provenance --tag "${NPM_RELEASE_TAG}" + fi + + if [ "${#plain[@]}" -gt 0 ]; then + yarn workspaces foreach --all --no-private --parallel "${plain[@]}" \ + npm publish --tolerate-republish --access public --tag "${NPM_RELEASE_TAG}" + fi release-drive-image: name: Release Drive image @@ -244,13 +356,141 @@ jobs: with: tag: ${{ github.event.release.tag_name }} + check-dashmate-deb-version: + name: Check Dashmate deb version + runs-on: ubuntu-24.04 + timeout-minutes: 10 + if: ${{ !inputs.only_drive }} + permissions: + contents: read + outputs: + validated_version: ${{ steps.check.outputs.validated_version }} + steps: + - name: Check out repo + uses: actions/checkout@v4 + + # apt reads a deb whose version does not sort above the installed one as a + # downgrade and refuses it, leaving operators silently stuck on the older + # release. Catch that before anything is built or published. + - name: Check deb version sorts above the last published release + id: check + env: + GH_TOKEN: ${{ github.token }} + CURRENT_TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + set -euo pipefail + compare="${GITHUB_WORKSPACE}/scripts/check_deb_version.sh" + translate="${GITHUB_WORKSPACE}/scripts/deb_version.js" + if [ ! -x "${compare}" ] || [ ! -f "${translate}" ]; then + echo "::error::${compare} or ${translate} is missing" + exit 1 + fi + # The comparison runs on Debian versions, never on the semver tags: + # "4.1.0-1" is valid as both and means something different in each. + new_version="$(node "${translate}" "${CURRENT_TAG}")" + # Published so the packaging job can prove the deb it actually builds + # carries the version validated here, rather than both jobs + # independently predicting it from the tag. + echo "validated_version=${new_version}" >> "${GITHUB_OUTPUT}" + + # Which release line a tag belongs to. Used only to group releases, so + # an older-line hotfix is measured against its own predecessor instead + # of a higher line it was never meant to supersede. Every version that + # actually gets compared still comes from a deb's control field. + line_of_tag() { + local tag="${1#v}" + printf '%s' "${tag%%-*}" | cut -d. -f1,2 + } + current_line="$(line_of_tag "${CURRENT_TAG}")" + + # Candidates are non-draft releases that shipped a deb, newest first; + # prereleases count, because they go to the same channel and apt + # compares against whatever the operator installed last. The current + # tag is included: a deb already attached to it is what an earlier run + # of this same release shipped, and it is exactly what apt measures a + # rebuild against when the revision was not bumped. + # Paginated and sorted explicitly: the repository has several hundred + # releases, so a single page silently hides older lines, and relying on + # the API's default ordering would make the choice of predecessor an + # undocumented implementation detail. + candidates="${RUNNER_TEMP}/deb-release-candidates" + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + | jq -r ' + add + | sort_by(.created_at) + | reverse + | .[] + | select(.draft == false) + | { tag: .tag_name, asset: ([.assets[].name | select(endswith("_amd64.deb"))] | first) } + | select(.asset != null) + | "\(.tag)\t\(.asset)"' > "${candidates}" + + # The newest release on this line, falling back to the newest of any + # line when this line has not shipped a deb yet. Deliberately NOT "the + # newest release sorting below this one": that would choose the + # baseline using the very condition the comparison goes on to assert, + # leaving a gate that cannot fail. + baseline_tag="" + baseline_asset="" + newest_tag="" + newest_asset="" + while IFS=$'\t' read -r tag asset; do + if [ -z "${newest_tag}" ]; then + newest_tag="${tag}" + newest_asset="${asset}" + fi + if [ "$(line_of_tag "${tag}")" = "${current_line}" ]; then + baseline_tag="${tag}" + baseline_asset="${asset}" + break + fi + done < "${candidates}" + + if [ -z "${baseline_tag}" ]; then + baseline_tag="${newest_tag}" + baseline_asset="${newest_asset}" + fi + + if [ -z "${baseline_tag}" ]; then + echo "::notice::No published deb to compare ${CURRENT_TAG} against" + exit 0 + fi + + # Read the baseline from the deb's own control field: the only version + # apt looks at, the only one a server-side rename of the asset cannot + # alter, and what operators actually installed rather than what the tag + # would produce today. + mkdir -p baseline-deb + gh release download "${baseline_tag}" --repo "${GITHUB_REPOSITORY}" \ + --pattern "${baseline_asset}" --dir baseline-deb --clobber + baseline_version="$(dpkg-deb -f "baseline-deb/${baseline_asset}" Version)" + if [ -z "${baseline_version}" ]; then + echo "::error::Could not read the Version field from ${baseline_asset} in ${baseline_tag}" + exit 1 + fi + + echo "Comparing ${CURRENT_TAG} (${new_version}) against ${baseline_asset} from ${baseline_tag} (${baseline_version})" + status=0 + "${compare}" "${new_version}" "${baseline_version}" || status=$? + if [ "${status}" -eq 1 ]; then + echo "::error::${new_version} does not sort above ${baseline_version}, so apt would refuse this release as a downgrade or report it as already the newest version. Rebuilding an already published version needs DASHMATE_DEB_REVISION; re-releasing a version whose predecessor carried a git sha in its upstream part needs DASHMATE_DEB_EPOCH=1. Both are read by scripts/deb_version.js and must be set for the packaging job as well." + fi + exit "${status}" + + # Holds the Apple signing certificate and the notarization credentials, and + # runs dependency lifecycle scripts while doing so, so it is gated on its own + # environment rather than sharing one with the job that will hold the package + # signing key. On a release the ref is refs/tags/v*, so the protection rule + # has to be a tag rule; a branch-only policy blocks every release. release-dashmate-packages: name: Release Dashmate packages runs-on: ${{ matrix.os }} if: ${{ !inputs.only_drive }} - needs: release-npm + needs: + - publish-npm + - check-dashmate-deb-version + environment: dashmate-release-build permissions: - id-token: write # s3 cache contents: write # update release artifacts strategy: fail-fast: false @@ -281,9 +521,11 @@ jobs: run: | brew install llvm coreutils + # Pinned to a master commit rather than the latest tag: the newest release + # (1.0.12) still declares the removed Node 16 runtime. - name: Set up Docker for macOS if: runner.os == 'macOS' - uses: docker-practice/actions-setup-docker@master + uses: docker-practice/actions-setup-docker@509de4a162d8fd24b2721a9fb3721131a6a4776b # master, 2025-08-24 - name: Install the Apple certificate if: runner.os == 'macOS' @@ -320,6 +562,34 @@ jobs: OSX_KEYCHAIN: ${{ runner.temp }}/app-signing.keychain-db run: "${GITHUB_WORKSPACE}/scripts/pack_dashmate.sh ${{ matrix.package_type }}" + # The gate validates a version derived from the tag; nothing so far proves + # the deb that actually gets built carries it. Without this the gate's + # verdict applies to a prediction rather than to the bytes that ship. + - name: Check the built deb carries the validated version + if: matrix.package_type == 'deb' + env: + VALIDATED_VERSION: ${{ needs.check-dashmate-deb-version.outputs.validated_version }} + run: | + set -euo pipefail + if [ -z "${VALIDATED_VERSION}" ]; then + echo "::error::The version gate did not publish a validated version" + exit 1 + fi + debs="${RUNNER_TEMP}/built-debs" + find packages/dashmate/dist -type f -name '*.deb' > "${debs}" + if [ ! -s "${debs}" ]; then + echo "::error::No deb was produced to check" + exit 1 + fi + while IFS= read -r deb; do + built_version="$(dpkg-deb -f "${deb}" Version)" + if [ "${built_version}" != "${VALIDATED_VERSION}" ]; then + echo "::error::${deb} carries version ${built_version}, but the gate validated ${VALIDATED_VERSION}; the packaging scheme and the gate disagree" + exit 1 + fi + echo "${deb} carries the validated version ${built_version}" + done < "${debs}" + - name: Upload artifacts to action summary uses: actions/upload-artifact@v4 if: github.event_name != 'release' @@ -332,8 +602,151 @@ jobs: run: | find packages/dashmate/dist/ -name '*.pkg' -exec sh -c 'xcrun notarytool submit "{}" --apple-id "${{ secrets.MACOS_APPLE_ID }}" --team-id "${{ secrets.MACOS_TEAM_ID }}" --password "${{ secrets.MACOS_NOTARIZING_PASSWORD }}" --wait;' \; + # Recorded here, after notarization and immediately before upload, so the + # hashes cover the exact bytes that leave this job. The checksums job + # compares them against what the release actually serves, which is what + # makes SHA256SUMS evidence rather than a restatement of whatever is + # attached to the release by the time it runs. + - name: Record built package checksums + if: github.event_name == 'release' + run: | + set -euo pipefail + if command -v sha256sum > /dev/null; then + hash_cmd=(sha256sum) + else + hash_cmd=(shasum -a 256) + fi + cd packages/dashmate/dist + find . -type f | sed 's|^\./||' | LC_ALL=C sort > "${RUNNER_TEMP}/built-files" + if [ ! -s "${RUNNER_TEMP}/built-files" ]; then + echo "::error::No built packages found to record" + exit 1 + fi + : > "${RUNNER_TEMP}/built.sha256" + while IFS= read -r file; do + "${hash_cmd[@]}" "${file}" >> "${RUNNER_TEMP}/built.sha256" + done < "${RUNNER_TEMP}/built-files" + cat "${RUNNER_TEMP}/built.sha256" + + - name: Upload built package checksums + uses: actions/upload-artifact@v4 + if: github.event_name == 'release' + with: + name: dashmate-built-checksums-${{ matrix.package_type }} + path: ${{ runner.temp }}/built.sha256 + retention-days: 1 + if-no-files-found: error + - name: Upload artifacts to release - uses: softprops/action-gh-release@v0.1.15 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 if: github.event_name == 'release' with: files: packages/dashmate/dist/** + + # Publication of the release, kept apart from the jobs that build it: there is + # no checkout and no dependency install, so no repository or package script + # runs beside the signing material this job is going to hold. One first-party + # action still executes here to collect the build checksums. + # + # The environment is where the deployment protection rules live; without them + # this job's secrets would be reachable from any ref someone with write access + # can dispatch the workflow on. It covers this job and the packaging job only + # - the Docker image jobs below receive credentials via `secrets: inherit` and + # are not gated. + release-dashmate-checksums: + name: Release Dashmate checksums + runs-on: ubuntu-24.04 + timeout-minutes: 30 + needs: release-dashmate-packages + if: github.event_name == 'release' + environment: dashmate-release-signing + permissions: + contents: write # update release artifacts + steps: + # Regenerating SHA256SUMS under an existing signature would leave the + # signature attesting a file that no longer exists in that form, which + # reads as valid to anyone who checks it. Removing the stale signature is + # a maintainer decision, so it is deliberately a manual step. + - name: Refuse to regenerate checksums under an existing signature + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + signature="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + --jq '[.assets[].name | select(. == "SHA256SUMS.asc")] | first // ""')" + if [ -n "${signature}" ]; then + echo "::error::SHA256SUMS.asc is already attached to ${TAG}, and regenerating SHA256SUMS would strand it over a file that no longer exists. Delete it with 'gh release delete-asset ${TAG} SHA256SUMS.asc', re-run, and sign the new file." + exit 1 + fi + + - name: Download published release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + mkdir -p assets + gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" --dir assets --clobber + # Drop the output of an earlier run of this job so a re-run hashes + # only the packages. + rm -f assets/SHA256SUMS + + - name: Download built package checksums + uses: actions/download-artifact@v4 + with: + pattern: dashmate-built-checksums-* + path: built-checksums + + # A maintainer signs SHA256SUMS out of band and attaches the detached + # signature as SHA256SUMS.asc; apt publication then refuses to publish any + # package whose hash is not in the signed file, so a compromised CI run + # alone cannot ship a package. The listing is sorted byte-wise so + # regenerating it reproduces exactly what was signed. + - name: Generate SHA256SUMS + run: | + set -euo pipefail + cd assets + find . -maxdepth 1 -type f -printf '%P\n' | LC_ALL=C sort > "${RUNNER_TEMP}/release-assets" + if [ ! -s "${RUNNER_TEMP}/release-assets" ]; then + echo "::error::No release assets found to checksum" + exit 1 + fi + xargs -a "${RUNNER_TEMP}/release-assets" -d '\n' sha256sum > SHA256SUMS + cat SHA256SUMS + + # Hashing whatever the release currently serves would attest availability, + # not origin: anyone able to swap an asset between the packaging job and + # this one would simply get the replacement blessed. Every published hash + # therefore has to have been produced by a packaging job. + - name: Verify published assets against the built packages + run: | + set -euo pipefail + cat built-checksums/*/built.sha256 > "${RUNNER_TEMP}/built-all.sha256" + if [ ! -s "${RUNNER_TEMP}/built-all.sha256" ]; then + echo "::error::No built package checksums were recorded" + exit 1 + fi + cut -d' ' -f1 "${RUNNER_TEMP}/built-all.sha256" | LC_ALL=C sort -u > "${RUNNER_TEMP}/built-hashes" + + unmatched=0 + while read -r hash name; do + if ! grep -qxF "${hash}" "${RUNNER_TEMP}/built-hashes"; then + echo "::error::${name} (${hash}) was not produced by any packaging job" + unmatched=1 + fi + done < assets/SHA256SUMS + if [ "${unmatched}" -ne 0 ]; then + echo "::error::Published assets do not match the built packages; refusing to publish checksums" + exit 1 + fi + echo "All published assets match packages built in this run" + + # Uploaded with the preinstalled CLI rather than a third-party action: + # code running in this job runs beside the signing material, and an action + # can read the keyring or shim the tools regardless of being pinned. + - name: Upload checksums to release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "${TAG}" assets/SHA256SUMS --repo "${GITHUB_REPOSITORY}" --clobber diff --git a/packages/dashmate/package.json b/packages/dashmate/package.json index 358dad86963..70df188c576 100644 --- a/packages/dashmate/package.json +++ b/packages/dashmate/package.json @@ -16,7 +16,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/dashevo/dashmate.git" + "url": "https://github.com/dashpay/platform" }, "type": "module", "bin": "./bin/run.js", From c8ed516c8d11fe4c42df095d15dba69f926f7b23 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 19:48:13 +0700 Subject: [PATCH 03/11] ci: pin the remaining mutable third-party action refs to commit shas The release workflow pins the actions it names directly, but reaches two more through the local composite actions it calls, and both resolved to a ref their owner can move: dtolnay/rust-toolchain@master is a branch, and mozilla-actions/sccache-action@v0.0.6 is a tag. Both run in jobs that have already logged in to DockerHub, so whoever moves the ref runs with those credentials. Both shas were resolved from the GitHub API. dtolnay/rust-toolchain's master head is also what its v1 tag points at, so the trailing comment names the version rather than a date. --- .github/actions/rust/action.yaml | 2 +- .github/actions/sccache/action.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/rust/action.yaml b/.github/actions/rust/action.yaml index 808f86cbe32..e1765dfce6b 100644 --- a/.github/actions/rust/action.yaml +++ b/.github/actions/rust/action.yaml @@ -39,7 +39,7 @@ runs: echo "TOOLCHAIN_VERSION=$TOOLCHAIN_VERSION" >> $GITHUB_ENV echo "version=$TOOLCHAIN_VERSION" >> $GITHUB_OUTPUT - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 name: Install Rust toolchain with: toolchain: ${{ steps.rust_toolchain.outputs.version }} diff --git a/.github/actions/sccache/action.yaml b/.github/actions/sccache/action.yaml index 48d9ae670b9..d541b70f3b1 100644 --- a/.github/actions/sccache/action.yaml +++ b/.github/actions/sccache/action.yaml @@ -77,7 +77,7 @@ runs: - name: Install sccache binary if: steps.check.outputs.available == 'true' && inputs.install == 'true' - uses: mozilla-actions/sccache-action@v0.0.6 + uses: mozilla-actions/sccache-action@9e326ebed976843c9932b3aa0e021c6f50310eb4 # v0.0.6 with: version: "v${{ inputs.version }}" From 2c047e886ce014d597c9b019a729be420e5a1ced Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 19:48:29 +0700 Subject: [PATCH 04/11] ci(dashmate): bind published hashes to names, and order the baseline by publication Two holes in the release gates, both letting through exactly what the gates exist to catch. The checksum gate reduced the build records to a bare set of hashes and only asked whether each published hash appeared in it. Removing a built package therefore still passed, and bytes built for one architecture could be served under another architecture's file name, because that hash had been produced by some matrix leg. The records are now kept under the name the file is published as - a release has no directories, so the upload flattens every build path to its basename - and the complete (hash, name) sets are compared in both directions: every built package must be published, and every published package must match what was built. Scoping the listing to the packages this workflow built also settles what it should say about assets other release jobs attach. It has no build record for those, so listing them would attest artifacts it knows nothing about, and would depend on whether those jobs had finished yet. The Debian baseline was chosen by creation date. A release drafted early and published late reaches operators after releases created after it, so a rerun of such a release measured itself against its predecessor instead of against the package it had already shipped, and a same-version rebuild passed the gate while apt would report it as already the newest version - the same defect the gate is there to prevent. It now orders by publication. The baseline choice moved out of the workflow into scripts/, alongside the version mapping it feeds, because embedded in YAML it could not be tested. Against the previous ordering the new rerun test fails, picking the predecessor's package: 12 passing 1 failing before, 13 passing after. Verified with the real dpkg: the published 4.1.0 series still sorts strictly upward through beta.2, rc.1, rc.2, rc.3, 4.1.0 and 4.1.1, and 4.1.0-1 still sorts below the already published 4.1.0.bfc80249b9-1. The checksum gate was exercised against a fake release: an asset removed and an architecture swapped both pass the old check and fail the new one. --- .github/workflows/release.yml | 193 +++++++++--------- .../unit/packaging/debReleaseBaseline.spec.js | 168 +++++++++++++++ scripts/deb_release_baseline.js | 100 +++++++++ 3 files changed, 360 insertions(+), 101 deletions(-) create mode 100644 packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js create mode 100644 scripts/deb_release_baseline.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 904897bb789..c4543d1f03b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -381,8 +381,9 @@ jobs: set -euo pipefail compare="${GITHUB_WORKSPACE}/scripts/check_deb_version.sh" translate="${GITHUB_WORKSPACE}/scripts/deb_version.js" - if [ ! -x "${compare}" ] || [ ! -f "${translate}" ]; then - echo "::error::${compare} or ${translate} is missing" + select_baseline="${GITHUB_WORKSPACE}/scripts/deb_release_baseline.js" + if [ ! -x "${compare}" ] || [ ! -f "${translate}" ] || [ ! -f "${select_baseline}" ]; then + echo "::error::${compare}, ${translate} or ${select_baseline} is missing" exit 1 fi # The comparison runs on Debian versions, never on the semver tags: @@ -393,69 +394,22 @@ jobs: # independently predicting it from the tag. echo "validated_version=${new_version}" >> "${GITHUB_OUTPUT}" - # Which release line a tag belongs to. Used only to group releases, so - # an older-line hotfix is measured against its own predecessor instead - # of a higher line it was never meant to supersede. Every version that - # actually gets compared still comes from a deb's control field. - line_of_tag() { - local tag="${1#v}" - printf '%s' "${tag%%-*}" | cut -d. -f1,2 - } - current_line="$(line_of_tag "${CURRENT_TAG}")" - - # Candidates are non-draft releases that shipped a deb, newest first; - # prereleases count, because they go to the same channel and apt - # compares against whatever the operator installed last. The current - # tag is included: a deb already attached to it is what an earlier run - # of this same release shipped, and it is exactly what apt measures a - # rebuild against when the revision was not bumped. - # Paginated and sorted explicitly: the repository has several hundred - # releases, so a single page silently hides older lines, and relying on - # the API's default ordering would make the choice of predecessor an - # undocumented implementation detail. - candidates="${RUNNER_TEMP}/deb-release-candidates" - gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ - | jq -r ' - add - | sort_by(.created_at) - | reverse - | .[] - | select(.draft == false) - | { tag: .tag_name, asset: ([.assets[].name | select(endswith("_amd64.deb"))] | first) } - | select(.asset != null) - | "\(.tag)\t\(.asset)"' > "${candidates}" - - # The newest release on this line, falling back to the newest of any - # line when this line has not shipped a deb yet. Deliberately NOT "the - # newest release sorting below this one": that would choose the - # baseline using the very condition the comparison goes on to assert, - # leaving a gate that cannot fail. - baseline_tag="" - baseline_asset="" - newest_tag="" - newest_asset="" - while IFS=$'\t' read -r tag asset; do - if [ -z "${newest_tag}" ]; then - newest_tag="${tag}" - newest_asset="${asset}" - fi - if [ "$(line_of_tag "${tag}")" = "${current_line}" ]; then - baseline_tag="${tag}" - baseline_asset="${asset}" - break - fi - done < "${candidates}" + # Every release is fetched, not just the first page: the repository has + # several hundred of them, and a single page silently hides older + # lines. Which one becomes the baseline is decided by the script rather + # than by the API's default ordering, so the choice of predecessor is + # not an undocumented implementation detail. + baseline="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + | node "${select_baseline}" "${CURRENT_TAG}")" - if [ -z "${baseline_tag}" ]; then - baseline_tag="${newest_tag}" - baseline_asset="${newest_asset}" - fi - - if [ -z "${baseline_tag}" ]; then + if [ -z "${baseline}" ]; then echo "::notice::No published deb to compare ${CURRENT_TAG} against" exit 0 fi + baseline_tag="${baseline%%$'\t'*}" + baseline_asset="${baseline#*$'\t'}" + # Read the baseline from the deb's own control field: the only version # apt looks at, the only one a server-side rename of the asset cannot # alter, and what operators actually installed rather than what the tag @@ -617,14 +571,25 @@ jobs: hash_cmd=(shasum -a 256) fi cd packages/dashmate/dist - find . -type f | sed 's|^\./||' | LC_ALL=C sort > "${RUNNER_TEMP}/built-files" + # Hidden files are skipped to match the glob that uploads this + # directory, which does not match them either. Recording a file that + # never gets uploaded would fail the release for a package that was + # never meant to ship. + find . -type f -not -path '*/.*' | sed 's|^\./||' | LC_ALL=C sort > "${RUNNER_TEMP}/built-files" if [ ! -s "${RUNNER_TEMP}/built-files" ]; then echo "::error::No built packages found to record" exit 1 fi + # Recorded under the name the file will carry on the release rather + # than its path in the build tree: a release has no directories, so + # the upload flattens every file to its basename. A record keyed by + # path could only ever be matched against a published asset by hash, + # which is what lets bytes built for one target be served under + # another target's name. : > "${RUNNER_TEMP}/built.sha256" while IFS= read -r file; do - "${hash_cmd[@]}" "${file}" >> "${RUNNER_TEMP}/built.sha256" + hash="$("${hash_cmd[@]}" "${file}" | cut -d' ' -f1)" + printf '%s %s\n' "${hash}" "${file##*/}" >> "${RUNNER_TEMP}/built.sha256" done < "${RUNNER_TEMP}/built-files" cat "${RUNNER_TEMP}/built.sha256" @@ -680,67 +645,93 @@ jobs: exit 1 fi - - name: Download published release assets + - name: Download built package checksums + uses: actions/download-artifact@v4 + with: + pattern: dashmate-built-checksums-* + path: built-checksums + + # The names and hashes the packaging jobs recorded are the whole + # expectation this job holds: SHA256SUMS covers the packages this workflow + # built, not whatever the release happens to serve. Other jobs attach + # assets of their own, and this job has no build record for those - listing + # them would attest artifacts it knows nothing about, and would depend on + # whether those jobs had finished yet. + - name: Collect the packages built in this run + run: | + set -euo pipefail + cat built-checksums/*/built.sha256 | LC_ALL=C sort -u > "${RUNNER_TEMP}/built.sha256" + if [ ! -s "${RUNNER_TEMP}/built.sha256" ]; then + echo "::error::No built package checksums were recorded" + exit 1 + fi + cut -d' ' -f3- "${RUNNER_TEMP}/built.sha256" | LC_ALL=C sort > "${RUNNER_TEMP}/built-names" + + # A release holds one asset per name, so two built files sharing a name + # means one of them silently replaced the other on the way up and there + # is no longer any way to say which bytes are published under it. + duplicates="$(LC_ALL=C uniq -d "${RUNNER_TEMP}/built-names")" + if [ -n "${duplicates}" ]; then + echo "::error::More than one built file would be published under the same name: $(echo "${duplicates}" | tr '\n' ' ')" + exit 1 + fi + cat "${RUNNER_TEMP}/built.sha256" + + # Fetched by name, one asset at a time, so that a package built here but + # missing from the release is reported as missing rather than quietly + # dropped from the listing. + - name: Download the published packages env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail mkdir -p assets - gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" --dir assets --clobber - # Drop the output of an earlier run of this job so a re-run hashes - # only the packages. - rm -f assets/SHA256SUMS - - - name: Download built package checksums - uses: actions/download-artifact@v4 - with: - pattern: dashmate-built-checksums-* - path: built-checksums + missing=0 + while IFS= read -r name; do + gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" \ + --pattern "${name}" --dir assets --clobber > /dev/null 2>&1 || true + if [ ! -f "assets/${name}" ]; then + echo "::error::${name} was built in this run but is not published on ${TAG}" + missing=1 + fi + done < "${RUNNER_TEMP}/built-names" + if [ "${missing}" -ne 0 ]; then + echo "::error::The release is missing packages this run built; refusing to publish checksums" + exit 1 + fi # A maintainer signs SHA256SUMS out of band and attaches the detached # signature as SHA256SUMS.asc; apt publication then refuses to publish any # package whose hash is not in the signed file, so a compromised CI run - # alone cannot ship a package. The listing is sorted byte-wise so - # regenerating it reproduces exactly what was signed. + # alone cannot ship a package. The listing is generated in byte-wise name + # order so regenerating it reproduces exactly what was signed. - name: Generate SHA256SUMS run: | set -euo pipefail cd assets - find . -maxdepth 1 -type f -printf '%P\n' | LC_ALL=C sort > "${RUNNER_TEMP}/release-assets" - if [ ! -s "${RUNNER_TEMP}/release-assets" ]; then - echo "::error::No release assets found to checksum" - exit 1 - fi - xargs -a "${RUNNER_TEMP}/release-assets" -d '\n' sha256sum > SHA256SUMS + xargs -a "${RUNNER_TEMP}/built-names" -d '\n' sha256sum > SHA256SUMS cat SHA256SUMS # Hashing whatever the release currently serves would attest availability, # not origin: anyone able to swap an asset between the packaging job and - # this one would simply get the replacement blessed. Every published hash - # therefore has to have been produced by a packaging job. + # this one would simply get the replacement blessed. The comparison is on + # whole (hash, name) pairs and runs in both directions, because each half + # alone leaves a way through: matching hashes only would let bytes built + # for one target be published under another target's name, and matching + # published assets only would let a package be dropped from the release + # entirely without anything noticing. - name: Verify published assets against the built packages run: | set -euo pipefail - cat built-checksums/*/built.sha256 > "${RUNNER_TEMP}/built-all.sha256" - if [ ! -s "${RUNNER_TEMP}/built-all.sha256" ]; then - echo "::error::No built package checksums were recorded" - exit 1 - fi - cut -d' ' -f1 "${RUNNER_TEMP}/built-all.sha256" | LC_ALL=C sort -u > "${RUNNER_TEMP}/built-hashes" - - unmatched=0 - while read -r hash name; do - if ! grep -qxF "${hash}" "${RUNNER_TEMP}/built-hashes"; then - echo "::error::${name} (${hash}) was not produced by any packaging job" - unmatched=1 - fi - done < assets/SHA256SUMS - if [ "${unmatched}" -ne 0 ]; then - echo "::error::Published assets do not match the built packages; refusing to publish checksums" - exit 1 + LC_ALL=C sort assets/SHA256SUMS > "${RUNNER_TEMP}/published.sha256" + if diff -u "${RUNNER_TEMP}/built.sha256" "${RUNNER_TEMP}/published.sha256" \ + --label built --label published; then + echo "All published packages match the packages built in this run" + exit 0 fi - echo "All published assets match packages built in this run" + echo "::error::Published packages do not match the packages built in this run; refusing to publish checksums" + exit 1 # Uploaded with the preinstalled CLI rather than a third-party action: # code running in this job runs beside the signing material, and an action diff --git a/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js b/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js new file mode 100644 index 00000000000..0c7efb43019 --- /dev/null +++ b/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js @@ -0,0 +1,168 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { selectDebBaseline, releaseLine } from '../../../../../scripts/deb_release_baseline.js'; + +const SCRIPT_PATH = fileURLToPath(new URL('../../../../../scripts/deb_release_baseline.js', import.meta.url)); + +/** + * A releases API entry, trimmed to the fields the baseline choice reads. + * + * `createdAt` is never used to order releases and is only set where a test needs the two + * dates to disagree. + */ +function release({ + tag, publishedAt, createdAt = publishedAt, draft = false, assets = [], +}) { + return { + tag_name: tag, + draft, + created_at: createdAt, + published_at: publishedAt, + assets: assets.map((name) => ({ name })), + }; +} + +function deb(version, arch = 'amd64') { + return `dashmate_${version}_${arch}.deb`; +} + +describe('deb_release_baseline.js', () => { + describe('#releaseLine', () => { + it('should group a prerelease with the release it leads up to', () => { + expect(releaseLine('v4.1.0-rc.3')).to.equal('4.1.0'.split('.').slice(0, 2).join('.')); + expect(releaseLine('v4.1.0-rc.3')).to.equal(releaseLine('v4.1.0')); + expect(releaseLine('v4.1.7')).to.equal(releaseLine('v4.1.0')); + }); + + it('should keep separate minor lines apart', () => { + expect(releaseLine('v4.2.0')).to.not.equal(releaseLine('v4.1.0')); + }); + }); + + describe('#selectDebBaseline', () => { + it('should measure a release against the last package offered on its own line', () => { + const releases = [ + release({ tag: 'v4.1.0', publishedAt: '2026-08-01T00:00:00Z', assets: [deb('4.1.0-1')] }), + release({ tag: 'v4.1.1', publishedAt: '2026-08-10T00:00:00Z', assets: [deb('4.1.1-1')] }), + ]; + + expect(selectDebBaseline(releases, 'v4.1.2')).to.deep.equal({ + tag: 'v4.1.1', + asset: deb('4.1.1-1'), + }); + }); + + // An operator on the 4.1 line was never offered 4.2, so measuring a 4.1 hotfix + // against it would demand a version that outranks a release it does not supersede. + it('should not measure a hotfix against a higher line', () => { + const releases = [ + release({ tag: 'v4.1.1', publishedAt: '2026-08-01T00:00:00Z', assets: [deb('4.1.1-1')] }), + release({ tag: 'v4.2.0', publishedAt: '2026-08-20T00:00:00Z', assets: [deb('4.2.0-1')] }), + ]; + + expect(selectDebBaseline(releases, 'v4.1.2').tag).to.equal('v4.1.1'); + }); + + // The first release on a new line still has to outrank whatever apt last installed, + // which is the newest package from the previous line. + it('should fall back to the newest package of any line', () => { + const releases = [ + release({ tag: 'v4.1.0', publishedAt: '2026-08-01T00:00:00Z', assets: [deb('4.1.0-1')] }), + release({ tag: 'v4.1.1', publishedAt: '2026-08-10T00:00:00Z', assets: [deb('4.1.1-1')] }), + ]; + + expect(selectDebBaseline(releases, 'v4.2.0').tag).to.equal('v4.1.1'); + }); + + // The package attached to the current tag is what an earlier run of this same release + // already shipped, and it is exactly what apt measures a rebuild against. Ordering by + // creation instead of publication loses that comparison whenever the release was + // drafted before its own predecessor, and a same-version rebuild then passes the gate + // while apt reports the package as already the newest version. + it('should measure a rerun against the package that release already shipped', () => { + const current = release({ + tag: 'v4.1.1', + createdAt: '2026-08-01T00:00:00Z', + publishedAt: '2026-08-20T00:00:00Z', + assets: [deb('4.1.1-1')], + }); + const predecessor = release({ + tag: 'v4.1.0', + createdAt: '2026-08-05T00:00:00Z', + publishedAt: '2026-08-06T00:00:00Z', + assets: [deb('4.1.0-1')], + }); + + // The two orderings disagree, which is the whole point of the fixture: chosen by + // creation the predecessor wins, chosen by publication the current release does. + expect(current.created_at < predecessor.created_at).to.equal(true); + expect(current.published_at > predecessor.published_at).to.equal(true); + + expect(selectDebBaseline([current, predecessor], 'v4.1.1').asset).to.equal(deb('4.1.1-1')); + }); + + // A draft has never been offered to anyone, so nothing can have installed it. + it('should ignore drafts', () => { + const releases = [ + release({ tag: 'v4.1.0', publishedAt: '2026-08-01T00:00:00Z', assets: [deb('4.1.0-1')] }), + release({ + tag: 'v4.1.9', publishedAt: null, draft: true, assets: [deb('4.1.9-1')], + }), + ]; + + expect(selectDebBaseline(releases, 'v4.1.1').tag).to.equal('v4.1.0'); + }); + + // Prereleases go to the same channel as stable releases, so apt compares against them + // like anything else. + it('should measure against a prerelease', () => { + const releases = [ + release({ tag: 'v4.1.0-rc.3', publishedAt: '2026-08-10T00:00:00Z', assets: [deb('4.1.0.rc.3-1')] }), + release({ tag: 'v4.0.9', publishedAt: '2026-07-01T00:00:00Z', assets: [deb('4.0.9-1')] }), + ]; + + expect(selectDebBaseline(releases, 'v4.1.0').tag).to.equal('v4.1.0-rc.3'); + }); + + it('should skip releases that shipped no package', () => { + const releases = [ + release({ tag: 'v4.1.0', publishedAt: '2026-08-01T00:00:00Z', assets: [deb('4.1.0-1')] }), + release({ tag: 'v4.1.1', publishedAt: '2026-08-10T00:00:00Z', assets: ['dashmate-v4.1.1-x64.pkg'] }), + ]; + + expect(selectDebBaseline(releases, 'v4.1.2').tag).to.equal('v4.1.0'); + }); + + it('should have nothing to compare against when no release shipped a package', () => { + expect(selectDebBaseline([], 'v4.1.0')).to.equal(null); + expect(selectDebBaseline([ + release({ tag: 'v4.1.0', publishedAt: '2026-08-01T00:00:00Z' }), + ], 'v4.1.1')).to.equal(null); + }); + }); + + describe('command line', () => { + function run(currentTag, releases) { + return execFileSync(process.execPath, [SCRIPT_PATH, currentTag], { + encoding: 'utf8', + input: JSON.stringify(releases), + }); + } + + // The workflow pipes `gh api --paginate --slurp`, which emits one array per page. + it('should read a paginated response and print the tag and package name', () => { + const output = run('v4.1.2', [ + [release({ tag: 'v4.1.0', publishedAt: '2026-08-01T00:00:00Z', assets: [deb('4.1.0-1')] })], + [release({ tag: 'v4.1.1', publishedAt: '2026-08-10T00:00:00Z', assets: [deb('4.1.1-1')] })], + ]); + + expect(output).to.equal(`v4.1.1\t${deb('4.1.1-1')}\n`); + }); + + // The workflow reads an empty result as "nothing to compare against" and stops there, + // so anything printed on the happy path would be taken for a package name. + it('should print nothing when no release shipped a package', () => { + expect(run('v4.1.0', [[]])).to.equal(''); + }); + }); +}); diff --git a/scripts/deb_release_baseline.js b/scripts/deb_release_baseline.js new file mode 100644 index 00000000000..2af323bdb27 --- /dev/null +++ b/scripts/deb_release_baseline.js @@ -0,0 +1,100 @@ +/** + * Choose the published release whose Debian package a new release has to sort above. + * + * apt refuses a package whose version does not sort above the one already installed, so + * the release to measure against is the last one an operator was offered on the same + * release line - or, when the line has not shipped a package yet, the last one offered + * at all. The release being built is deliberately a candidate: a package already + * attached to that tag is what an earlier run shipped, and it is exactly what apt + * compares a rebuild against. + * + * Only the choice of release is made here. The version itself is always read from the + * chosen package's own control field, never derived from the tag, because that is the + * only version apt looks at and the only one a rename of the asset cannot alter. + */ + +// Releases are ordered by when they were published rather than when they were created. +// A release drafted early and published late reaches operators after releases created +// after it, and it is the order the packages were offered in that decides what apt has +// already installed. Ordering by creation lets a rerun of such a release measure itself +// against its own predecessor instead of against the package it already shipped, which +// passes a same-version rebuild that apt will then report as already the newest version. +const PUBLISHED_AT = 'published_at'; + +// Only the architecture-independent naming matters here: any published package can be +// read for its version, and the amd64 one is present in every release that shipped debs. +const BASELINE_ASSET_SUFFIX = '_amd64.deb'; + +/** + * The release line a tag belongs to, as `major.minor`. + * + * Used only to group releases, so that a hotfix on an older line is measured against its + * own predecessor instead of a higher line it was never meant to supersede. + * + * @param {string} tag + * @returns {string} + */ +function releaseLine(tag) { + return String(tag).replace(/^v/, '').split('-')[0].split('.').slice(0, 2).join('.'); +} + +/** + * @param {object[]} releases - releases as returned by the GitHub releases API + * @param {string} currentTag - the tag being released + * @returns {{tag: string, asset: string}|null} the release to compare against, if any + */ +function selectDebBaseline(releases, currentTag) { + const candidates = releases + // Drafts have never been offered to anyone and carry no publication date to sort by. + // Prereleases are kept: they go to the same channel, and apt compares against + // whatever the operator installed last regardless of how it was labelled. + .filter((release) => release.draft === false && release[PUBLISHED_AT] != null) + .sort((left, right) => String(right[PUBLISHED_AT]).localeCompare(String(left[PUBLISHED_AT]))) + .map((release) => ({ + tag: release.tag_name, + asset: (release.assets || []) + .map((asset) => asset.name) + .find((name) => String(name).endsWith(BASELINE_ASSET_SUFFIX)), + })) + .filter((candidate) => candidate.asset !== undefined); + + const line = releaseLine(currentTag); + + return candidates.find((candidate) => releaseLine(candidate.tag) === line) + || candidates[0] + || null; +} + +module.exports.selectDebBaseline = selectDebBaseline; +module.exports.releaseLine = releaseLine; + +if (require.main === module) { + const currentTag = process.argv[2]; + + if (!currentTag) { + console.error('Usage: deb_release_baseline.js CURRENT_TAG < releases.json\n\n' + + ' Reads the GitHub releases API response on stdin and prints the tag and the\n' + + ' package file name of the release the current tag has to sort above, separated\n' + + ' by a tab. Prints nothing when no published release has shipped a package.\n'); + + process.exit(1); + } + + const input = require('node:fs').readFileSync(0, 'utf8'); + const parsed = JSON.parse(input); + + if (!Array.isArray(parsed)) { + console.error('Expected the releases API response to be an array'); + + process.exit(1); + } + + // A paginated response arrives as one array per page. + const releases = parsed.flatMap((page) => (Array.isArray(page) ? page : [page])); + + const baseline = selectDebBaseline(releases, currentTag); + + if (baseline !== null) { + process.stdout.write(`${baseline.tag}\t${baseline.asset}\n`); + } +} From 9d896916e008c54d533926868a1462d852d001f8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 20:20:11 +0700 Subject: [PATCH 05/11] ci(dashmate): re-check for a signature immediately before replacing checksums The guard at the start of the job cannot cover the whole run. A maintainer can attach SHA256SUMS.asc while the packages are being downloaded and verified, and the upload would then replace the file that signature covers, leaving a valid looking signature over bytes it never saw. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4543d1f03b..edf0a90a603 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -736,8 +736,20 @@ jobs: # Uploaded with the preinstalled CLI rather than a third-party action: # code running in this job runs beside the signing material, and an action # can read the keyring or shim the tools regardless of being pinned. + # The guard at the start of this job cannot cover the whole run: a + # maintainer can attach a signature while the packages are downloaded and + # verified. Re-checking here means the upload never replaces a file a + # signature already covers, leaving that signature over bytes it never saw. - name: Upload checksums to release env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.release.tag_name }} - run: gh release upload "${TAG}" assets/SHA256SUMS --repo "${GITHUB_REPOSITORY}" --clobber + run: | + set -euo pipefail + signature="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + --jq '[.assets[].name | select(. == "SHA256SUMS.asc")] | first // ""')" + if [ -n "${signature}" ]; then + echo "::error::SHA256SUMS.asc was attached to ${TAG} while this job was running, so uploading SHA256SUMS now would leave that signature over bytes it did not sign. Delete it with 'gh release delete-asset ${TAG} SHA256SUMS.asc', re-run, and sign the new file." + exit 1 + fi + gh release upload "${TAG}" assets/SHA256SUMS --repo "${GITHUB_REPOSITORY}" --clobber From a269069e0d1ded619c8d2d4b82209ca25d192f5c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 20:24:47 +0700 Subject: [PATCH 06/11] docs(dashmate): install the package that was downloaded, not every match The install step globbed for the package, so a directory holding an older release or the other architecture's package handed apt every match. It now installs exactly the file the download step selected. Also guards the signing key variable against being unset, so enabling strict mode later cannot abort an ordinary unsigned local build. Co-Authored-By: Claude Opus 5 --- packages/dashmate/docs/installation.md | 10 +++++----- scripts/pack_dashmate.sh | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/dashmate/docs/installation.md b/packages/dashmate/docs/installation.md index f35e198095a..497a8a6b19c 100644 --- a/packages/dashmate/docs/installation.md +++ b/packages/dashmate/docs/installation.md @@ -24,17 +24,17 @@ Download the newest dashmate installation package for your architecture from the The file name contains the version, so it changes with every release; this downloads the one matching the architecture you are on: ```bash -curl -fsSL https://api.github.com/repos/dashpay/platform/releases/latest \ +DASHMATE_DEB="$(curl -fsSL https://api.github.com/repos/dashpay/platform/releases/latest \ | grep -o "https://[^\"]*_$(dpkg --print-architecture)\.deb" \ - | head -n 1 \ - | xargs curl -fLO + | head -n 1)" +curl -fLO "$DASHMATE_DEB" ``` -Install dashmate using apt: +Install the package that was just downloaded: ```bash sudo apt update -sudo apt install ./dashmate_*.deb +sudo apt install "./$(basename "$DASHMATE_DEB")" ``` > **Note:** At the end of the installation process, apt may display an error due to installing a downloaded package. diff --git a/scripts/pack_dashmate.sh b/scripts/pack_dashmate.sh index d78de7353d1..9387b5aafcf 100755 --- a/scripts/pack_dashmate.sh +++ b/scripts/pack_dashmate.sh @@ -157,7 +157,7 @@ rewrite_deb_versions() { apt-ftparchive -c "$FTPARCHIVE_CONF" release . > Release # The signatures oclif made cover the metadata from before the rewrite. - if [ -n "$DASHMATE_DEB_KEY" ] + if [ -n "${DASHMATE_DEB_KEY:-}" ] then gpg --digest-algo SHA512 --clearsign -u "$DASHMATE_DEB_KEY" -o InRelease Release gpg --digest-algo SHA512 -abs -u "$DASHMATE_DEB_KEY" -o Release.gpg Release From 868640c5b87b0575213d19d3a0956df1e3ae2a99 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 21:20:10 +0700 Subject: [PATCH 07/11] ci(dashmate): stop the release token outliving the step that needs it Checkout keeps the token available to every later step in the job by default. None of these jobs uses git authentication after checking out, and the packaging jobs hold signing material, so the credential is no longer left behind them. The version tests also inherited the environment, so exporting the rebuild revision in the shell that runs them changed what the script printed and failed an assertion. Reproducible through the rebuild procedure this branch documents. Test would have caught this in CI: with the revision exported, 1 case fails before the fix. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 7 +++++++ .../dashmate/test/unit/packaging/debVersion.spec.js | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edf0a90a603..83b1c0739c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v4 + with: + persist-credentials: false - name: Check package version matches tag uses: geritol/match-tag-to-package-version@dd6acafe4382a73f4282687d5ee384b238ea1df7 # 0.2.0 @@ -176,6 +178,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v4 + with: + persist-credentials: false - name: Download JS build artifacts uses: actions/download-artifact@v4 @@ -368,6 +372,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v4 + with: + persist-credentials: false # apt reads a deb whose version does not sort above the installed one as a # downgrade and refuses it, leaving operators silently stuck on the older @@ -462,6 +468,7 @@ jobs: - name: Check out repo uses: actions/checkout@v4 with: + persist-credentials: false fetch-depth: 0 - name: Download JS build artifacts diff --git a/packages/dashmate/test/unit/packaging/debVersion.spec.js b/packages/dashmate/test/unit/packaging/debVersion.spec.js index 34141a4e8c9..bd80a62788f 100644 --- a/packages/dashmate/test/unit/packaging/debVersion.spec.js +++ b/packages/dashmate/test/unit/packaging/debVersion.spec.js @@ -278,10 +278,18 @@ describe('deb_version.js', () => { }); describe('command line', () => { + // Both variables are always given a value, so a rebuild that exports + // DASHMATE_DEB_REVISION in the shell running the tests cannot reach the + // script and change the version a test asserts. function run(version, env) { return execFileSync(process.execPath, [SCRIPT_PATH, version], { encoding: 'utf8', - env: { ...process.env, ...env }, + env: { + ...process.env, + DASHMATE_DEB_REVISION: '1', + DASHMATE_DEB_EPOCH: '', + ...env, + }, }).trim(); } From 59fd78e1d3c147d98ba76a0e68bffed2057fdf9b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 22:46:13 +0700 Subject: [PATCH 08/11] refactor(dashmate): move the release packaging scripts next to the package The deb versioning scripts lived at the repository root under snake_case names while the package they serve is dashmate, and the release workflow carried a hundred lines of inline bash that could only be exercised by pushing a tag. The three scripts move to packages/dashmate/scripts/ under kebab-case names, matching the scripts already there. The dashmate package is ESM, so the two JS files trade module.exports and require.main for exports and an import.meta.url main check; nothing else about them changes. Three workflow steps become invocations of scripts a release engineer can run locally: the version gate, the check that the built deb carries the validated version, and the checksum recording. Every comment, error message and exit code moves with the logic it explains. The checksums job's three remaining bash steps stay inline. That job deliberately has no checkout so that no repository script runs beside the signing material, and extracting them would mean adding one. release.yml: 762 -> 662 lines. --- .github/workflows/release.yml | 121 ++---------------- .../scripts/check-built-deb-version.sh | 48 +++++++ .../dashmate/scripts/check-deb-version.sh | 10 +- .../scripts/check-release-deb-version.sh | 96 ++++++++++++++ .../dashmate/scripts/deb-release-baseline.js | 12 +- .../dashmate/scripts/deb-version.js | 9 +- .../scripts/record-built-checksums.sh | 66 ++++++++++ .../unit/packaging/debReleaseBaseline.spec.js | 6 +- .../test/unit/packaging/debVersion.spec.js | 6 +- scripts/pack_dashmate.sh | 4 +- 10 files changed, 246 insertions(+), 132 deletions(-) create mode 100755 packages/dashmate/scripts/check-built-deb-version.sh rename scripts/check_deb_version.sh => packages/dashmate/scripts/check-deb-version.sh (83%) create mode 100755 packages/dashmate/scripts/check-release-deb-version.sh rename scripts/deb_release_baseline.js => packages/dashmate/scripts/deb-release-baseline.js (93%) rename scripts/deb_version.js => packages/dashmate/scripts/deb-version.js (96%) create mode 100755 packages/dashmate/scripts/record-built-checksums.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83b1c0739c0..7b261ba2546 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,10 @@ concurrency: permissions: contents: read -# Debian version knobs, read by scripts/deb_version.js in both the version gate -# and the packaging job. They have to stay in lockstep: a value seen by only one -# of them makes the gate validate a version that is not the one that ships. +# Debian version knobs, read by packages/dashmate/scripts/deb-version.js in both +# the version gate and the packaging job. They have to stay in lockstep: a value +# seen by only one of them makes the gate validate a version that is not the one +# that ships. # Bump the revision to rebuild an already published version; set the epoch only # to outrank a version whose upstream part carried a git sha. env: @@ -384,58 +385,7 @@ jobs: GH_TOKEN: ${{ github.token }} CURRENT_TAG: ${{ inputs.tag || github.event.release.tag_name }} run: | - set -euo pipefail - compare="${GITHUB_WORKSPACE}/scripts/check_deb_version.sh" - translate="${GITHUB_WORKSPACE}/scripts/deb_version.js" - select_baseline="${GITHUB_WORKSPACE}/scripts/deb_release_baseline.js" - if [ ! -x "${compare}" ] || [ ! -f "${translate}" ] || [ ! -f "${select_baseline}" ]; then - echo "::error::${compare}, ${translate} or ${select_baseline} is missing" - exit 1 - fi - # The comparison runs on Debian versions, never on the semver tags: - # "4.1.0-1" is valid as both and means something different in each. - new_version="$(node "${translate}" "${CURRENT_TAG}")" - # Published so the packaging job can prove the deb it actually builds - # carries the version validated here, rather than both jobs - # independently predicting it from the tag. - echo "validated_version=${new_version}" >> "${GITHUB_OUTPUT}" - - # Every release is fetched, not just the first page: the repository has - # several hundred of them, and a single page silently hides older - # lines. Which one becomes the baseline is decided by the script rather - # than by the API's default ordering, so the choice of predecessor is - # not an undocumented implementation detail. - baseline="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ - | node "${select_baseline}" "${CURRENT_TAG}")" - - if [ -z "${baseline}" ]; then - echo "::notice::No published deb to compare ${CURRENT_TAG} against" - exit 0 - fi - - baseline_tag="${baseline%%$'\t'*}" - baseline_asset="${baseline#*$'\t'}" - - # Read the baseline from the deb's own control field: the only version - # apt looks at, the only one a server-side rename of the asset cannot - # alter, and what operators actually installed rather than what the tag - # would produce today. - mkdir -p baseline-deb - gh release download "${baseline_tag}" --repo "${GITHUB_REPOSITORY}" \ - --pattern "${baseline_asset}" --dir baseline-deb --clobber - baseline_version="$(dpkg-deb -f "baseline-deb/${baseline_asset}" Version)" - if [ -z "${baseline_version}" ]; then - echo "::error::Could not read the Version field from ${baseline_asset} in ${baseline_tag}" - exit 1 - fi - - echo "Comparing ${CURRENT_TAG} (${new_version}) against ${baseline_asset} from ${baseline_tag} (${baseline_version})" - status=0 - "${compare}" "${new_version}" "${baseline_version}" || status=$? - if [ "${status}" -eq 1 ]; then - echo "::error::${new_version} does not sort above ${baseline_version}, so apt would refuse this release as a downgrade or report it as already the newest version. Rebuilding an already published version needs DASHMATE_DEB_REVISION; re-releasing a version whose predecessor carried a git sha in its upstream part needs DASHMATE_DEB_EPOCH=1. Both are read by scripts/deb_version.js and must be set for the packaging job as well." - fi - exit "${status}" + "${GITHUB_WORKSPACE}/packages/dashmate/scripts/check-release-deb-version.sh" "${CURRENT_TAG}" # Holds the Apple signing certificate and the notarization credentials, and # runs dependency lifecycle scripts while doing so, so it is gated on its own @@ -523,33 +473,13 @@ jobs: OSX_KEYCHAIN: ${{ runner.temp }}/app-signing.keychain-db run: "${GITHUB_WORKSPACE}/scripts/pack_dashmate.sh ${{ matrix.package_type }}" - # The gate validates a version derived from the tag; nothing so far proves - # the deb that actually gets built carries it. Without this the gate's - # verdict applies to a prediction rather than to the bytes that ship. - name: Check the built deb carries the validated version if: matrix.package_type == 'deb' env: VALIDATED_VERSION: ${{ needs.check-dashmate-deb-version.outputs.validated_version }} run: | - set -euo pipefail - if [ -z "${VALIDATED_VERSION}" ]; then - echo "::error::The version gate did not publish a validated version" - exit 1 - fi - debs="${RUNNER_TEMP}/built-debs" - find packages/dashmate/dist -type f -name '*.deb' > "${debs}" - if [ ! -s "${debs}" ]; then - echo "::error::No deb was produced to check" - exit 1 - fi - while IFS= read -r deb; do - built_version="$(dpkg-deb -f "${deb}" Version)" - if [ "${built_version}" != "${VALIDATED_VERSION}" ]; then - echo "::error::${deb} carries version ${built_version}, but the gate validated ${VALIDATED_VERSION}; the packaging scheme and the gate disagree" - exit 1 - fi - echo "${deb} carries the validated version ${built_version}" - done < "${debs}" + packages/dashmate/scripts/check-built-deb-version.sh \ + "${VALIDATED_VERSION}" packages/dashmate/dist "${RUNNER_TEMP}" - name: Upload artifacts to action summary uses: actions/upload-artifact@v4 @@ -563,42 +493,13 @@ jobs: run: | find packages/dashmate/dist/ -name '*.pkg' -exec sh -c 'xcrun notarytool submit "{}" --apple-id "${{ secrets.MACOS_APPLE_ID }}" --team-id "${{ secrets.MACOS_TEAM_ID }}" --password "${{ secrets.MACOS_NOTARIZING_PASSWORD }}" --wait;' \; - # Recorded here, after notarization and immediately before upload, so the - # hashes cover the exact bytes that leave this job. The checksums job - # compares them against what the release actually serves, which is what - # makes SHA256SUMS evidence rather than a restatement of whatever is - # attached to the release by the time it runs. + # Recorded after notarization and immediately before upload, so the hashes + # cover the exact bytes that leave this job. - name: Record built package checksums if: github.event_name == 'release' run: | - set -euo pipefail - if command -v sha256sum > /dev/null; then - hash_cmd=(sha256sum) - else - hash_cmd=(shasum -a 256) - fi - cd packages/dashmate/dist - # Hidden files are skipped to match the glob that uploads this - # directory, which does not match them either. Recording a file that - # never gets uploaded would fail the release for a package that was - # never meant to ship. - find . -type f -not -path '*/.*' | sed 's|^\./||' | LC_ALL=C sort > "${RUNNER_TEMP}/built-files" - if [ ! -s "${RUNNER_TEMP}/built-files" ]; then - echo "::error::No built packages found to record" - exit 1 - fi - # Recorded under the name the file will carry on the release rather - # than its path in the build tree: a release has no directories, so - # the upload flattens every file to its basename. A record keyed by - # path could only ever be matched against a published asset by hash, - # which is what lets bytes built for one target be served under - # another target's name. - : > "${RUNNER_TEMP}/built.sha256" - while IFS= read -r file; do - hash="$("${hash_cmd[@]}" "${file}" | cut -d' ' -f1)" - printf '%s %s\n' "${hash}" "${file##*/}" >> "${RUNNER_TEMP}/built.sha256" - done < "${RUNNER_TEMP}/built-files" - cat "${RUNNER_TEMP}/built.sha256" + packages/dashmate/scripts/record-built-checksums.sh \ + "${RUNNER_TEMP}" packages/dashmate/dist - name: Upload built package checksums uses: actions/upload-artifact@v4 diff --git a/packages/dashmate/scripts/check-built-deb-version.sh b/packages/dashmate/scripts/check-built-deb-version.sh new file mode 100755 index 00000000000..0883f9f725f --- /dev/null +++ b/packages/dashmate/scripts/check-built-deb-version.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euo pipefail + +cmd_usage="Usage: check-built-deb-version.sh VALIDATED_VERSION [DIST_PATH] [WORK_PATH] + +Checks that every deb in DIST_PATH carries VALIDATED_VERSION in its control field. + +The version gate validates a version derived from the tag; nothing else proves the deb +that actually gets built carries it. Without this check the gate's verdict applies to a +prediction rather than to the bytes that ship. + + DIST_PATH directory the packages were built into, default packages/dashmate/dist + WORK_PATH directory for intermediate files, default \$RUNNER_TEMP or \$TMPDIR + + EXIT CODES: + 0 every built deb carries the validated version + 1 a deb carries another version, or there was nothing to check + 2 wrong arguments +" + +VALIDATED_VERSION="${1:-}" + +DIR_PATH=$(dirname "$(realpath "$0")") + +DIST_PATH="${2:-${DIR_PATH}/../dist}" +WORK_PATH="${3:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}}" + +if [ -z "${VALIDATED_VERSION}" ]; then + echo "::error::The version gate did not publish a validated version" + echo "$cmd_usage" >&2 + exit 1 +fi + +debs="${WORK_PATH}/built-debs" +find "${DIST_PATH}" -type f -name '*.deb' > "${debs}" +if [ ! -s "${debs}" ]; then + echo "::error::No deb was produced to check" + exit 1 +fi +while IFS= read -r deb; do + built_version="$(dpkg-deb -f "${deb}" Version)" + if [ "${built_version}" != "${VALIDATED_VERSION}" ]; then + echo "::error::${deb} carries version ${built_version}, but the gate validated ${VALIDATED_VERSION}; the packaging scheme and the gate disagree" + exit 1 + fi + echo "${deb} carries the validated version ${built_version}" +done < "${debs}" diff --git a/scripts/check_deb_version.sh b/packages/dashmate/scripts/check-deb-version.sh similarity index 83% rename from scripts/check_deb_version.sh rename to packages/dashmate/scripts/check-deb-version.sh index c878cc3f9ca..3dca5924a51 100755 --- a/scripts/check_deb_version.sh +++ b/packages/dashmate/scripts/check-deb-version.sh @@ -2,7 +2,7 @@ set -e -cmd_usage="Usage: check_deb_version.sh NEW_VERSION PREVIOUS_VERSION +cmd_usage="Usage: check-deb-version.sh NEW_VERSION PREVIOUS_VERSION Exits successfully only when NEW_VERSION sorts strictly above PREVIOUS_VERSION under dpkg's version comparison, which is what decides whether apt offers a release as an @@ -11,9 +11,9 @@ upgrade at all. Both arguments are Debian package versions ([EPOCH:]UPSTREAM[-REVISION]), not semver tags. Translate a semver version first: - scripts/check_deb_version.sh \\ - \"\$(node scripts/deb_version.js 4.1.0-rc.4)\" \\ - \"\$(node scripts/deb_version.js 4.1.0-rc.3)\" + packages/dashmate/scripts/check-deb-version.sh \\ + \"\$(node packages/dashmate/scripts/deb-version.js 4.1.0-rc.4)\" \\ + \"\$(node packages/dashmate/scripts/deb-version.js 4.1.0-rc.3)\" EXIT CODES: 0 new version sorts above the previous one @@ -36,7 +36,7 @@ fi # would defeat the point of the check. Refuse to answer instead of answering wrongly. if ! command -v dpkg > /dev/null 2>&1 then - echo "check_deb_version.sh: dpkg not found, cannot compare Debian versions." >&2 + echo "check-deb-version.sh: dpkg not found, cannot compare Debian versions." >&2 echo "Run this on a Debian based host or inside a container that has dpkg." >&2 exit 3 fi diff --git a/packages/dashmate/scripts/check-release-deb-version.sh b/packages/dashmate/scripts/check-release-deb-version.sh new file mode 100755 index 00000000000..968d4f71d1e --- /dev/null +++ b/packages/dashmate/scripts/check-release-deb-version.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +set -euo pipefail + +cmd_usage="Usage: check-release-deb-version.sh CURRENT_TAG + +Checks that the deb built for CURRENT_TAG sorts above the deb of the last release +operators were offered, which is what decides whether apt takes it as an upgrade. + +The baseline release is chosen by deb-release-baseline.js, its version is read from +the published package's own control field, and the two are compared by +check-deb-version.sh. + + ENVIRONMENT: + GITHUB_REPOSITORY owner/name of the repository to read releases from + GH_TOKEN token the GitHub CLI authenticates with + GITHUB_OUTPUT step output file; the validated version is appended to it as + validated_version when set + + EXIT CODES: + 0 the new version sorts above the baseline, or there is no baseline + 1 the new version is equal to or below the baseline, or the check could not run + 2 wrong arguments + 3 dpkg is unavailable, so the comparison could not be made +" + +CURRENT_TAG="${1:-}" + +if [ -z "${CURRENT_TAG}" ] +then + echo "$cmd_usage" >&2 + exit 2 +fi + +if [ -z "${GITHUB_REPOSITORY:-}" ] +then + echo "check-release-deb-version.sh: GITHUB_REPOSITORY is not set." >&2 + echo "$cmd_usage" >&2 + exit 2 +fi + +DIR_PATH=$(dirname "$(realpath "$0")") + +compare="${DIR_PATH}/check-deb-version.sh" +translate="${DIR_PATH}/deb-version.js" +select_baseline="${DIR_PATH}/deb-release-baseline.js" +if [ ! -x "${compare}" ] || [ ! -f "${translate}" ] || [ ! -f "${select_baseline}" ]; then + echo "::error::${compare}, ${translate} or ${select_baseline} is missing" + exit 1 +fi +# The comparison runs on Debian versions, never on the semver tags: +# "4.1.0-1" is valid as both and means something different in each. +new_version="$(node "${translate}" "${CURRENT_TAG}")" +# Published so the packaging job can prove the deb it actually builds +# carries the version validated here, rather than both jobs +# independently predicting it from the tag. +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "validated_version=${new_version}" >> "${GITHUB_OUTPUT}" +fi + +# Every release is fetched, not just the first page: the repository has +# several hundred of them, and a single page silently hides older +# lines. Which one becomes the baseline is decided by the script rather +# than by the API's default ordering, so the choice of predecessor is +# not an undocumented implementation detail. +baseline="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + | node "${select_baseline}" "${CURRENT_TAG}")" + +if [ -z "${baseline}" ]; then + echo "::notice::No published deb to compare ${CURRENT_TAG} against" + exit 0 +fi + +baseline_tag="${baseline%%$'\t'*}" +baseline_asset="${baseline#*$'\t'}" + +# Read the baseline from the deb's own control field: the only version +# apt looks at, the only one a server-side rename of the asset cannot +# alter, and what operators actually installed rather than what the tag +# would produce today. +mkdir -p baseline-deb +gh release download "${baseline_tag}" --repo "${GITHUB_REPOSITORY}" \ + --pattern "${baseline_asset}" --dir baseline-deb --clobber +baseline_version="$(dpkg-deb -f "baseline-deb/${baseline_asset}" Version)" +if [ -z "${baseline_version}" ]; then + echo "::error::Could not read the Version field from ${baseline_asset} in ${baseline_tag}" + exit 1 +fi + +echo "Comparing ${CURRENT_TAG} (${new_version}) against ${baseline_asset} from ${baseline_tag} (${baseline_version})" +status=0 +"${compare}" "${new_version}" "${baseline_version}" || status=$? +if [ "${status}" -eq 1 ]; then + echo "::error::${new_version} does not sort above ${baseline_version}, so apt would refuse this release as a downgrade or report it as already the newest version. Rebuilding an already published version needs DASHMATE_DEB_REVISION; re-releasing a version whose predecessor carried a git sha in its upstream part needs DASHMATE_DEB_EPOCH=1. Both are read by packages/dashmate/scripts/deb-version.js and must be set for the packaging job as well." +fi +exit "${status}" diff --git a/scripts/deb_release_baseline.js b/packages/dashmate/scripts/deb-release-baseline.js similarity index 93% rename from scripts/deb_release_baseline.js rename to packages/dashmate/scripts/deb-release-baseline.js index 2af323bdb27..309cd0b1a5d 100644 --- a/scripts/deb_release_baseline.js +++ b/packages/dashmate/scripts/deb-release-baseline.js @@ -13,6 +13,9 @@ * only version apt looks at and the only one a rename of the asset cannot alter. */ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + // Releases are ordered by when they were published rather than when they were created. // A release drafted early and published late reaches operators after releases created // after it, and it is the order the packages were offered in that decides what apt has @@ -65,14 +68,13 @@ function selectDebBaseline(releases, currentTag) { || null; } -module.exports.selectDebBaseline = selectDebBaseline; -module.exports.releaseLine = releaseLine; +export { selectDebBaseline, releaseLine }; -if (require.main === module) { +if (process.argv[1] === fileURLToPath(import.meta.url)) { const currentTag = process.argv[2]; if (!currentTag) { - console.error('Usage: deb_release_baseline.js CURRENT_TAG < releases.json\n\n' + console.error('Usage: deb-release-baseline.js CURRENT_TAG < releases.json\n\n' + ' Reads the GitHub releases API response on stdin and prints the tag and the\n' + ' package file name of the release the current tag has to sort above, separated\n' + ' by a tab. Prints nothing when no published release has shipped a package.\n'); @@ -80,7 +82,7 @@ if (require.main === module) { process.exit(1); } - const input = require('node:fs').readFileSync(0, 'utf8'); + const input = fs.readFileSync(0, 'utf8'); const parsed = JSON.parse(input); if (!Array.isArray(parsed)) { diff --git a/scripts/deb_version.js b/packages/dashmate/scripts/deb-version.js similarity index 96% rename from scripts/deb_version.js rename to packages/dashmate/scripts/deb-version.js index 4b7c56d0e5e..e3567d13498 100644 --- a/scripts/deb_version.js +++ b/packages/dashmate/scripts/deb-version.js @@ -24,6 +24,8 @@ * published `4.1.0.bfc80249b9-1` because the sha extends the upstream version. */ +import { fileURLToPath } from 'node:url'; + // Version identifiers follow semver: no leading zeros, because `4.1.0-rc.01` and // `4.1.0-rc.1` are two distinct tags that dpkg considers the same version. const NUMERIC_IDENTIFIER = '(?:0|[1-9]\\d*)'; @@ -94,16 +96,15 @@ function debFileNameVersion(debVersion) { return debVersion.replace(/^\d+:/, '').replace(/~/g, '.'); } -module.exports.debVersionFromSemver = debVersionFromSemver; -module.exports.debFileNameVersion = debFileNameVersion; +export { debVersionFromSemver, debFileNameVersion }; -if (require.main === module) { +if (process.argv[1] === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2); const forFileName = args[0] === '--file-name'; const version = forFileName ? args[1] : args[0]; if (!version) { - console.error('Usage: deb_version.js [--file-name] SEMVER_VERSION\n\n' + console.error('Usage: deb-version.js [--file-name] SEMVER_VERSION\n\n' + ' Prints the Debian package version for a semver version, or the version as it\n' + ' appears in the package file name.\n\n' + ' DASHMATE_DEB_REVISION Debian revision, default 1. Bump it to rebuild a version\n' diff --git a/packages/dashmate/scripts/record-built-checksums.sh b/packages/dashmate/scripts/record-built-checksums.sh new file mode 100755 index 00000000000..3b6f6df508f --- /dev/null +++ b/packages/dashmate/scripts/record-built-checksums.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +set -euo pipefail + +cmd_usage="Usage: record-built-checksums.sh OUTPUT_PATH [DIST_PATH] + +Records the sha256 of every package in DIST_PATH into OUTPUT_PATH/built.sha256, and the +file names they were taken from into OUTPUT_PATH/built-files. + +Run after notarization and immediately before upload, so the hashes cover the exact +bytes that leave the packaging job. The checksums job compares them against what the +release actually serves, which is what makes SHA256SUMS evidence rather than a +restatement of whatever is attached to the release by the time it runs. + + OUTPUT_PATH directory the two records are written to + DIST_PATH directory the packages were built into, default packages/dashmate/dist + + EXIT CODES: + 0 checksums recorded + 1 there was nothing to record + 2 wrong arguments +" + +OUTPUT_PATH="${1:-}" + +DIR_PATH=$(dirname "$(realpath "$0")") + +DIST_PATH="${2:-${DIR_PATH}/../dist}" + +if [ -z "${OUTPUT_PATH}" ] +then + echo "$cmd_usage" >&2 + exit 2 +fi + +# The records are addressed from inside the dist directory, so their location has to +# survive the change of directory below. +OUTPUT_PATH=$(realpath "${OUTPUT_PATH}") + +if command -v sha256sum > /dev/null; then + hash_cmd=(sha256sum) +else + hash_cmd=(shasum -a 256) +fi +cd "${DIST_PATH}" +# Hidden files are skipped to match the glob that uploads this +# directory, which does not match them either. Recording a file that +# never gets uploaded would fail the release for a package that was +# never meant to ship. +find . -type f -not -path '*/.*' | sed 's|^\./||' | LC_ALL=C sort > "${OUTPUT_PATH}/built-files" +if [ ! -s "${OUTPUT_PATH}/built-files" ]; then + echo "::error::No built packages found to record" + exit 1 +fi +# Recorded under the name the file will carry on the release rather +# than its path in the build tree: a release has no directories, so +# the upload flattens every file to its basename. A record keyed by +# path could only ever be matched against a published asset by hash, +# which is what lets bytes built for one target be served under +# another target's name. +: > "${OUTPUT_PATH}/built.sha256" +while IFS= read -r file; do + hash="$("${hash_cmd[@]}" "${file}" | cut -d' ' -f1)" + printf '%s %s\n' "${hash}" "${file##*/}" >> "${OUTPUT_PATH}/built.sha256" +done < "${OUTPUT_PATH}/built-files" +cat "${OUTPUT_PATH}/built.sha256" diff --git a/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js b/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js index 0c7efb43019..81c30a4ffb8 100644 --- a/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js +++ b/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js @@ -1,8 +1,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { selectDebBaseline, releaseLine } from '../../../../../scripts/deb_release_baseline.js'; +import { selectDebBaseline, releaseLine } from '../../../scripts/deb-release-baseline.js'; -const SCRIPT_PATH = fileURLToPath(new URL('../../../../../scripts/deb_release_baseline.js', import.meta.url)); +const SCRIPT_PATH = fileURLToPath(new URL('../../../scripts/deb-release-baseline.js', import.meta.url)); /** * A releases API entry, trimmed to the fields the baseline choice reads. @@ -26,7 +26,7 @@ function deb(version, arch = 'amd64') { return `dashmate_${version}_${arch}.deb`; } -describe('deb_release_baseline.js', () => { +describe('deb-release-baseline.js', () => { describe('#releaseLine', () => { it('should group a prerelease with the release it leads up to', () => { expect(releaseLine('v4.1.0-rc.3')).to.equal('4.1.0'.split('.').slice(0, 2).join('.')); diff --git a/packages/dashmate/test/unit/packaging/debVersion.spec.js b/packages/dashmate/test/unit/packaging/debVersion.spec.js index bd80a62788f..7b184fb6aff 100644 --- a/packages/dashmate/test/unit/packaging/debVersion.spec.js +++ b/packages/dashmate/test/unit/packaging/debVersion.spec.js @@ -1,8 +1,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { debVersionFromSemver, debFileNameVersion } from '../../../../../scripts/deb_version.js'; +import { debVersionFromSemver, debFileNameVersion } from '../../../scripts/deb-version.js'; -const SCRIPT_PATH = fileURLToPath(new URL('../../../../../scripts/deb_version.js', import.meta.url)); +const SCRIPT_PATH = fileURLToPath(new URL('../../../scripts/deb-version.js', import.meta.url)); /** * Independent port of dpkg's version comparison (`verrevcmp` in dpkg's version.c), @@ -112,7 +112,7 @@ function compareDebVersions(left, right) { return upstream === 0 ? verrevcmp(a.revision, b.revision) : upstream; } -describe('deb_version.js', () => { +describe('deb-version.js', () => { describe('version comparison oracle', () => { it('should order versions the way dpkg does', () => { expect(compareDebVersions('4.1.0', '4.1.0')).to.equal(0); diff --git a/scripts/pack_dashmate.sh b/scripts/pack_dashmate.sh index 9387b5aafcf..d150ce3cbe8 100755 --- a/scripts/pack_dashmate.sh +++ b/scripts/pack_dashmate.sh @@ -66,8 +66,8 @@ rewrite_deb_versions() { fi SEMVER_VERSION=$(node -p "require('./package.json').version") - DEB_VERSION=$(node "$DIR_PATH/deb_version.js" "$SEMVER_VERSION") - DEB_FILE_VERSION=$(node "$DIR_PATH/deb_version.js" --file-name "$SEMVER_VERSION") + DEB_VERSION=$(node "$ROOT_PATH/packages/dashmate/scripts/deb-version.js" "$SEMVER_VERSION") + DEB_FILE_VERSION=$(node "$ROOT_PATH/packages/dashmate/scripts/deb-version.js" --file-name "$SEMVER_VERSION") rm -rf "$STAGING_PATH" mkdir -p "$STAGING_PATH" From 609f597b3fe4a13b1dd3c17d9bc0b94e43ebad8e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 22:51:58 +0700 Subject: [PATCH 09/11] build: point every publishable package at the repository it lives in Fourteen manifests declared no repository and four still pointed at the standalone dashevo repositories these packages moved out of years ago, so npm showed the wrong source for them and would not accept a provenance attestation. That gap was previously worked around in the release workflow, which classified each workspace at publish time and split the release into an attested batch and a plain one so a manifest could not fail the run. With the metadata correct the workaround is unnecessary and publishing is a single command again. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 48 ++----------------- packages/dapi-grpc/package.json | 3 +- packages/dash-spv/package.json | 5 ++ packages/dashpay-contract/package.json | 3 +- .../document-history-contract/package.json | 5 ++ packages/dpns-contract/package.json | 5 ++ packages/js-dapi-client/package.json | 5 ++ packages/js-dash-sdk/package.json | 3 +- packages/js-evo-sdk/package.json | 5 ++ packages/js-grpc-common/package.json | 5 ++ packages/keyword-search-contract/package.json | 5 ++ .../package.json | 5 ++ packages/token-history-contract/package.json | 5 ++ packages/wallet-lib/package.json | 3 +- packages/wallet-utils-contract/package.json | 5 ++ packages/wasm-dpp/package.json | 5 ++ packages/wasm-dpp2/package.json | 5 ++ packages/wasm-sdk/package.json | 5 ++ packages/withdrawals-contract/package.json | 5 ++ 19 files changed, 82 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b261ba2546..9a67d8ef4d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,13 +224,8 @@ jobs: echo "NPM suffix: ${{ steps.suffix.outputs.result }}" echo "NPM release tag: ${{ steps.tag.outputs.result }}" - # npm rejects a provenance attestation unless the package declares a public - # `repository` matching, case-sensitively, the repository the build runs - # in, so the flag is applied per workspace rather than to the whole set: a - # manifest that does not qualify publishes exactly as it does today, with a - # warning naming the file to fix, instead of failing the release. - # Attested packages go first: publishing cannot be undone, so the batch - # carrying the newer machinery is the one to fail early. + # Every publishable workspace declares this repository, which is what npm + # requires before it will accept a provenance attestation. - name: Publish NPM packages env: NPM_RELEASE_TAG: ${{ steps.tag.outputs.result }} @@ -240,43 +235,8 @@ jobs: echo "Refusing to publish outside CI" exit 1 fi - - # Written to a file rather than piped into the loop: a process - # substitution hides its own failure, so an empty or failed listing - # would publish nothing and still report success. - workspaces="${RUNNER_TEMP}/publishable-workspaces" - yarn workspaces list --no-private --json | jq -r '.location' > "${workspaces}" - if [ ! -s "${workspaces}" ]; then - echo "::error::Found no publishable workspaces" - exit 1 - fi - - expected="https://github.com/${GITHUB_REPOSITORY}" - attested=() - plain=() - while IFS= read -r location; do - manifest="${location}/package.json" - name="$(jq -r '.name' "${manifest}")" - url="$(jq -r 'if (.repository | type) == "string" then .repository else .repository.url // "" end' "${manifest}")" - normalized="${url#git+}" - normalized="${normalized%.git}" - if [ "${normalized}" = "${expected}" ]; then - attested+=(--include "${name}") - else - plain+=(--include "${name}") - echo "::warning file=${manifest}::published without provenance: repository must be \"${expected}\", found \"${url:-}\"" - fi - done < "${workspaces}" - - if [ "${#attested[@]}" -gt 0 ]; then - yarn workspaces foreach --all --no-private --parallel "${attested[@]}" \ - npm publish --tolerate-republish --access public --provenance --tag "${NPM_RELEASE_TAG}" - fi - - if [ "${#plain[@]}" -gt 0 ]; then - yarn workspaces foreach --all --no-private --parallel "${plain[@]}" \ - npm publish --tolerate-republish --access public --tag "${NPM_RELEASE_TAG}" - fi + yarn workspaces foreach --all --no-private --parallel \ + npm publish --tolerate-republish --access public --provenance --tag "${NPM_RELEASE_TAG}" release-drive-image: name: Release Drive image diff --git a/packages/dapi-grpc/package.json b/packages/dapi-grpc/package.json index 5291c09f184..b432b19314c 100644 --- a/packages/dapi-grpc/package.json +++ b/packages/dapi-grpc/package.json @@ -37,7 +37,8 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/dashevo/dapi-grpc.git" + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/dapi-grpc" }, "license": "MIT", "bugs": { diff --git a/packages/dash-spv/package.json b/packages/dash-spv/package.json index 24752bca25b..23eb8161984 100644 --- a/packages/dash-spv/package.json +++ b/packages/dash-spv/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/dash-spv", "version": "5.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/dash-spv" + }, "description": "Repository containing SPV functions used by @dashevo", "main": "index.js", "scripts": { diff --git a/packages/dashpay-contract/package.json b/packages/dashpay-contract/package.json index 77b5b42dff8..df8d40704f3 100644 --- a/packages/dashpay-contract/package.json +++ b/packages/dashpay-contract/package.json @@ -9,7 +9,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/dashevo/dashpay-contract.git" + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/dashpay-contract" }, "author": "Dash Core Team", "contributors": [ diff --git a/packages/document-history-contract/package.json b/packages/document-history-contract/package.json index 44be2ed914f..a14d76ead3b 100644 --- a/packages/document-history-contract/package.json +++ b/packages/document-history-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/document-history-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/document-history-contract" + }, "description": "The document history contract", "scripts": { "lint": "eslint .", diff --git a/packages/dpns-contract/package.json b/packages/dpns-contract/package.json index 34daf3f5a39..796e78ebcbb 100644 --- a/packages/dpns-contract/package.json +++ b/packages/dpns-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/dpns-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/dpns-contract" + }, "description": "A contract and helper scripts for DPNS DApp", "scripts": { "lint": "eslint .", diff --git a/packages/js-dapi-client/package.json b/packages/js-dapi-client/package.json index 0344e1e541d..1b08d35653d 100644 --- a/packages/js-dapi-client/package.json +++ b/packages/js-dapi-client/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/dapi-client", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/js-dapi-client" + }, "description": "Client library used to access Dash DAPI endpoints", "main": "lib/index.js", "contributors": [ diff --git a/packages/js-dash-sdk/package.json b/packages/js-dash-sdk/package.json index 72ac80247b6..739e2eb7a86 100644 --- a/packages/js-dash-sdk/package.json +++ b/packages/js-dash-sdk/package.json @@ -29,7 +29,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/dashevo/DashJS.git" + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/js-dash-sdk" }, "author": "Dash Core Group ", "license": "MIT", diff --git a/packages/js-evo-sdk/package.json b/packages/js-evo-sdk/package.json index 18489d9282e..a3a423a490b 100644 --- a/packages/js-evo-sdk/package.json +++ b/packages/js-evo-sdk/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/evo-sdk", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/js-evo-sdk" + }, "type": "module", "main": "./dist/evo-sdk.module.js", "types": "./dist/sdk.d.ts", diff --git a/packages/js-grpc-common/package.json b/packages/js-grpc-common/package.json index 6fb9b00d34d..a53e3f24377 100644 --- a/packages/js-grpc-common/package.json +++ b/packages/js-grpc-common/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/grpc-common", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/js-grpc-common" + }, "description": "Common GRPC library", "main": "index.js", "scripts": { diff --git a/packages/keyword-search-contract/package.json b/packages/keyword-search-contract/package.json index 00a59312929..36bcb3339b9 100644 --- a/packages/keyword-search-contract/package.json +++ b/packages/keyword-search-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/keyword-search-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/keyword-search-contract" + }, "description": "A contract that allows searching for contracts", "scripts": { "lint": "eslint .", diff --git a/packages/masternode-reward-shares-contract/package.json b/packages/masternode-reward-shares-contract/package.json index e404646da84..440924ade9c 100644 --- a/packages/masternode-reward-shares-contract/package.json +++ b/packages/masternode-reward-shares-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/masternode-reward-shares-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/masternode-reward-shares-contract" + }, "description": "A contract and helper scripts for reward sharing", "scripts": { "lint": "eslint .", diff --git a/packages/token-history-contract/package.json b/packages/token-history-contract/package.json index 782bcc39d19..7f01c261195 100644 --- a/packages/token-history-contract/package.json +++ b/packages/token-history-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/token-history-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/token-history-contract" + }, "description": "The token history contract", "scripts": { "lint": "eslint .", diff --git a/packages/wallet-lib/package.json b/packages/wallet-lib/package.json index 74ee5692073..e60e9b9f7d7 100644 --- a/packages/wallet-lib/package.json +++ b/packages/wallet-lib/package.json @@ -31,7 +31,8 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/dashevo/wallet-lib.git" + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/wallet-lib" }, "keywords": [ "cryptocurrency", diff --git a/packages/wallet-utils-contract/package.json b/packages/wallet-utils-contract/package.json index 25362f53fa8..bda5d449660 100644 --- a/packages/wallet-utils-contract/package.json +++ b/packages/wallet-utils-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/wallet-utils-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/wallet-utils-contract" + }, "description": "A contract and helper scripts for Wallet DApp", "scripts": { "lint": "eslint .", diff --git a/packages/wasm-dpp/package.json b/packages/wasm-dpp/package.json index 6ce455d1df8..b1f4fe3e1f6 100644 --- a/packages/wasm-dpp/package.json +++ b/packages/wasm-dpp/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/wasm-dpp", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/wasm-dpp" + }, "description": "The JavaScript implementation of the Dash Platform Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/wasm-dpp2/package.json b/packages/wasm-dpp2/package.json index 7cc9ccb3756..2458ce2cd20 100644 --- a/packages/wasm-dpp2/package.json +++ b/packages/wasm-dpp2/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/wasm-dpp2", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/wasm-dpp2" + }, "type": "module", "main": "./dist/dpp.js", "types": "./dist/dpp.d.ts", diff --git a/packages/wasm-sdk/package.json b/packages/wasm-sdk/package.json index 1a5dfba21df..2831c1e9e52 100644 --- a/packages/wasm-sdk/package.json +++ b/packages/wasm-sdk/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/wasm-sdk", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/wasm-sdk" + }, "type": "module", "main": "./dist/sdk.js", "types": "./dist/sdk.d.ts", diff --git a/packages/withdrawals-contract/package.json b/packages/withdrawals-contract/package.json index 7acc9c3a701..806829d465c 100644 --- a/packages/withdrawals-contract/package.json +++ b/packages/withdrawals-contract/package.json @@ -1,6 +1,11 @@ { "name": "@dashevo/withdrawals-contract", "version": "4.2.0-dev.7", + "repository": { + "type": "git", + "url": "https://github.com/dashpay/platform.git", + "directory": "packages/withdrawals-contract" + }, "description": "Data Contract to manipulate and track withdrawals", "scripts": { "build": "", From 68aed13f7f216d1889b5e7e8e9183964edb117f3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 1 Sep 2026 04:12:53 +0700 Subject: [PATCH 10/11] fix(dashmate): report a missing dpkg before downloading anything to compare The baseline version is read with dpkg-deb, which ran before the comparison script checked that dpkg exists. A host without either tool died on a missing command after already downloading a package, instead of reporting the status that means the comparison could not be made. Co-Authored-By: Claude Opus 5 --- .../dashmate/scripts/check-release-deb-version.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/dashmate/scripts/check-release-deb-version.sh b/packages/dashmate/scripts/check-release-deb-version.sh index 968d4f71d1e..0ce9ef1bfb3 100755 --- a/packages/dashmate/scripts/check-release-deb-version.sh +++ b/packages/dashmate/scripts/check-release-deb-version.sh @@ -39,6 +39,20 @@ then exit 2 fi +# Both tools are needed before anything is downloaded: the baseline version is +# read with dpkg-deb and compared with dpkg. Checking here keeps a host without +# them reporting the documented "could not compare" status rather than dying on +# a missing command part way through. +for tool in dpkg dpkg-deb +do + if ! command -v "$tool" > /dev/null 2>&1 + then + echo "check-release-deb-version.sh: $tool not found, cannot compare Debian versions." >&2 + echo "Run this on a Debian based host or inside a container that has dpkg." >&2 + exit 3 + fi +done + DIR_PATH=$(dirname "$(realpath "$0")") compare="${DIR_PATH}/check-deb-version.sh" From 810b852b3197ffbbe6a47e066825ce732eac2a83 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 2 Sep 2026 23:57:29 +0700 Subject: [PATCH 11/11] refactor(dashmate): name the packaging scripts by the module system they use Moving them beside the package made them ES modules by inheriting its manifest, so running one from anywhere that manifest is not above it failed with a syntax error pointing at the import statement rather than at the cause. Packaging scripts get copied into staging directories, so the extension now says what the file is instead of depending on where it sits. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 2 +- packages/dashmate/scripts/check-deb-version.sh | 4 ++-- packages/dashmate/scripts/check-release-deb-version.sh | 8 ++++---- .../{deb-release-baseline.js => deb-release-baseline.mjs} | 2 +- .../dashmate/scripts/{deb-version.js => deb-version.mjs} | 2 +- .../test/unit/packaging/debReleaseBaseline.spec.js | 6 +++--- packages/dashmate/test/unit/packaging/debVersion.spec.js | 6 +++--- scripts/pack_dashmate.sh | 4 ++-- 8 files changed, 17 insertions(+), 17 deletions(-) rename packages/dashmate/scripts/{deb-release-baseline.js => deb-release-baseline.mjs} (98%) rename packages/dashmate/scripts/{deb-version.js => deb-version.mjs} (98%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a67d8ef4d0..5e8558128ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ concurrency: permissions: contents: read -# Debian version knobs, read by packages/dashmate/scripts/deb-version.js in both +# Debian version knobs, read by packages/dashmate/scripts/deb-version.mjs in both # the version gate and the packaging job. They have to stay in lockstep: a value # seen by only one of them makes the gate validate a version that is not the one # that ships. diff --git a/packages/dashmate/scripts/check-deb-version.sh b/packages/dashmate/scripts/check-deb-version.sh index 3dca5924a51..c816cbb2cfc 100755 --- a/packages/dashmate/scripts/check-deb-version.sh +++ b/packages/dashmate/scripts/check-deb-version.sh @@ -12,8 +12,8 @@ Both arguments are Debian package versions ([EPOCH:]UPSTREAM[-REVISION]), not se tags. Translate a semver version first: packages/dashmate/scripts/check-deb-version.sh \\ - \"\$(node packages/dashmate/scripts/deb-version.js 4.1.0-rc.4)\" \\ - \"\$(node packages/dashmate/scripts/deb-version.js 4.1.0-rc.3)\" + \"\$(node packages/dashmate/scripts/deb-version.mjs 4.1.0-rc.4)\" \\ + \"\$(node packages/dashmate/scripts/deb-version.mjs 4.1.0-rc.3)\" EXIT CODES: 0 new version sorts above the previous one diff --git a/packages/dashmate/scripts/check-release-deb-version.sh b/packages/dashmate/scripts/check-release-deb-version.sh index 0ce9ef1bfb3..372a26374f5 100755 --- a/packages/dashmate/scripts/check-release-deb-version.sh +++ b/packages/dashmate/scripts/check-release-deb-version.sh @@ -7,7 +7,7 @@ cmd_usage="Usage: check-release-deb-version.sh CURRENT_TAG Checks that the deb built for CURRENT_TAG sorts above the deb of the last release operators were offered, which is what decides whether apt takes it as an upgrade. -The baseline release is chosen by deb-release-baseline.js, its version is read from +The baseline release is chosen by deb-release-baseline.mjs, its version is read from the published package's own control field, and the two are compared by check-deb-version.sh. @@ -56,8 +56,8 @@ done DIR_PATH=$(dirname "$(realpath "$0")") compare="${DIR_PATH}/check-deb-version.sh" -translate="${DIR_PATH}/deb-version.js" -select_baseline="${DIR_PATH}/deb-release-baseline.js" +translate="${DIR_PATH}/deb-version.mjs" +select_baseline="${DIR_PATH}/deb-release-baseline.mjs" if [ ! -x "${compare}" ] || [ ! -f "${translate}" ] || [ ! -f "${select_baseline}" ]; then echo "::error::${compare}, ${translate} or ${select_baseline} is missing" exit 1 @@ -105,6 +105,6 @@ echo "Comparing ${CURRENT_TAG} (${new_version}) against ${baseline_asset} from $ status=0 "${compare}" "${new_version}" "${baseline_version}" || status=$? if [ "${status}" -eq 1 ]; then - echo "::error::${new_version} does not sort above ${baseline_version}, so apt would refuse this release as a downgrade or report it as already the newest version. Rebuilding an already published version needs DASHMATE_DEB_REVISION; re-releasing a version whose predecessor carried a git sha in its upstream part needs DASHMATE_DEB_EPOCH=1. Both are read by packages/dashmate/scripts/deb-version.js and must be set for the packaging job as well." + echo "::error::${new_version} does not sort above ${baseline_version}, so apt would refuse this release as a downgrade or report it as already the newest version. Rebuilding an already published version needs DASHMATE_DEB_REVISION; re-releasing a version whose predecessor carried a git sha in its upstream part needs DASHMATE_DEB_EPOCH=1. Both are read by packages/dashmate/scripts/deb-version.mjs and must be set for the packaging job as well." fi exit "${status}" diff --git a/packages/dashmate/scripts/deb-release-baseline.js b/packages/dashmate/scripts/deb-release-baseline.mjs similarity index 98% rename from packages/dashmate/scripts/deb-release-baseline.js rename to packages/dashmate/scripts/deb-release-baseline.mjs index 309cd0b1a5d..2ff2d7463bc 100644 --- a/packages/dashmate/scripts/deb-release-baseline.js +++ b/packages/dashmate/scripts/deb-release-baseline.mjs @@ -74,7 +74,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { const currentTag = process.argv[2]; if (!currentTag) { - console.error('Usage: deb-release-baseline.js CURRENT_TAG < releases.json\n\n' + console.error('Usage: deb-release-baseline.mjs CURRENT_TAG < releases.json\n\n' + ' Reads the GitHub releases API response on stdin and prints the tag and the\n' + ' package file name of the release the current tag has to sort above, separated\n' + ' by a tab. Prints nothing when no published release has shipped a package.\n'); diff --git a/packages/dashmate/scripts/deb-version.js b/packages/dashmate/scripts/deb-version.mjs similarity index 98% rename from packages/dashmate/scripts/deb-version.js rename to packages/dashmate/scripts/deb-version.mjs index e3567d13498..9b79f8eeb12 100644 --- a/packages/dashmate/scripts/deb-version.js +++ b/packages/dashmate/scripts/deb-version.mjs @@ -104,7 +104,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { const version = forFileName ? args[1] : args[0]; if (!version) { - console.error('Usage: deb-version.js [--file-name] SEMVER_VERSION\n\n' + console.error('Usage: deb-version.mjs [--file-name] SEMVER_VERSION\n\n' + ' Prints the Debian package version for a semver version, or the version as it\n' + ' appears in the package file name.\n\n' + ' DASHMATE_DEB_REVISION Debian revision, default 1. Bump it to rebuild a version\n' diff --git a/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js b/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js index 81c30a4ffb8..13f068c6cb6 100644 --- a/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js +++ b/packages/dashmate/test/unit/packaging/debReleaseBaseline.spec.js @@ -1,8 +1,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { selectDebBaseline, releaseLine } from '../../../scripts/deb-release-baseline.js'; +import { selectDebBaseline, releaseLine } from '../../../scripts/deb-release-baseline.mjs'; -const SCRIPT_PATH = fileURLToPath(new URL('../../../scripts/deb-release-baseline.js', import.meta.url)); +const SCRIPT_PATH = fileURLToPath(new URL('../../../scripts/deb-release-baseline.mjs', import.meta.url)); /** * A releases API entry, trimmed to the fields the baseline choice reads. @@ -26,7 +26,7 @@ function deb(version, arch = 'amd64') { return `dashmate_${version}_${arch}.deb`; } -describe('deb-release-baseline.js', () => { +describe('deb-release-baseline.mjs', () => { describe('#releaseLine', () => { it('should group a prerelease with the release it leads up to', () => { expect(releaseLine('v4.1.0-rc.3')).to.equal('4.1.0'.split('.').slice(0, 2).join('.')); diff --git a/packages/dashmate/test/unit/packaging/debVersion.spec.js b/packages/dashmate/test/unit/packaging/debVersion.spec.js index 7b184fb6aff..1fcd3351a6d 100644 --- a/packages/dashmate/test/unit/packaging/debVersion.spec.js +++ b/packages/dashmate/test/unit/packaging/debVersion.spec.js @@ -1,8 +1,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { debVersionFromSemver, debFileNameVersion } from '../../../scripts/deb-version.js'; +import { debVersionFromSemver, debFileNameVersion } from '../../../scripts/deb-version.mjs'; -const SCRIPT_PATH = fileURLToPath(new URL('../../../scripts/deb-version.js', import.meta.url)); +const SCRIPT_PATH = fileURLToPath(new URL('../../../scripts/deb-version.mjs', import.meta.url)); /** * Independent port of dpkg's version comparison (`verrevcmp` in dpkg's version.c), @@ -112,7 +112,7 @@ function compareDebVersions(left, right) { return upstream === 0 ? verrevcmp(a.revision, b.revision) : upstream; } -describe('deb-version.js', () => { +describe('deb-version.mjs', () => { describe('version comparison oracle', () => { it('should order versions the way dpkg does', () => { expect(compareDebVersions('4.1.0', '4.1.0')).to.equal(0); diff --git a/scripts/pack_dashmate.sh b/scripts/pack_dashmate.sh index d150ce3cbe8..45904c98f14 100755 --- a/scripts/pack_dashmate.sh +++ b/scripts/pack_dashmate.sh @@ -66,8 +66,8 @@ rewrite_deb_versions() { fi SEMVER_VERSION=$(node -p "require('./package.json').version") - DEB_VERSION=$(node "$ROOT_PATH/packages/dashmate/scripts/deb-version.js" "$SEMVER_VERSION") - DEB_FILE_VERSION=$(node "$ROOT_PATH/packages/dashmate/scripts/deb-version.js" --file-name "$SEMVER_VERSION") + DEB_VERSION=$(node "$ROOT_PATH/packages/dashmate/scripts/deb-version.mjs" "$SEMVER_VERSION") + DEB_FILE_VERSION=$(node "$ROOT_PATH/packages/dashmate/scripts/deb-version.mjs" --file-name "$SEMVER_VERSION") rm -rf "$STAGING_PATH" mkdir -p "$STAGING_PATH"