Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,36 @@ on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: core-checks-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
core:
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
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
- 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: |
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 }}
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: 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 }}
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"
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
17 changes: 17 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
39 changes: 34 additions & 5 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
129 changes: 129 additions & 0 deletions sdk/tests/release-publication.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'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, 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 => {
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 });
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, '.', () => '[[]]'));
});

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]));
});

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']);
});
4 changes: 3 additions & 1 deletion sdk/tooling/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'){
Expand Down
Loading