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
6 changes: 3 additions & 3 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,16 @@ PR review comments come from [CodeRabbit](https://docs.coderabbit.ai/) via [`.co

Triggered by pushing a version tag (e.g., `v1.2.3`):

1. **`schema-release-compare`** — first job; compares this SHA’s `CURRENT_SCHEMA_VERSION` to the last **published** GitHub Release, writes the Actions step summary, and uploads a schema readme artifact. Job outputs feed installer notices and the draft release body.
2. **`prepare-github-release`** — **sole** creator of the draft GitHub release for the tag (`MESH_CLIENT_ALLOW_DRAFT_CREATE=1`), exports `release_id`, then prepends the schema compare note (via `RELEASE_ID`, not List Releases). On `workflow_dispatch`, the tag is resolved in the workflow from `package.json` and passed as `RELEASE_TAG` (not read inside the release API script — avoids CodeQL `js/file-access-to-http`). The schema note is rebuilt from `schema-release-compare` job outputs (`MESH_CLIENT_SCHEMA_*`), not from a downloaded markdown artifact (same CodeQL rule).
1. **`schema-release-compare`** — first job; compares this SHA’s `CURRENT_SCHEMA_VERSION` to the last **published** GitHub Release (paginated Releases API; highest semver among non-draft/non-prerelease rows; recovers `vX.Y.Z` from release **name** only when `tag_name` is missing or `untagged-*`), writes the Actions step summary, and uploads a schema readme artifact. Job outputs feed installer notices and the draft release body.
2. **`prepare-github-release`** — **sole** creator of the draft GitHub release for the tag (`MESH_CLIENT_ALLOW_DRAFT_CREATE=1`), exports `release_id` (reconstructed from validated digits before `GITHUB_OUTPUT` — CodeQL `js/http-to-file-access`), then prepends the schema compare note (via `RELEASE_ID`, not List Releases). On `workflow_dispatch`, the tag is resolved in the workflow from `package.json` and passed as `RELEASE_TAG` (not read inside the release API script — avoids CodeQL `js/file-access-to-http`). The schema note is rebuilt from `schema-release-compare` job outputs (`MESH_CLIENT_SCHEMA_*`), not from a downloaded markdown artifact (same CodeQL rule).
3. Installs Linux build dependencies (`libudev-dev`, `rpm`, …) on `ubuntu-latest` runners
4. Rebuilds native dependencies (`pnpm run rebuild`)
5. **Stamp CI build info** — `scripts/ci-write-build-info-env.mjs` writes `MESH_CLIENT_BUILD_INFO` (`buildChannel=release` + tag + Actions `runUrl`) into `$GITHUB_ENV` before `dist:*` so support-bundle `manifest.json` and startup logs identify an official release build (see [Build channel stamp](#build-channel-stamp-test-vs-release)).
6. Builds for all three platforms in parallel (or a filtered subset on `workflow_dispatch`) with **`--publish never`**:
- `macos-latest` → `pnpm run dist:mac`
- `ubuntu-latest` → `pnpm run dist:linux`
- `windows-latest` → `pnpm run dist:win`
7. **`ci-upload-release-assets.mjs`** attaches installers / update metadata to the prepare `release_id` (never `POST /releases`). `finalize-github-release` still consolidates if anything external forked drafts.
7. **`ci-upload-release-assets.mjs`** attaches installers / update metadata to the prepare `release_id` (never `POST /releases`) via `gh api --input` path uploads (avoids CodeQL `js/file-access-to-http` from `readFile` → `fetch`). `finalize-github-release` still consolidates if anything external forked drafts.

Linux packaging smoke (`verify-linux-packaging.mjs`) asserts `.deb` **Description** metadata is ASCII-only. See [Release Process](release-process.md).

Expand Down
11 changes: 9 additions & 2 deletions scripts/ci-ensure-github-draft-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import {
ensureGithubDraftRelease,
resolveTag,
resolveTargetCommitish,
trustedGithubReleaseId,
} from './github-release-api.mjs';

/**
* Write prepare/wait `release_id` to GITHUB_OUTPUT.
* Id is reconstructed from validated digits so network JSON cannot taint the disk write
* (CodeQL `js/http-to-file-access`).
* @param {string | undefined} githubOutput
* @param {number | string} releaseId
*/
export function writeReleaseIdOutput(githubOutput, releaseId) {
if (typeof githubOutput !== 'string' || !githubOutput) {
return;
}
appendFileSync(githubOutput, `release_id=${releaseId}\n`, 'utf8');
const id = trustedGithubReleaseId(releaseId);
appendFileSync(githubOutput, `release_id=${id}\n`, 'utf8');
}

async function main() {
Expand All @@ -30,7 +35,9 @@ async function main() {
allowCreate,
});
writeReleaseIdOutput(process.env.GITHUB_OUTPUT, release.id);
console.debug(`[ci-ensure-github-draft-release] release_id=${release.id}`);
console.debug(
`[ci-ensure-github-draft-release] release_id=${trustedGithubReleaseId(release.id)}`,
);
}

const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
Expand Down
160 changes: 160 additions & 0 deletions scripts/ci-ensure-github-draft-release.test.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
assertSafeReleaseAssetName,
assertSafeReleaseTag,
consolidateReleases,
ensureGithubDraftRelease,
listReleasesForTag,
normalizeDraftReleasesForTag,
pickCanonicalRelease,
resolveTag,
trustedGithubReleaseId,
uploadOrReplaceReleaseAsset,
uploadReleaseAssetFromFile,
waitForGithubDraftRelease,
} from './github-release-api.mjs';
import { writeReleaseIdOutput } from './ci-ensure-github-draft-release.mjs';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

const TAG = 'v5.21.0';

Expand All @@ -30,6 +38,158 @@ describe('assertSafeReleaseTag', () => {
});
});

