From f7ea620a38054423fd25cbad5be4a6b1d1f9214a Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:37:42 +0000 Subject: [PATCH 1/4] Add guarded GitHub release publication and repository workflow --- .github/workflows/checks.yml | 9 +- .github/workflows/release.yml | 67 +++++++++++ AGENTS.md | 8 +- DEVELOPMENT.md | 17 +++ sdk/package.json | 3 +- sdk/tests/release-publication.test.js | 70 ++++++++++++ sdk/tooling/release-publication.js | 159 ++++++++++++++++++++++++++ tooling/release.js | 4 + 8 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 sdk/tests/release-publication.test.js create mode 100644 sdk/tooling/release-publication.js create mode 100644 tooling/release.js diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2159957..9fe5b57 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -4,6 +4,9 @@ on: push: branches: [main] workflow_dispatch: +concurrency: + group: core-checks-${{ github.ref }} + cancel-in-progress: true permissions: contents: read jobs: @@ -11,10 +14,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm @@ -24,7 +27,7 @@ jobs: - run: npm test - run: npm run build -- "$RUNNER_TEMP/core-candidate" - run: tar -C "$RUNNER_TEMP/platform-packages" -czf "$RUNNER_TEMP/platform-packages.tar.gz" . - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: core-development-candidate path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..504660b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Publish core release +on: + workflow_dispatch: + inputs: + version: + description: Version explicitly selected by the owner (X.Y.Z) + required: true + type: string + expected_main: + description: Verified latest main commit (40 characters) + required: true + type: string + changelog: + description: Reviewed release notes + required: true + type: string +concurrency: + group: core-release-publication + cancel-in-progress: false +permissions: + contents: read +jobs: + publish: + if: github.ref == 'refs/heads/main' && inputs.expected_main == github.sha + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + actions: read + id-token: write + attestations: write + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_COMMIT: ${{ inputs.expected_main }} + RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md + RELEASE_PLATFORM_BUNDLE: ${{ runner.temp }}/platform-packages + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.expected_main }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: dashboard/package-lock.json + - run: npm run bootstrap -- "$RUNNER_TEMP/platform-packages" + - name: Verify selected main and unused version + env: + GH_TOKEN: ${{ github.token }} + run: node tooling/release.js guard + - run: npm run check + - run: npm test + - run: npm run build -- "$RUNNER_TEMP/candidate" + - name: Package the reviewed release + env: + RELEASE_NOTES: ${{ inputs.changelog }} + run: | + printf '%s\n' "$RELEASE_NOTES" > "$RELEASE_NOTES_FILE" + node tooling/release.js package "$RUNNER_TEMP/candidate" "$RUNNER_TEMP/publication" + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ runner.temp }}/publication/* + - name: Publish verified assets to GitHub Releases + env: + GH_TOKEN: ${{ github.token }} + run: node tooling/release.js publish "$RUNNER_TEMP/publication" diff --git a/AGENTS.md b/AGENTS.md index 8040534..a397a29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,10 @@ # Dispatch Core -This is the future `dispatch-core` repository. The current task is local only: -do not initialize Git, publish, or change production. In the development workspace, -read the workspace `AGENTS.md` and `dev/AGENTS.md` as well. +This is the `dispatch-core` repository. Develop in feature worktrees, +open PRs, and present verified PR details before requesting merge approval. +Merge only with explicit approval in chat. Publishing requires a user-requested +release and user-selected version. Installation is a separate owner action. +In the Dispatch workspace, also read the workspace `AGENTS.md` and `dev/AGENTS.md`. - `core/`: shared API, accounts, authorization, browser manager, auth broker, plugin service coordination and update state. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 19928f3..eb99514 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -56,3 +56,20 @@ integration coverage. Older native/OCI restore fixtures remain beside their components and are selected explicitly when changing compatibility code. Historical monolithic release automation has been retired from these repositories. See `RELEASES.md` for the human-approved branch, PR and release workflow. + +## GitHub workflow + +Use an isolated feature worktree from freshly fetched `origin/main`. Open a draft +PR after the first reviewed commit. Run the applicable local checks and wait for +GitHub checks on the exact PR commit before marking it ready. Report the PR link, +changes and verification in chat. Only merge after the owner explicitly approves; +recheck the approved head and required checks immediately before merging. Automatic +merge is disabled. Main requires PRs, up-to-date checks and resolved conversations. +The owner's chat approval is the human gate; GitHub does not interpret chat. + +`tooling/workflow.py pr-details --repo OWNER/REPOSITORY --pr NUMBER` reports PR +facts. This helper is read-only; use normal git/gh commands for branches and PRs. +Keep multiline PR bodies in a file and pass `--body-file`. + +The manual release workflow is a separate operation; never dispatch it as part of +ordinary development, merging, testing or retrying CI. See `RELEASES.md`. diff --git a/sdk/package.json b/sdk/package.json index 7a50b5b..0b37318 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -44,7 +44,8 @@ "./runtime/storage": "./node/storage.js", "./runtime/cdp-transport": "./node/cdp-transport.js", "./tooling/project": "./tooling/project.js", - "./tooling/release-package": "./tooling/release-package.js" + "./tooling/release-package": "./tooling/release-package.js", + "./tooling/release-publication": "./tooling/release-publication.js" }, "files": [ "src", diff --git a/sdk/tests/release-publication.test.js b/sdk/tests/release-publication.test.js new file mode 100644 index 0000000..3d41ae8 --- /dev/null +++ b/sdk/tests/release-publication.test.js @@ -0,0 +1,70 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { inventory, hash, verifyRelease } = require('dispatch-protocol/releases/package'); +const { identity, packageRelease, context, assertVerifiedMain, assertUnusedVersion, verifyPublication } = require('../tooling/release-publication'); +const selected = { product: 'core', repository: 'example/dispatch-core', version: '1.2.3', commit: 'a'.repeat(40) }; + +test('release package preserves candidate, binds source and verifies every packaged file', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-publication-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const candidate = path.join(root, 'candidate'); + fs.mkdirSync(path.join(candidate, 'code'), { recursive: true }); + fs.writeFileSync(path.join(candidate, 'code/package.json'), JSON.stringify({ name: 'dispatch-core', version: '0.0.0' })); + fs.writeFileSync(path.join(candidate, 'code/server.js'), 'module.exports = {};\n'); + const files = inventory(candidate); + const manifest = { schemaVersion: 1, product: 'core', version: '0.0.0', channel: 'development', protocol: 1, minimumProtocol: 1, + sourceDigest: hash(JSON.stringify(files)), packages: {}, plugins: [], files }; + const original = JSON.stringify(manifest); + fs.writeFileSync(path.join(candidate, 'release.json'), original); + const options = { ...selected, candidate, notes: 'x'.repeat(30) }; + const first = path.join(root, 'first'), second = path.join(root, 'second'); + const result = packageRelease({ ...options, output: first }); + packageRelease({ ...options, output: second }); + assert.equal(fs.readFileSync(path.join(candidate, 'release.json'), 'utf8'), original); + assert.deepEqual(fs.readFileSync(path.join(first, result.archive)), fs.readFileSync(path.join(second, result.archive))); + const extracted = path.join(root, 'extracted');fs.mkdirSync(extracted); + execFileSync('tar', ['-xzf', path.join(first, result.archive), '-C', extracted]); + const released = verifyRelease(extracted, result.digest); + assert.equal(released.channel, 'release');assert.equal(released.source.commit, selected.commit); + assert.equal(JSON.parse(fs.readFileSync(path.join(extracted, 'code/package.json'))).version, selected.version); + assert.equal(verifyPublication(first, selected).length, 4); + fs.appendFileSync(path.join(first, result.archive), 'tampered'); + assert.throws(() => verifyPublication(first, selected), /digest_mismatch/); + fs.appendFileSync(path.join(candidate, 'code/server.js'), 'tampered'); + assert.throws(() => packageRelease({ ...options, output: path.join(root, 'bad') }), /digest_mismatch/); +}); + +test('publication rejects invalid versions and another product repository', () => { + for (const version of ['0.0.0', '01.2.3', '1.2', 'v1.2.3', '1.2.3;echo bad']) assert.throws(() => identity({ ...selected, version }), /identity_invalid/); + assert.throws(() => identity({ ...selected, repository: 'example/dispatch-dsp' }), /identity_invalid/); +}); + +test('only an explicit main workflow dispatch at the selected checkout may publish', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-release-context-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'dispatch-core' })); + const env = { GITHUB_REPOSITORY: selected.repository, RELEASE_COMMIT: selected.commit, RELEASE_VERSION: selected.version, + GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', GITHUB_SHA: selected.commit }; + assert.throws(() => context(root, env), /main_dispatch_required/); + assert.throws(() => context(root, { ...env, GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/feature' }), /main_dispatch_required/); +}); + +test('release requires successful main push checks and refuses changed main', () => { + const run = runs => (command, args) => JSON.stringify(args.at(-1).endsWith('/commits/main') ? { sha: selected.commit } : { workflow_runs: runs }); + assert.throws(() => assertVerifiedMain(selected, '.', run([])), /checks_required/); + assert.throws(() => assertVerifiedMain(selected, '.', () => JSON.stringify({ sha: 'b'.repeat(40) })), /main_changed/); + assert.throws(() => assertVerifiedMain(selected, '.', run([{ head_sha: selected.commit, head_branch: 'main', event: 'pull_request', conclusion: 'success' }])), /checks_required/); + assert.equal(assertVerifiedMain(selected, '.', run([{ head_sha: selected.commit, head_branch: 'main', event: 'push', conclusion: 'success' }])).verified, true); +}); + +test('existing tags and draft releases cannot be overwritten, and API errors fail closed', () => { + assert.throws(() => assertUnusedVersion(selected, '.', () => JSON.stringify([[{ ref: 'refs/tags/v1.2.3' }]])), /version_exists/); + assert.throws(() => assertUnusedVersion(selected, '.', () => JSON.stringify([[{ tag_name: 'v1.2.3', draft: true }]])), /version_exists/); + assert.throws(() => assertUnusedVersion(selected, '.', () => { throw new Error('network unavailable'); }), /network unavailable/); + assert.doesNotThrow(() => assertUnusedVersion(selected, '.', () => '[[]]')); +}); diff --git a/sdk/tooling/release-publication.js b/sdk/tooling/release-publication.js new file mode 100644 index 0000000..3bfc052 --- /dev/null +++ b/sdk/tooling/release-publication.js @@ -0,0 +1,159 @@ +'use strict'; + +// Shared publication tooling. This module never activates a release or contacts a DSP. +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const sha256 = value => crypto.createHash('sha256').update(value).digest('hex'); +const execute = (command, args, cwd) => execFileSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }).trim(); +const validVersion = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) && value !== '0.0.0'; + +function identity({ repository, commit, version, product }) { + if (!['core', 'dsp'].includes(product) || !new RegExp(`^[A-Za-z0-9_.-]+/dispatch-${product}$`).test(repository) + || !/^[a-f0-9]{40}$/.test(commit) || !validVersion(version)) throw new Error('release_identity_invalid'); + return { repository, commit, ref: 'refs/heads/main' }; +} + +function packageRelease({ candidate, output, repository, commit, version, notes, platformBundle }) { + const { inventory, verifyRelease } = require('dispatch-protocol/releases/package'); + const original = JSON.parse(fs.readFileSync(path.join(candidate, 'release.json'))); + verifyRelease(candidate, sha256(JSON.stringify(original))); + const source = identity({ repository, commit, version, product: original.product }); + if (original.channel !== 'development') throw new Error('release_candidate_required'); + if (typeof notes !== 'string' || notes.trim().length < 20 || Buffer.byteLength(notes) > 100000 || notes.includes('\0')) throw new Error('release_notes_invalid'); + candidate = fs.realpathSync(candidate); + output = path.resolve(output); + if (output === candidate || output.startsWith(candidate + path.sep) || fs.existsSync(output)) throw new Error('release_output_invalid'); + fs.mkdirSync(output, { recursive: true, mode: 0o700 }); + const staging = fs.mkdtempSync(path.join(path.dirname(output), '.release-package-')); + try { + fs.cpSync(candidate, staging, { recursive: true }); + fs.chmodSync(path.join(staging, 'release.json'), 0o600); + fs.unlinkSync(path.join(staging, 'release.json')); + const packagePath = path.join(staging, 'code/package.json'); + const pkg = JSON.parse(fs.readFileSync(packagePath)); + if (pkg.name !== `dispatch-${original.product}` || pkg.version !== original.version) throw new Error('release_product_mismatch'); + pkg.version = version; + fs.chmodSync(packagePath, 0o644); + fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n'); + fs.writeFileSync(path.join(staging, 'release-notes.md'), notes.trim() + '\n', { mode: 0o644 }); + const files = inventory(staging); + const manifest = { ...original, version, channel: 'release', source, + sourceDigest: sha256(JSON.stringify(files.filter(file => file.path.startsWith('code/')))), files }; + if (platformBundle) { + if (original.product !== 'core') throw new Error('release_platform_bundle_invalid'); + inventory(platformBundle); + const bundle = JSON.parse(fs.readFileSync(path.join(platformBundle, 'manifest.json'))); + if (bundle.kind !== 'dispatch-platform-packages' || bundle.packages.length !== Object.keys(original.packages).length + || bundle.packages.some(item => original.packages[item.name] !== item.version)) throw new Error('release_platform_bundle_invalid'); + const asset = 'platform-packages.tar.gz'; + execute('tar', ['--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner', '-czf', path.join(output, asset), '-C', platformBundle, '.']); + manifest.platformBundle = { asset, sha256: sha256(fs.readFileSync(path.join(output, asset))) }; + } + const serialized = JSON.stringify(manifest, null, 2) + '\n'; + fs.writeFileSync(path.join(staging, 'release.json'), serialized, { mode: 0o444 }); + const digest = sha256(JSON.stringify(manifest)); + verifyRelease(staging, digest); + const archive = `dispatch-${manifest.product}-${version}.tar.gz`; + execute('tar', ['--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner', '-czf', path.join(output, archive), '-C', staging, '.']); + fs.writeFileSync(path.join(output, 'release.json'), serialized); + fs.copyFileSync(path.join(staging, 'release-notes.md'), path.join(output, 'release-notes.md')); + const names = [archive, 'release.json', 'release-notes.md']; + if (manifest.platformBundle) names.push(manifest.platformBundle.asset); + fs.writeFileSync(path.join(output, 'SHA256SUMS'), names.map(name => `${sha256(fs.readFileSync(path.join(output, name)))} ${name}\n`).join('')); + return { product: manifest.product, version, digest, archive, source }; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +} + +function context(root, env = process.env) { + const product = JSON.parse(fs.readFileSync(path.join(root, 'package.json'))).name.replace('dispatch-', ''); + const selected = { repository: env.GITHUB_REPOSITORY, commit: env.RELEASE_COMMIT, version: env.RELEASE_VERSION, product }; + identity(selected); + if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || env.GITHUB_REF !== 'refs/heads/main' + || env.GITHUB_SHA !== selected.commit || execute('git', ['rev-parse', 'HEAD'], root) !== selected.commit) throw new Error('release_main_dispatch_required'); + return selected; +} + +function assertVerifiedMain(selected, root, run = execute) { + const { repository, commit, product } = selected; + const main = JSON.parse(run('gh', ['api', `repos/${repository}/commits/main`], root)); + if (main.sha !== commit) throw new Error('release_main_changed'); + const runs = JSON.parse(run('gh', ['api', `repos/${repository}/actions/workflows/checks.yml/runs?head_sha=${commit}&event=push&status=success&per_page=100`], root)).workflow_runs; + if (!runs.some(item => item.head_sha === commit && item.head_branch === 'main' && item.event === 'push' && item.conclusion === 'success')) throw new Error('release_main_checks_required'); + // Check runs alone are not a merge approval; branch protection and the chat workflow govern integration. + return { product, commit, verified: true }; +} + +function assertUnusedVersion(selected, root, run = execute) { + const { repository, version } = selected; + // Paginated lists avoid treating an authentication/network error as an absent tag. + const tags = JSON.parse(run('gh', ['api', '--paginate', '--slurp', `repos/${repository}/git/matching-refs/tags/v${version}`], root)).flat(); + const releases = JSON.parse(run('gh', ['api', '--paginate', '--slurp', `repos/${repository}/releases?per_page=100`], root)).flat(); + if (tags.some(item => item.ref === `refs/tags/v${version}`) || releases.some(item => item.tag_name === `v${version}`)) throw new Error('release_version_exists'); +} + +function verifyPublication(directory, selected) { + const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'release.json'))); + identity({ ...selected, product: manifest.product }); + if (manifest.product !== selected.product || manifest.version !== selected.version || manifest.channel !== 'release' + || manifest.source?.commit !== selected.commit || manifest.source?.repository !== selected.repository + || manifest.source?.ref !== 'refs/heads/main') throw new Error('release_publication_mismatch'); + const archive = `dispatch-${selected.product}-${selected.version}.tar.gz`; + const names = [archive, 'release.json', 'release-notes.md']; + if (manifest.platformBundle) { + if (manifest.product !== 'core' || manifest.platformBundle.asset !== 'platform-packages.tar.gz' + || sha256(fs.readFileSync(path.join(directory, 'platform-packages.tar.gz'))) !== manifest.platformBundle.sha256) throw new Error('release_platform_bundle_invalid'); + names.push(manifest.platformBundle.asset); + } + const expected = names.map(name => `${sha256(fs.readFileSync(path.join(directory, name)))} ${name}\n`).join(''); + if (fs.readFileSync(path.join(directory, 'SHA256SUMS'), 'utf8') !== expected) throw new Error('release_publication_digest_mismatch'); + return [...names, 'SHA256SUMS']; +} + +function publish(directory, selected, root, run = execute) { + const names = verifyPublication(directory, selected); + assertVerifiedMain(selected, root, run); + assertUnusedVersion(selected, root, run); + for (const name of names) run('gh', ['attestation', 'verify', path.join(directory, name), '--repo', selected.repository, + '--signer-workflow', `${selected.repository}/.github/workflows/release.yml`, '--source-ref', 'refs/heads/main', + '--source-digest', selected.commit, '--deny-self-hosted-runners'], root); + // Recheck after network verification; concurrent main changes must never select different code. + assertVerifiedMain(selected, root, run); + const tag = `v${selected.version}`; + run('gh', ['api', '--method', 'POST', `repos/${selected.repository}/git/refs`, '-f', `ref=refs/tags/${tag}`, '-f', `sha=${selected.commit}`], root); + run('gh', ['release', 'create', tag, ...names.map(name => path.join(directory, name)), '--repo', selected.repository, + '--verify-tag', '--draft', '--title', `Dispatch ${selected.product === 'core' ? 'Core' : 'DSP'} ${selected.version}`, + '--notes-file', path.join(directory, 'release-notes.md')], root); + const check = fs.mkdtempSync(path.join(path.dirname(directory), '.release-download-')); + try { + run('gh', ['release', 'download', tag, '--repo', selected.repository, '--dir', check], root); + for (const name of names) if (!fs.readFileSync(path.join(check, name)).equals(fs.readFileSync(path.join(directory, name)))) throw new Error('release_upload_mismatch'); + assertVerifiedMain(selected, root, run); + run('gh', ['release', 'edit', tag, '--repo', selected.repository, '--draft=false', '--latest'], root); + const release = JSON.parse(run('gh', ['release', 'view', tag, '--repo', selected.repository, '--json', 'url,isDraft,tagName'], root)); + if (release.isDraft || release.tagName !== tag) throw new Error('release_publication_incomplete'); + return release; + } finally { fs.rmSync(check, { recursive: true, force: true }); } +} + +function main(root, args) { + const [command, candidate, output] = args; + const selected = context(root); + if (command === 'guard') { + if (execute('git', ['status', '--porcelain', '--untracked-files=no'], root)) throw new Error('release_source_modified'); + assertUnusedVersion(selected, root); + return assertVerifiedMain(selected, root); + } + if (command === 'package') { + if (selected.product === 'core' && !process.env.RELEASE_PLATFORM_BUNDLE) throw new Error('release_platform_bundle_required'); + return packageRelease({ ...selected, candidate, output, platformBundle: process.env.RELEASE_PLATFORM_BUNDLE, + notes: fs.readFileSync(process.env.RELEASE_NOTES_FILE, 'utf8') }); + } + if (command === 'publish') return publish(path.resolve(candidate), selected, root); + throw new Error('usage: release.js guard | package CANDIDATE OUTPUT | publish OUTPUT'); +} + +module.exports = { identity, validVersion, packageRelease, context, assertVerifiedMain, assertUnusedVersion, verifyPublication, publish, main }; diff --git a/tooling/release.js b/tooling/release.js new file mode 100644 index 0000000..bf3c626 --- /dev/null +++ b/tooling/release.js @@ -0,0 +1,4 @@ +'use strict'; +try { + console.log(JSON.stringify(require('../sdk/tooling/release-publication').main(require('node:path').resolve(__dirname, '..'), process.argv.slice(2)))); +} catch (error) { console.error(error.message); process.exitCode = 1; } From cb9b59c1b4e3e9a7c401d0a29cfbeeea7d49b85c Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:40:14 +0000 Subject: [PATCH 2/4] Verify release component versions and document dependency bootstrap --- .github/workflows/release.yml | 6 ++-- RELEASES.md | 39 ++++++++++++++++++++--- sdk/tests/release-publication.test.js | 16 ++++++++-- sdk/tooling/release-publication.js | 45 +++++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 504660b..22bb5e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,8 +32,6 @@ jobs: env: RELEASE_VERSION: ${{ inputs.version }} RELEASE_COMMIT: ${{ inputs.expected_main }} - RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md - RELEASE_PLATFORM_BUNDLE: ${{ runner.temp }}/platform-packages steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -45,6 +43,10 @@ jobs: cache: npm cache-dependency-path: dashboard/package-lock.json - run: npm run bootstrap -- "$RUNNER_TEMP/platform-packages" + - name: Set release package paths + run: | + echo "RELEASE_NOTES_FILE=$RUNNER_TEMP/release-notes.md" >> "$GITHUB_ENV" + echo "RELEASE_PLATFORM_BUNDLE=$RUNNER_TEMP/platform-packages" >> "$GITHUB_ENV" - name: Verify selected main and unused version env: GH_TOKEN: ${{ github.token }} diff --git a/RELEASES.md b/RELEASES.md index e1d2351..d9a274f 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -33,11 +33,40 @@ must drain processes, snapshot private state, start selected code, verify health and restore on failure. Recover interrupted operations explicitly before continuing. The persistent state directory is private and is never included in source exports. -The GitHub feed, signed release provenance, repository configuration, permanent Dev -DSP deployment, separate Core preview, privileged activation hooks and owner Updates -UI are subsequent work. The development builder intentionally cannot publish a -production release. Release versions and the deployment baseline are not assigned -by local repository preparation. +The GitHub feed, permanent Dev DSP deployment, separate Core preview, privileged +activation hooks and owner Updates UI are subsequent work. The development builder +still produces development candidates. Publication uses the separate guarded +workflow below; no release version or production baseline is assigned by setup. Legacy `core/installations/RELEASES.md` documents old native/OCI recovery formats. It does not authorize or describe the new release workflow. + +## GitHub publication + +`.github/workflows/release.yml` accepts only a manual dispatch from `main`, the +owner-selected `version`, the verified 40-character `expected_main` commit, and +reviewed `changelog` text. Dispatch it only after the owner requests a release and +supplies its version. Pass inputs as structured JSON; do not interpolate notes +into shell commands. The release worker checks that main still matches and has a +successful main-push checks run. It checks again before publishing. + +The SDK's `tooling/release-publication.js` packages a fresh verified development +build, records the source repository/commit and selected version, includes the +notes in its hashed inventory, and produces a deterministic tar archive plus +`release.json`, `release-notes.md` and `SHA256SUMS`. Core also publishes +`platform-packages.tar.gz`, bound by digest in the release manifest. Each asset +receives GitHub build provenance. Publishing verifies the signer workflow, main +source ref, source commit and hosted runner before creating the version tag and +a draft release. It downloads and compares the uploaded assets before publishing. + +Existing tags or releases, including drafts, are never overwritten. A failed +publication can leave a reserved tag or draft; inspect it and its verified assets +before recovery. Do not repeatedly dispatch publication or delete history to make +CI pass. Reusing a published SDK/support-package/plugin version with different +installed bytes is rejected; bump that component in a reviewed PR first. + +The release job has GitHub publication permissions only. There are no production +SSH credentials, service restarts, deployment hooks or DSP activation steps. +GitHub publication and installed-version verification are separate operations. +The first real publication is still pending an owner-selected version; do not +claim that upload/attestation publication has been exercised by the unit tests. diff --git a/sdk/tests/release-publication.test.js b/sdk/tests/release-publication.test.js index 3d41ae8..4cb0404 100644 --- a/sdk/tests/release-publication.test.js +++ b/sdk/tests/release-publication.test.js @@ -6,11 +6,11 @@ const path = require('node:path'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); const { inventory, hash, verifyRelease } = require('dispatch-protocol/releases/package'); -const { identity, packageRelease, context, assertVerifiedMain, assertUnusedVersion, verifyPublication } = require('../tooling/release-publication'); +const { identity, packageRelease, context, assertVerifiedMain, assertUnusedVersion, assertComponentVersions, verifyPublication } = require('../tooling/release-publication'); const selected = { product: 'core', repository: 'example/dispatch-core', version: '1.2.3', commit: 'a'.repeat(40) }; test('release package preserves candidate, binds source and verifies every packaged file', t => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-publication-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-release-fixture-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const candidate = path.join(root, 'candidate'); fs.mkdirSync(path.join(candidate, 'code'), { recursive: true }); @@ -68,3 +68,15 @@ test('existing tags and draft releases cannot be overwritten, and API errors fai assert.throws(() => assertUnusedVersion(selected, '.', () => { throw new Error('network unavailable'); }), /network unavailable/); assert.doesNotThrow(() => assertUnusedVersion(selected, '.', () => '[[]]')); }); + +test('changed SDK or plugin bytes require new component versions across release history', () => { + const prior = { packages: { 'dispatch-sdk': '1.0.0' }, plugins: [{ pluginId: 'sample', version: '1.0.0', digest: 'a'.repeat(64) }], + files: [{ path: 'code/node_modules/dispatch-sdk/src/index.js', sha256: 'b'.repeat(64), executable: false }] }; + assert.doesNotThrow(() => assertComponentVersions(prior, [prior])); + const next = structuredClone(prior);next.plugins[0].digest = 'c'.repeat(64); + assert.throws(() => assertComponentVersions(next, [prior]), /plugin:sample@1.0.0/); + next.plugins[0].version = '1.0.1';next.files[0].sha256 = 'd'.repeat(64); + assert.throws(() => assertComponentVersions(next, [prior]), /package:dispatch-sdk@1.0.0/); + next.packages['dispatch-sdk'] = '1.0.1'; + assert.doesNotThrow(() => assertComponentVersions(next, [prior])); +}); diff --git a/sdk/tooling/release-publication.js b/sdk/tooling/release-publication.js index 3bfc052..a6e2b1a 100644 --- a/sdk/tooling/release-publication.js +++ b/sdk/tooling/release-publication.js @@ -93,6 +93,46 @@ function assertUnusedVersion(selected, root, run = execute) { const tags = JSON.parse(run('gh', ['api', '--paginate', '--slurp', `repos/${repository}/git/matching-refs/tags/v${version}`], root)).flat(); const releases = JSON.parse(run('gh', ['api', '--paginate', '--slurp', `repos/${repository}/releases?per_page=100`], root)).flat(); if (tags.some(item => item.ref === `refs/tags/v${version}`) || releases.some(item => item.tag_name === `v${version}`)) throw new Error('release_version_exists'); + return releases.filter(item => !item.draft && !item.prerelease); +} + +function componentVersions(manifest) { + const components = new Map(); + for (const [name, version] of Object.entries(manifest.packages)) { + const prefix = `code/node_modules/${name}/`; + const files = manifest.files.filter(item => item.path.startsWith(prefix)); + if (!files.length) throw new Error('release_component_files_missing'); + components.set(`package:${name}@${version}`, sha256(JSON.stringify(files))); + } + for (const plugin of manifest.plugins) components.set(`plugin:${plugin.pluginId}@${plugin.version}`, plugin.digest); + return components; +} + +function assertComponentVersions(manifest, previous) { + const next = componentVersions(manifest); + for (const prior of previous) for (const [key, digest] of componentVersions(prior)) { + if (next.has(key) && next.get(key) !== digest) throw new Error(`release_component_version_reused: ${key}; changed packages need a version bump in a reviewed PR`); + } +} + +function verifyPriorComponents(directory, selected, releases, root, run) { + const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'release.json'))); + for (const release of releases) { + if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(release.tag_name)) throw new Error('release_history_invalid'); + const temporary = fs.mkdtempSync(path.join(path.dirname(directory), '.release-history-')); + try { + run('gh', ['release', 'download', release.tag_name, '--repo', selected.repository, '--pattern', 'release.json', '--dir', temporary], root); + const file = path.join(temporary, 'release.json'); + if (fs.statSync(file).size > 16 * 1024 * 1024) throw new Error('release_history_invalid'); + const prior = JSON.parse(fs.readFileSync(file)); + identity({ product: prior.product, repository: prior.source?.repository, commit: prior.source?.commit, version: prior.version }); + if (prior.product !== selected.product || prior.source.repository !== selected.repository || `v${prior.version}` !== release.tag_name) throw new Error('release_history_invalid'); + run('gh', ['attestation', 'verify', file, '--repo', selected.repository, + '--signer-workflow', `${selected.repository}/.github/workflows/release.yml`, '--source-ref', 'refs/heads/main', + '--source-digest', prior.source.commit, '--deny-self-hosted-runners'], root); + assertComponentVersions(manifest, [prior]); + } finally { fs.rmSync(temporary, { recursive: true, force: true }); } + } } function verifyPublication(directory, selected) { @@ -116,7 +156,8 @@ function verifyPublication(directory, selected) { function publish(directory, selected, root, run = execute) { const names = verifyPublication(directory, selected); assertVerifiedMain(selected, root, run); - assertUnusedVersion(selected, root, run); + const prior = assertUnusedVersion(selected, root, run); + verifyPriorComponents(directory, selected, prior, root, run); for (const name of names) run('gh', ['attestation', 'verify', path.join(directory, name), '--repo', selected.repository, '--signer-workflow', `${selected.repository}/.github/workflows/release.yml`, '--source-ref', 'refs/heads/main', '--source-digest', selected.commit, '--deny-self-hosted-runners'], root); @@ -156,4 +197,4 @@ function main(root, args) { throw new Error('usage: release.js guard | package CANDIDATE OUTPUT | publish OUTPUT'); } -module.exports = { identity, validVersion, packageRelease, context, assertVerifiedMain, assertUnusedVersion, verifyPublication, publish, main }; +module.exports = { identity, validVersion, packageRelease, context, assertVerifiedMain, assertUnusedVersion, assertComponentVersions, verifyPublication, publish, main }; From 071a028d423c8f766b66a8a29a278785982634a3 Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:47:24 +0000 Subject: [PATCH 3/4] Validate workflow syntax and exercise publication failure recovery --- .github/workflows/checks.yml | 6 ++++ sdk/tests/release-publication.test.js | 49 ++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 9fe5b57..18810a3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -22,6 +22,12 @@ jobs: node-version: 22 cache: npm cache-dependency-path: dashboard/package-lock.json + - name: Validate GitHub workflows + run: | + curl --fail --silent --show-error --location https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz --output "$RUNNER_TEMP/actionlint.tar.gz" + printf '%s %s\n' '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8' "$RUNNER_TEMP/actionlint.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" - run: npm run bootstrap -- "$RUNNER_TEMP/platform-packages" - run: npm run check - run: npm test diff --git a/sdk/tests/release-publication.test.js b/sdk/tests/release-publication.test.js index 4cb0404..8a594a5 100644 --- a/sdk/tests/release-publication.test.js +++ b/sdk/tests/release-publication.test.js @@ -6,7 +6,7 @@ const path = require('node:path'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); const { inventory, hash, verifyRelease } = require('dispatch-protocol/releases/package'); -const { identity, packageRelease, context, assertVerifiedMain, assertUnusedVersion, assertComponentVersions, verifyPublication } = require('../tooling/release-publication'); +const { identity, packageRelease, context, assertVerifiedMain, assertUnusedVersion, assertComponentVersions, verifyPublication, publish } = require('../tooling/release-publication'); const selected = { product: 'core', repository: 'example/dispatch-core', version: '1.2.3', commit: 'a'.repeat(40) }; test('release package preserves candidate, binds source and verifies every packaged file', t => { @@ -80,3 +80,50 @@ test('changed SDK or plugin bytes require new component versions across release next.packages['dispatch-sdk'] = '1.0.1'; assert.doesNotThrow(() => assertComponentVersions(next, [prior])); }); + +test('publisher attests before writing and leaves failed uploads as drafts', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-publisher-fixture-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const directory = path.join(root, 'publication');fs.mkdirSync(directory); + const manifest = { product: selected.product, version: selected.version, channel: 'release', source: identity(selected) }; + fs.writeFileSync(path.join(directory, 'release.json'), JSON.stringify(manifest)); + const archive = 'dispatch-core-1.2.3.tar.gz'; + fs.writeFileSync(path.join(directory, archive), 'fixture archive'); + fs.writeFileSync(path.join(directory, 'release-notes.md'), 'x'.repeat(30)); + const names = [archive, 'release.json', 'release-notes.md']; + fs.writeFileSync(path.join(directory, 'SHA256SUMS'), names.map(name => `${hash(fs.readFileSync(path.join(directory, name)))} ${name}\n`).join('')); + names.push('SHA256SUMS'); + const writes = []; + let failAttestation = true, corruptUpload = false; + const run = (command, args) => { + assert.equal(command, 'gh'); + if (args[0] === 'attestation') { + assert(args.includes('--deny-self-hosted-runners'));assert(args.includes(selected.commit)); + if (failAttestation) throw new Error('attestation_failed'); + return ''; + } + if (args[0] === 'api') { + if (args.includes('POST')) { writes.push('tag');return '{}'; } + if (args.at(-1).endsWith('/commits/main')) return JSON.stringify({ sha: selected.commit }); + if (args.at(-1).includes('/actions/')) return JSON.stringify({ workflow_runs: [{ head_sha: selected.commit, head_branch: 'main', event: 'push', conclusion: 'success' }] }); + return '[[]]'; + } + if (args[1] === 'create') { assert(args.includes('--draft'));writes.push('draft'); } + else if (args[1] === 'download') { + const target = args[args.indexOf('--dir') + 1]; + for (const name of names) fs.copyFileSync(path.join(directory, name), path.join(target, name)); + if (corruptUpload) fs.appendFileSync(path.join(target, archive), 'tampered'); + } else if (args[1] === 'edit') writes.push('publish'); + else if (args[1] === 'view') return JSON.stringify({ url: 'https://example.com/release', isDraft: false, tagName: 'v1.2.3' }); + else assert.fail(`unexpected command: ${args.join(' ')}`); + return ''; + }; + assert.throws(() => publish(directory, selected, root, run), /attestation_failed/); + assert.deepEqual(writes, []); + failAttestation = false;corruptUpload = true; + assert.throws(() => publish(directory, selected, root, run), /upload_mismatch/); + assert.deepEqual(writes, ['tag', 'draft']); + writes.length = 0;corruptUpload = false; + assert.equal(publish(directory, selected, root, run).isDraft, false); + assert.deepEqual(writes, ['tag', 'draft', 'publish']); +}); From 90e29d2940eac250b15042e336661562cff72fe1 Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:57:37 +0000 Subject: [PATCH 4/4] Allow repositories to bound test runner concurrency --- sdk/tooling/project.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/tooling/project.js b/sdk/tooling/project.js index 6b43655..eb76ab3 100644 --- a/sdk/tooling/project.js +++ b/sdk/tooling/project.js @@ -6,10 +6,12 @@ async function main(root,args) { if(command==='check')return require('./check-source').check(root); if(command==='test'||command==='test:integration'){ const config=JSON.parse(fs.readFileSync(path.join(root,'tooling/tests.json'))); + const concurrency=config.concurrency??4; + if(!Number.isInteger(concurrency)||concurrency<1||concurrency>8)throw new Error('test_concurrency_invalid'); const groups=command==='test'?config.unit:config.integration; if(!groups.length)throw new Error('test_selection_empty'); const selected=groups.flatMap(group=>group.endsWith('.test.js')?[path.join(root,group)]:require('./verify-boundaries').files(path.join(root,group)).filter(file=>file.endsWith('.test.js'))); - run(process.execPath,['--no-warnings',...(core?['--require',path.join(root,'tests/support/catalog.cjs')]:[]),'--test','--test-concurrency=4',...selected],root); + run(process.execPath,['--no-warnings',...(core?['--require',path.join(root,'tests/support/catalog.cjs')]:[]),'--test',`--test-concurrency=${concurrency}`,...selected],root); return {ok:true,testFiles:selected.length}; } if(command==='build'){