@crossbind/extension-github 0.0.0-stage → 2.0.0-beta.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğra Sarı and crossbind contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @crossbind/extension-github
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Puts a desktop app's installers and update on a GitHub release.
4
+
5
+ An app has it by having `@crossbind/core-app-host`, which lists it as a dependency: there is nothing to install or list.
6
+
7
+ ## Use
8
+
9
+ ```sh
10
+ npx crossbind app publish github -p macos
11
+ ```
12
+
13
+ It builds the desktop app and puts its installers, and with `app.updater` its update and the update's manifest, on the
14
+ GitHub release of the version `package.json` gives. `--draft` leaves the release a draft.
15
+
16
+ ## Platforms
17
+
18
+ macOS, Linux and Windows apps.
19
+
20
+ ## Replace or leave out
21
+
22
+ An extension named `github` with a publisher takes its place, and `app: { without: ['github'] }` in `crossbind.config.js` leaves it out. [extensions.md](https://github.com/crossbind/crossbind/blob/main/docs/api/extensions.md#publishers) says what one gives.
23
+
24
+ [app.md](https://github.com/crossbind/crossbind/blob/main/docs/api/app.md#github-releases) has the token it takes and the address `app.updater.url` points at.
@@ -0,0 +1,135 @@
1
+ import path from 'node:path';
2
+ import { spawnSync } from 'node:child_process';
3
+ import {
4
+ githubCaller, githubRepository, githubToken, newRelease, publishRelease, releaseOf, uploadAsset,
5
+ } from './src/githubReleases.js';
6
+
7
+ // The private key a build signs a desktop app's updates with, which it reads from the environment.
8
+ const UPDATER_KEY = 'CROSSBIND_UPDATER_PRIVATE_KEY';
9
+ // The installer whose files are an app's update.
10
+ const UPDATE_INSTALLER = 'updater';
11
+ const NO_REPOSITORY = 'crossbind: GitHub Releases takes the app to a repository of GitHub\'s, which neither CROSSBIND_GITHUB_REPOSITORY, GITHUB_REPOSITORY nor package.json\'s repository names: set repository to owner/name.';
12
+ const NO_TOKEN = 'crossbind: GitHub takes a release from a token with write access to the repository\'s contents: set GH_TOKEN or GITHUB_TOKEN, as a workflow does with its own, or sign in with `gh auth login`.';
13
+ const NO_UPDATE_KEY = `crossbind: app.updater has the app update from where it says, so its release takes the update ${UPDATER_KEY} signs: set it, as app.md's Updates says.`;
14
+
15
+ const isSame = (one, other) => typeof one === 'string' && one.toLowerCase() === other.toLowerCase();
16
+ const git = (api, args) => {
17
+ const result = spawnSync('git', args, { cwd: api.project.root, encoding: 'utf8' });
18
+ return result.status === 0 ? result.stdout.trim() : null;
19
+ };
20
+ const isPrerelease = (version) => version.split('+')[0].includes('-');
21
+
22
+ // The commit a new release's tag is made at where the repository has no such tag yet: the one a workflow of that
23
+ // repository runs for, else the project's own where its origin is that repository; else none, and GitHub makes it at its
24
+ // default branch.
25
+ function releaseCommit(api, repository) {
26
+ if (process.env.GITHUB_SHA) return isSame(process.env.GITHUB_REPOSITORY, repository) ? process.env.GITHUB_SHA : null;
27
+ const origin = git(api, ['remote', 'get-url', 'origin']);
28
+ return origin && isSame(githubRepository({ repository: origin }, {}), repository) ? git(api, ['rev-parse', 'HEAD']) : null;
29
+ }
30
+
31
+ // A macOS app's file takes the platform and arch into its name after the app's, as one release holds both arches', unless
32
+ // it has them already, as an update's, whose manifest names it.
33
+ function macosName(api, app, arch, file) {
34
+ const stem = path.basename(app, '.app');
35
+ const name = path.basename(file);
36
+ const own = `${api.project.name}-darwin-${arch}`;
37
+ if (name.startsWith(`${own}.`) || name.startsWith(`${own}-`)) return name;
38
+ const rest = name.startsWith(`${stem}.`) || name.startsWith(`${stem}-`) ? name.slice(stem.length) : `-${name}`;
39
+ return `${api.project.name}-darwin-${arch}${rest}`;
40
+ }
41
+
42
+ // The files of an app's release, under the names the release gives them: what each of its installers made, among them
43
+ // with app.updater the update's. A Linux or Windows app's update is one of its installers' files, which goes up once.
44
+ function releaseFiles(api, { app, arch, installers }, hasUpdate) {
45
+ if (!installers.some(({ name }) => name !== UPDATE_INSTALLER)) throw new Error(`crossbind: the build made no installer of ${app}, so nothing went to GitHub Releases.`);
46
+ if (hasUpdate && !installers.some(({ name }) => name === UPDATE_INSTALLER)) {
47
+ throw new Error(`crossbind: the build made no update of ${app}, which app.updater takes, so nothing went to GitHub Releases.`);
48
+ }
49
+ const files = [...new Set(installers.flatMap(({ files: made }) => made.map((file) => path.resolve(file))))];
50
+ return files.map((file) => ({ file, name: api.platform === 'macos' ? macosName(api, app, arch, file) : path.basename(file) }));
51
+ }
52
+
53
+ // What an app updates from the release misses: its manifest at the address of the newest release's, a version that
54
+ // release can be, and an address it can fetch with no token.
55
+ async function warnUpdates(api, call, repository, updater) {
56
+ const latest = `https://github.com/${repository}/releases/latest/download/${api.project.name}-{platform}-{arch}.update.json`;
57
+ if (!isSame(updater.url, latest)) {
58
+ api.warn(`crossbind: app.updater.url is ${updater.url}, not ${latest}, so the app does not update from the newest of ${repository}'s releases.`);
59
+ return;
60
+ }
61
+ if (isPrerelease(api.app.version)) api.warn(`crossbind: ${api.app.version} is a prerelease, which GitHub never makes the latest release, so no app updates to it.`);
62
+ if ((await call('GET', '')).private) api.warn(`crossbind: ${repository} is private, whose releases GitHub gives no app without a token, so none updates from them.`);
63
+ }
64
+
65
+ // The publisher of a desktop app's installers, and its update with app.updater, to a GitHub release of its version, as
66
+ // its tag v<version> names it: a release made or taken as it is, where an asset of the same name gives way. It puts out
67
+ // the release's page and each file's address, for a publisher after it.
68
+ export default function github() {
69
+ return {
70
+ name: 'github',
71
+ contract: 1,
72
+ root: import.meta.dirname,
73
+ publisher: {
74
+ platform: 'desktop',
75
+ store: 'GitHub Releases',
76
+ options: {
77
+ draft: { help: "leave the release a draft, which a publish without --draft, or GitHub's page, puts out" },
78
+ },
79
+
80
+ async check(api) {
81
+ const missing = [];
82
+ const repository = githubRepository(api.project.package);
83
+ if (!repository) missing.push(NO_REPOSITORY);
84
+ const token = githubToken();
85
+ if (!token) missing.push(NO_TOKEN);
86
+ const { updater } = api.config.app;
87
+ if (updater && !process.env[UPDATER_KEY]) missing.push(NO_UPDATE_KEY);
88
+ if (missing.length > 0) return { missing };
89
+ const { version } = api.app;
90
+ const call = githubCaller(repository, token);
91
+ if (updater) await warnUpdates(api, call, repository, updater);
92
+ const release = await releaseOf(call, `v${version}`);
93
+ const kind = release ? `${release.draft ? 'draft ' : ''}release` : 'new release';
94
+ api.log(`crossbind: publishing the ${api.platform} app of ${version} to ${repository}'s ${kind} v${version}; building the app first.`);
95
+ return { session: { call, repository, hasUpdate: Boolean(updater) } };
96
+ },
97
+
98
+ // The token writes to the repository, which the build has no need of.
99
+ buildEnv: () => ({ GH_TOKEN: undefined, GITHUB_TOKEN: undefined }),
100
+
101
+ async upload(api, { call, repository, hasUpdate }, { apps }) {
102
+ const { platform, app: { version }, options: { draft: isDraft } } = api;
103
+ const files = apps.flatMap((app) => releaseFiles(api, app, hasUpdate));
104
+ const twice = files.find(({ name }, index) => files.findIndex((other) => other.name === name) !== index);
105
+ if (twice) {
106
+ const both = files.filter(({ name }) => name === twice.name).map(({ file }) => file);
107
+ throw new Error(`crossbind: ${both.join(' and ')} would both be ${twice.name}, of which a GitHub release holds one, so nothing went to GitHub Releases.`);
108
+ }
109
+ const tag = `v${version}`;
110
+ // Another publish of the version may have made its release during this one's build.
111
+ let release = await releaseOf(call, tag);
112
+ if (!release) {
113
+ const commit = releaseCommit(api, repository);
114
+ if (!commit) api.log(`crossbind: ${repository} gets the tag ${tag} where it has none at its default branch, as it has no commit of this build's.`);
115
+ release = await newRelease(call, { tag, commit, isPrerelease: isPrerelease(version) });
116
+ }
117
+ const uploaded = [];
118
+ for (const { file, name } of files) {
119
+ api.log(`crossbind: uploading ${name} to ${tag}.`);
120
+ uploaded.push(await uploadAsset(call, release, file, name));
121
+ }
122
+ const published = (out) => ({
123
+ published: { repository, tag, url: out.html_url, files: uploaded.map(({ name, browser_download_url: url }) => ({ name, url })) },
124
+ });
125
+ if (release.draft && isDraft) {
126
+ api.log(`crossbind: the ${platform} app of ${version} is on the draft release ${tag}, which a publish without --draft, or GitHub's page, puts out: ${release.html_url}`);
127
+ return published(release);
128
+ }
129
+ const out = release.draft ? await publishRelease(call, release) : release;
130
+ api.log(`crossbind: the ${platform} app of ${version} is out on GitHub's release ${tag}${isDraft ? ', which was out already' : ''}: ${out.html_url}`);
131
+ return published(out);
132
+ },
133
+ },
134
+ };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@crossbind/extension-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "2.0.0-beta.69",
4
+ "description": "crossbind app extension: puts a desktop app's installers and update on a GitHub release.",
5
+ "homepage": "https://github.com/crossbind/crossbind/tree/main/extensions/github#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/crossbind/crossbind.git"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "exports": {
13
+ "./crossbind.extension.js": "./crossbind.extension.js"
14
+ },
15
+ "files": [
16
+ "crossbind.extension.js",
17
+ "src"
18
+ ],
19
+ "crossbind": {
20
+ "extension": "./crossbind.extension.js"
21
+ },
22
+ "keywords": [
23
+ "crossbind-extension",
24
+ "crossbind",
25
+ "publishing",
26
+ "github",
27
+ "publisher",
28
+ "app"
29
+ ]
6
30
  }
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ // A repository as GitHub names it, owner/name.
5
+ const REPOSITORY = /^[\w.-]+\/[\w.-]+$/;
6
+ // package.json's repository where it is GitHub's: owner/name or github:owner/name, as npm takes them, or its address,
7
+ // git@github.com:owner/name.git as well.
8
+ const GITHUB_ADDRESS = /^(?:(?:git\+)?(?:https?|ssh|git):\/\/(?:git@)?github\.com[/:]|git@github\.com:)([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/;
9
+
10
+ // The token GitHub takes a release from: GH_TOKEN or GITHUB_TOKEN, as GitHub's CLI and Actions name it, else the GitHub
11
+ // CLI's own sign-in where it has one; null without any.
12
+ export function githubToken(env = process.env) {
13
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN;
14
+ if (token) return token;
15
+ const signedIn = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
16
+ return signedIn.status === 0 && signedIn.stdout.trim() ? signedIn.stdout.trim() : null;
17
+ }
18
+
19
+ // The repository a release goes to: CROSSBIND_GITHUB_REPOSITORY, else GITHUB_REPOSITORY, as GitHub Actions sets it,
20
+ // else package.json's repository where it is GitHub's; null without one.
21
+ export function githubRepository(packageJson, env = process.env) {
22
+ const variable = ['CROSSBIND_GITHUB_REPOSITORY', 'GITHUB_REPOSITORY'].find((name) => env[name]);
23
+ if (variable) {
24
+ if (!REPOSITORY.test(env[variable])) throw new Error(`crossbind: ${variable} ${JSON.stringify(env[variable])} names no repository, as owner/name does.`);
25
+ return env[variable];
26
+ }
27
+ const given = typeof packageJson?.repository === 'string' ? packageJson.repository : packageJson?.repository?.url;
28
+ if (typeof given !== 'string') return null;
29
+ const address = GITHUB_ADDRESS.exec(given)?.[1];
30
+ if (address) return address;
31
+ const short = given.replace(/^github:/, '');
32
+ return REPOSITORY.test(short) ? short : null;
33
+ }
34
+
35
+ const apiAddress = () => process.env.GITHUB_API_URL || 'https://api.github.com';
36
+
37
+ // What GitHub said of a call it refused, with the field of each error where it names no message, as a 422's do.
38
+ const saidIn = (body) => [body?.message, ...(body?.errors ?? []).map((error) => error.message ?? [error.resource, error.field, error.code].filter(Boolean).join(' '))]
39
+ .filter(Boolean).join(': ');
40
+
41
+ function githubError(status, body, method, route, repository) {
42
+ const said = saidIn(body);
43
+ let text = `crossbind: GitHub answered ${method} ${route} with ${status}${said ? `: ${said}` : '.'}`;
44
+ if (status === 401) {
45
+ text = "crossbind: GitHub refused the token: it has expired, or is none of GitHub's; GH_TOKEN or GITHUB_TOKEN names it, else `gh auth login` signs in.";
46
+ } else if (status === 404 && route === `/repos/${repository}`) {
47
+ text = `crossbind: GitHub has no repository ${repository} the token sees: check its name, and that the token reaches it.`;
48
+ } else if ((status === 403 && !/rate limit/i.test(said)) || (status === 404 && route.endsWith('/releases'))) {
49
+ text += `\ncrossbind: the token takes write access to the contents of ${repository}: a workflow gives its GITHUB_TOKEN \`permissions: contents: write\`, a fine-grained token "Contents" read and write, a classic one the repo scope.`;
50
+ }
51
+ return Object.assign(new Error(text), { status, body });
52
+ }
53
+
54
+ // A caller of GitHub's API for one repository, at a route of the repository's or at an address GitHub gave, as an
55
+ // upload's. A file's bytes go up as they are.
56
+ export function githubCaller(repository, token) {
57
+ return async (method, route, body) => {
58
+ const isAddress = route.startsWith('https://');
59
+ const address = isAddress ? route : `${apiAddress()}/repos/${repository}${route}`;
60
+ const isBytes = Buffer.isBuffer(body);
61
+ const response = await fetch(address, {
62
+ method,
63
+ headers: {
64
+ authorization: `Bearer ${token}`,
65
+ accept: 'application/vnd.github+json',
66
+ 'x-github-api-version': '2022-11-28',
67
+ 'user-agent': 'crossbind',
68
+ ...(body === undefined ? {} : { 'content-type': isBytes ? 'application/octet-stream' : 'application/json' }),
69
+ },
70
+ body: isBytes || body === undefined ? body : JSON.stringify(body),
71
+ });
72
+ const shown = isAddress ? new URL(route).pathname : `/repos/${repository}${route.split('?')[0]}`;
73
+ const text = await response.text();
74
+ let json = null;
75
+ try {
76
+ json = text ? JSON.parse(text) : {};
77
+ } catch {
78
+ if (response.ok) throw new Error(`crossbind: GitHub answered ${method} ${shown} with no JSON, as a proxy between might: ${text.slice(0, 200)}`);
79
+ }
80
+ if (response.ok) return json;
81
+ throw githubError(response.status, json, method, shown, repository);
82
+ };
83
+ }
84
+
85
+ // The oldest draft of a tag on the first page of the repository's releases, which lists the newest first; drafts have no
86
+ // tag yet, which GitHub's lookup by tag goes by.
87
+ async function draftOf(call, tag) {
88
+ const drafts = (await call('GET', '/releases?per_page=100')).filter((release) => release.draft && release.tag_name === tag);
89
+ return drafts.reduce((oldest, release) => (oldest && oldest.id < release.id ? oldest : release), null);
90
+ }
91
+
92
+ // The release of a tag: the one GitHub has out, else its oldest draft; null without one.
93
+ export async function releaseOf(call, tag) {
94
+ const published = await call('GET', `/releases/tags/${encodeURIComponent(tag)}`).catch((error) => {
95
+ if (error.status === 404) return null;
96
+ throw error;
97
+ });
98
+ return published ?? draftOf(call, tag);
99
+ }
100
+
101
+ // A new draft release of a tag, at the commit given or else the default branch's, with the notes GitHub writes of the
102
+ // changes since the last. Publishes of the same version at once, as a matrix's jobs, each make one: all then take the
103
+ // oldest, and the others go. A tag GitHub has a release of out takes that one.
104
+ export async function newRelease(call, { tag, commit, isPrerelease }) {
105
+ let made;
106
+ try {
107
+ made = await call('POST', '/releases', {
108
+ tag_name: tag, ...(commit ? { target_commitish: commit } : {}), name: tag, draft: true, prerelease: isPrerelease, generate_release_notes: true,
109
+ });
110
+ } catch (error) {
111
+ const out = error.status === 422 && (error.body?.errors ?? []).some(({ code }) => code === 'already_exists') ? await releaseOf(call, tag) : null;
112
+ if (!out) throw error;
113
+ return out;
114
+ }
115
+ const oldest = await draftOf(call, tag) ?? made;
116
+ if (oldest.id !== made.id) await call('DELETE', `/releases/${made.id}`);
117
+ return oldest;
118
+ }
119
+
120
+ // Uploads a file to a release under a name, in place of an asset of that name the release has, as an earlier publish of
121
+ // the same version left, and returns the asset. GitHub renames a name with characters it takes none of, which the
122
+ // update's manifest would no longer find, so that stops the publish.
123
+ export async function uploadAsset(call, release, file, name) {
124
+ const bytes = fs.readFileSync(file);
125
+ const old = (release.assets ?? []).find((asset) => asset.name === name);
126
+ if (old) await call('DELETE', `/releases/assets/${old.id}`);
127
+ const uploaded = await call('POST', `${release.upload_url.replace(/\{.*\}$/, '')}?name=${encodeURIComponent(name)}`, bytes);
128
+ if (uploaded.name !== name) {
129
+ throw new Error(`crossbind: GitHub named ${name} ${uploaded.name}, as it takes no such name: the file's name takes letters, digits, . _ and - alone, as general.name in crossbind.config.js does for crossbind's own installers.`);
130
+ }
131
+ return uploaded;
132
+ }
133
+
134
+ // Puts a draft out, latest unless GitHub has a newer version out, so a fix of an older line leaves the newest latest.
135
+ export const publishRelease = (call, release) => call('PATCH', `/releases/${release.id}`, { draft: false, make_latest: 'legacy' });