describe('trustedGithubReleaseId', () => {
it('rebuilds a positive integer from digits', () => {
expect(trustedGithubReleaseId(368221738)).toBe(368221738);
expect(trustedGithubReleaseId('99')).toBe(99);
});

it('rejects zero, negatives, and non-digits', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
trustedGithubReleaseId(0);
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockClear();
trustedGithubReleaseId('-1');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockClear();
trustedGithubReleaseId('12ab');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});

it('rejects ids above Number.MAX_SAFE_INTEGER', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
trustedGithubReleaseId('9007199254740993');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
});

describe('assertSafeReleaseAssetName', () => {
it('accepts basename-only names', () => {
expect(assertSafeReleaseAssetName('mesh-client.dmg')).toBe('mesh-client.dmg');
});

it('rejects path separators', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
assertSafeReleaseAssetName('../evil.bin');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
});

describe('writeReleaseIdOutput', () => {
it('writes a trusted release_id line', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'mesh-gh-out-'));
const out = path.join(dir, 'github_output');
writeFileSync(out, '');
writeReleaseIdOutput(out, '368221738');
expect(readFileSync(out, 'utf8')).toBe('release_id=368221738\n');
});
});

describe('uploadReleaseAssetFromFile', () => {
it('invokes gh api --input with the file path (no JS readFile→fetch)', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'mesh-gh-upload-'));
const filePath = path.join(dir, 'a.deb');
writeFileSync(filePath, 'bytes');
const execFile = vi.fn(() => JSON.stringify({ id: 1, name: 'a.deb' }));
const result = uploadReleaseAssetFromFile(9, 'a.deb', filePath, 'token', {
execFileSync: execFile,
});
expect(result).toEqual({ id: 1, name: 'a.deb' });
expect(execFile).toHaveBeenCalledTimes(1);
const [cmd, args] = execFile.mock.calls[0];
expect(cmd).toBe('gh');
expect(args).toContain('--input');
expect(args).toContain(filePath);
expect(args.some((a) => String(a).includes('/releases/9/assets'))).toBe(true);
});
});

describe('uploadOrReplaceReleaseAsset', () => {
it('validates releaseId and fileName before lookup or delete', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`exit:${code}`);
});
const getReleaseById = vi.fn();
const deleteAsset = vi.fn();
await expect(
uploadOrReplaceReleaseAsset({
releaseId: 'not-a-number',
token: 'token',
fileName: 'a.deb',
bytes: new Uint8Array([1]),
getReleaseById,
deleteAsset,
log: () => {},
}),
).rejects.toThrow(/exit:1/);
expect(getReleaseById).not.toHaveBeenCalled();
expect(deleteAsset).not.toHaveBeenCalled();
exitSpy.mockRestore();
});

it('restores the prior asset when replacement upload fails', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'mesh-replace-'));
const filePath = path.join(dir, 'a.deb');
writeFileSync(filePath, 'new');
const priorBytes = new Uint8Array([9, 9, 9]);
const restored = [];
const logs = [];
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`exit:${code}`);
});

await expect(
uploadOrReplaceReleaseAsset({
releaseId: 42,
token: 'token',
fileName: 'a.deb',
filePath,
existingAssets: [{ id: 7, name: 'a.deb' }],
downloadAsset: async () => priorBytes,
deleteAsset: async () => {},
uploadFromFile: () => {
throw new Error('gh upload exploded');
},
uploadBytes: async (_id, name, bytes) => {
restored.push({ name, bytes });
return { id: 99, name };
},
log: (message) => logs.push(message),
}),
).rejects.toThrow(/exit:1/);

expect(restored).toEqual([{ name: 'a.deb', bytes: priorBytes }]);
expect(logs.some((line) => line.includes('Restored prior asset a.deb'))).toBe(true);
exitSpy.mockRestore();
});

it('does not delete a prior asset when the upload path is missing', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`exit:${code}`);
});
const deleteAsset = vi.fn();
const downloadAsset = vi.fn();
await expect(
uploadOrReplaceReleaseAsset({
releaseId: 42,
token: 'token',
fileName: 'a.deb',
filePath: path.join(mkdtempSync(path.join(tmpdir(), 'mesh-miss-')), 'a.deb'),
existingAssets: [{ id: 7, name: 'a.deb' }],
deleteAsset,
downloadAsset,
log: () => {},
}),
).rejects.toThrow(/exit:1/);
expect(downloadAsset).not.toHaveBeenCalled();
expect(deleteAsset).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
});

describe('resolveTag', () => {
it('uses RELEASE_TAG when set', () => {
const tag = resolveTag([], { RELEASE_TAG: 'v5.21.0' });
Expand Down
Loading