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
160 changes: 160 additions & 0 deletions .github/workflows/plugin-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Reusable release workflow for Ervisio plugins. A plugin's release.yml calls
# it (see template/.github/workflows/release.yml); everything happens here, so
# fixing the release process means changing this one file.
#
# Run by hand (Actions > Release > Run workflow, choose patch/minor/major):
# 1. bumps the version in plugin/manifest.json and package.json(-lock),
# 2. adds a "## X.Y.Z" section to CHANGELOG.md (the notes you typed, or the
# commit subjects since the last release),
# 3. commits "Release X.Y.Z", tags vX.Y.Z and pushes both,
# 4. builds, packs, validates the manifest with Ervisio's own validator,
# 5. creates the GitHub release with the tarball and its sha256,
# 6. tells the registry (Ervisio/plugins) to pick it up now, when the
# REGISTRY_TOKEN secret exists (organization secret; without it the
# registry still finds the release within six hours).
# Run on a pushed vX.Y.Z tag: steps 4-6 only (the old way still works).
name: Plugin release

on:
workflow_call:
inputs:
bump:
description: 'patch, minor or major; empty when called for a pushed tag'
type: string
default: ''
notes:
description: 'Release notes; empty = the commit subjects since the last release'
type: string
default: ''
ervisio-ref:
description: 'Ervisio release whose manifest validator is used'
type: string
default: 'main'
secrets:
REGISTRY_TOKEN:
required: false

jobs:
release:
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24

- name: Bump the version and write the changelog
if: inputs.bump != ''
env:
BUMP: ${{ inputs.bump }}
NOTES: ${{ inputs.notes }}
run: |
case "$BUMP" in patch|minor|major) ;; *) echo "::error::bump must be patch, minor or major"; exit 1;; esac
if [ "$GITHUB_REF_TYPE" != branch ]; then echo "::error::run it on a branch (normally main)"; exit 1; fi
old="$(node -p "require('./plugin/manifest.json').version")"
new="$(node -e '
const [a,b,c] = process.argv[1].split("-")[0].split(".").map(Number);
const k = process.argv[2];
console.log(k === "major" ? `${a+1}.0.0` : k === "minor" ? `${a}.${b+1}.0` : `${a}.${b}.${c+1}`);
' "$old" "$BUMP")"
if git rev-parse -q --verify "refs/tags/v$new" >/dev/null; then echo "::error::tag v$new exists already"; exit 1; fi
echo "Releasing $old -> $new"
# manifest.json: only the version line changes, the rest keeps its formatting.
node -e '
const fs = require("fs"); const p = "plugin/manifest.json";
const s = fs.readFileSync(p, "utf8");
const out = s.replace(/("version"\s*:\s*")[^"]*(")/, `$1${process.argv[1]}$2`);
if (out === s) { console.error("no version in manifest.json"); process.exit(1); }
JSON.parse(out); fs.writeFileSync(p, out);
' "$new"
npm version "$new" --no-git-tag-version --allow-same-version >/dev/null
# Notes: what was typed, or the commit subjects since the last tag.
body="$RUNNER_TEMP/section.md"
if [ -n "$NOTES" ]; then
printf '%s\n' "$NOTES" > "$body"
else
last="$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)"
git log --no-merges --format='- %s' ${last:+"$last"..}HEAD | grep -v '^- Release [0-9]' > "$body" || true
[ -s "$body" ] || echo "- Maintenance release." > "$body"
fi
node -e '
const fs = require("fs"); const [ver, bodyFile] = process.argv.slice(1);
const date = new Date().toISOString().slice(0, 10);
const section = `## ${ver} - ${date}\n\n${fs.readFileSync(bodyFile, "utf8").trim()}\n`;
let s = fs.existsSync("CHANGELOG.md") ? fs.readFileSync("CHANGELOG.md", "utf8") : "# Changelog\n";
const i = s.search(/^## /m);
s = i < 0 ? s.trimEnd() + "\n\n" + section : s.slice(0, i) + section + "\n" + s.slice(i);
fs.writeFileSync("CHANGELOG.md", s);
' "$new" "$body"
git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
commit -q -am "Release $new"
git tag -a "v$new" -m "$new"
git push origin "HEAD:$GITHUB_REF_NAME" "v$new"
echo "TAG=v$new" >> "$GITHUB_ENV"

- name: Tag of this release
if: inputs.bump == ''
run: |
if [ "$GITHUB_REF_TYPE" != tag ]; then echo "::error::choose patch, minor or major"; exit 1; fi
echo "TAG=$GITHUB_REF_NAME" >> "$GITHUB_ENV"

- name: Install
run: if [ -f package-lock.json ]; then npm ci; else npm install; fi
- name: Build
run: npm run build
- name: Pack (checks tag = manifest version = package.json version)
run: npm run pack

- uses: actions/checkout@v4
with:
repository: Ervisio/ervisio
ref: ${{ inputs.ervisio-ref }}
path: .ervisio-core
- uses: actions/setup-go@v5
with:
go-version-file: .ervisio-core/server/go.mod
cache: false
- name: Validate the manifest (Ervisio's own checks, throwaway key)
run: |
(cd .ervisio-core/server && CGO_ENABLED=0 go build -o "$RUNNER_TEMP/plugin-sign" ./internal/modules/plugins/cmd/plugin-sign)
id="$(node -p "require('./plugin/manifest.json').id")"
version="$(node -p "require('./plugin/manifest.json').version")"
echo "ID=$id" >> "$GITHUB_ENV"; echo "VERSION=$version" >> "$GITHUB_ENV"
(cd dist && sha256sum -c "$id-$version.tar.gz.sha256")
V="$RUNNER_TEMP/validate"; mkdir -p "$V"
tar -xzf "dist/$id-$version.tar.gz" -C "$V"
test "$(ls "$V")" = "$id"
if [ -n "$(find "$V" ! -type f ! -type d)" ]; then echo "links or special files in the tarball"; exit 1; fi
pub="$("$RUNNER_TEMP/plugin-sign" -genkey "$RUNNER_TEMP/throwaway.key" | sed -n 's/^public key: //p')"
"$RUNNER_TEMP/plugin-sign" -key "$RUNNER_TEMP/throwaway.key" "$V/$id"
"$RUNNER_TEMP/plugin-sign" -verify -pub "$pub" "$V/$id"
rm -f "$RUNNER_TEMP/throwaway.key"
- name: Release notes (CHANGELOG.md section of this version)
run: |
notes="$RUNNER_TEMP/notes.md"
awk -v v="$VERSION" '
/^## / { if (p) exit; h=$0; sub(/^## +\[?v?/, "", h); gsub(/\]/, " ", h); split(h, a, /[ \t(]+/); if (a[1] == v) { p=1; next } }
p { print }' CHANGELOG.md > "$notes"
if ! grep -q '[^[:space:]]' "$notes"; then echo "::error::CHANGELOG.md has no section for $VERSION"; exit 1; fi
cat "$notes"
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$TAG" --verify-tag --title "$ID $VERSION" --notes-file "$RUNNER_TEMP/notes.md" \
"dist/$ID-$VERSION.tar.gz" "dist/$ID-$VERSION.tar.gz.sha256"
- name: Tell the registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
if [ -z "$REGISTRY_TOKEN" ]; then
echo "::notice::REGISTRY_TOKEN is not set: the registry picks this release up within six hours (or run its Sync workflow)."
exit 0
fi
GH_TOKEN="$REGISTRY_TOKEN" gh api repos/Ervisio/plugins/dispatches \
-f event_type=plugin-release -f "client_payload[id]=$ID" -f "client_payload[repo]=$GITHUB_REPOSITORY"
echo "Registry notified: $ID $VERSION"
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ Windows support (Ervisio 0.6). Plugins written for 0.2 run unchanged on Linux; t
under one name; Windows named pipes as `socket`.
- `sdk.platform`: the server's system.
- docs/sdk.md: "Windows" section.
- One-button releases: the reusable workflow `.github/workflows/plugin-release.yml` bumps the version, writes the
changelog, tags, releases and tells the registry. The template's `release.yml` calls it (Actions › Release › Run
workflow). Existing plugins: replace `.github/workflows/release.yml` with the template's.
- `create-ervisio-plugin <id>`: a new plugin project from the template in one command (`--platforms`, `--github`).
- The template's CI validates against Ervisio `main`, so `platforms` and other new manifest fields are accepted.

## 0.2.0

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ frame and reaches the machine only through what the manifest declares: commands,

## Getting started

One command makes a new plugin project (template, id, names, platforms, git, the Release button):

```sh
npx degit Ervisio/plugin-sdk/template my-plugin # or copy the template/ folder
cd my-plugin
npm exec --yes --package=github:Ervisio/plugin-sdk -- create-ervisio-plugin my-tool --name "My tool" --platforms linux,windows
cd plugin-my-tool
npm install
npm run build # dist/hello/index.js + manifest.json
npm run build # dist/my-tool/index.js + manifest.json
```

Then turn on developer mode in Ervisio and load `dist/hello` from Plugins › Developer.
Add `--github Ervisio` to also create and push the GitHub repository (needs the `gh` CLI). Then turn on developer mode
in Ervisio and load `dist/my-tool` from Plugins › Developer.

Releasing is one button: **Actions › Release › Run workflow** (patch / minor / major). See
[docs/publishing.md](docs/publishing.md).

The package is not on npm yet; the template depends on it through git:

Expand Down
39 changes: 27 additions & 12 deletions docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,21 @@ release tarball. Both use `ervisio-plugin-pack`, installed with this package.

## Releasing

1. Set the new version in `plugin/manifest.json` and `package.json`, and add a `## X.Y.Z` section to `CHANGELOG.md`.
Say plainly what changed, and say it when the release asks for new permissions (capabilities) and why.
2. Commit, then tag and push: `git tag -a vX.Y.Z -m "X.Y.Z" && git push origin vX.Y.Z`.
3. The release workflow checks that the tag equals the manifest and package versions, builds, validates the manifest
with Ervisio's own validator, and creates the GitHub release.
On GitHub: **Actions › Release › Run workflow**, choose `patch`, `minor` or `major`, optionally type the release notes,
and run it. That is all. The workflow (the reusable one in this repository, `.github/workflows/plugin-release.yml`):

1. bumps the version in `plugin/manifest.json` and `package.json`,
2. adds a `## X.Y.Z` section to `CHANGELOG.md`: the notes you typed, or the commit subjects since the last release
(write commit subjects as you want them read: "Add the Logs tab", "Fix the stop button"),
3. commits `Release X.Y.Z`, tags `vX.Y.Z` and pushes both,
4. builds, validates the manifest with Ervisio's own validator and creates the GitHub release,
5. tells the registry, which publishes it (see below).

When a release asks for new permissions, say so in the notes and why. Pushing a `vX.Y.Z` tag yourself still works:
the workflow then does steps 4 and 5.

If `main` is protected so that GitHub Actions cannot push to it, allow `github-actions[bot]` to bypass the rule, or
release by pushing the tag yourself.

### Release assets (the contract with the registry)

Expand All @@ -48,13 +58,18 @@ Open a pull request on [Ervisio/plugins](https://github.com/Ervisio/plugins) tha
`registry.json`, following its [CONTRIBUTING.md](https://github.com/Ervisio/plugins/blob/main/CONTRIBUTING.md). After
that:

* The registry checks the latest release of every listed repository every few hours. A new version becomes a pull
request in the registry that shows the manifest, the release notes and the permission changes against the previous
version ("new permissions: ..."). A maintainer can also run the registry's "Sync" workflow by hand for a faster
pickup.
* Only registry maintainers merge. On merge, the registry signs the plugin with the Ervisio team key (`manifest.sig`
over the manifest, which lists the sha256 of every file), attaches the signed tarball to a release of the registry,
and publishes the new signed catalog.
* The release workflow tells the registry right away when the organization secret `REGISTRY_TOKEN` exists (a
fine-grained token allowed to run workflows on Ervisio/plugins: "Contents: read and write" there). Without it the
registry checks every listed repository every six hours.
* **Plugins of the Ervisio team** (`"trust": "team"` in `registry.json`): a new version that asks for **no new
permissions** is signed and published at once, with no pull request; it is in the marketplace a few minutes after
you press Release. A version with new or wider permissions, and the first version of a plugin, become a pull
request that shows the manifest, the notes and the permission changes; a maintainer merges it, then it is
published.
* **Community plugins**: every new version is a pull request, reviewed by a maintainer.
* On publish the registry signs the plugin with the Ervisio team key (`manifest.sig` over the manifest, which lists the
sha256 of every file), attaches the signed tarball to a release of the registry, and publishes the new signed
catalog.
* Every plugin in the catalog is signed and shows as verified. Ervisio installs only signed plugins by default, and
the consent dialog shows the user exactly the permissions your manifest asks for.

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@
"scripts/pack.mjs",
"docs",
"README.md",
"LICENSE"
"LICENSE",
"scripts/create.mjs",
"template"
],
"bin": {
"ervisio-plugin-pack": "scripts/pack.mjs"
"ervisio-plugin-pack": "scripts/pack.mjs",
"create-ervisio-plugin": "scripts/create.mjs"
},
"peerDependencies": {
"@types/react": "^18.3.0",
Expand Down
121 changes: 121 additions & 0 deletions scripts/create.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// create-ervisio-plugin: a new plugin project from the template, ready to
// build, with the one-button Release workflow.
//
// npm exec --yes --package=github:Ervisio/plugin-sdk -- create-ervisio-plugin <id> [options]
//
// --name "Display name" default: the id, capitalised
// --description "..." one line for the marketplace
// --author "..." default: git config user.name
// --platforms linux,windows where it works (default: linux)
// --dir <folder> default: plugin-<id>
// --github <owner> also create <owner>/plugin-<id> on GitHub and push (needs the gh CLI, logged in)
//
// It copies template/, fills in the id, names and platforms, runs git init
// with a first commit, and prints the next steps.
import { execFileSync } from 'node:child_process';
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const args = process.argv.slice(2);
const opt = (name, def) => {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def;
};
const fail = (msg) => {
console.error(`create-ervisio-plugin: ${msg}`);
process.exit(1);
};
const sh = (cmd, a, o = {}) => execFileSync(cmd, a, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...o }).trim();
const tryOut = (cmd, a) => {
try {
return sh(cmd, a);
} catch {
return '';
}
};

const id = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--')));
if (!id) fail('usage: create-ervisio-plugin <id> [--name "Name"] [--platforms linux,windows] [--github <owner>]');
if (!/^[a-z][a-z0-9-]{1,39}$/.test(id)) fail(`id "${id}" must be 2-40 lowercase letters, digits or dashes, starting with a letter`);
const name = opt('name', id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '));
const description = opt('description', `${name} for Ervisio.`);
const author = opt('author', tryOut('git', ['config', 'user.name']) || 'Your name');
const platforms = opt('platforms', 'linux').split(',').map((s) => s.trim()).filter(Boolean);
for (const p of platforms) if (p !== 'linux' && p !== 'windows') fail(`platform "${p}" is not linux or windows`);
const dir = resolve(opt('dir', `plugin-${id}`));
const owner = opt('github', '');
if (existsSync(dir)) fail(`${dir} exists already`);

const template = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
if (!existsSync(join(template, 'plugin', 'manifest.json'))) fail(`template not found at ${template}`);
cpSync(template, dir, { recursive: true, filter: (src) => !/[\\/](node_modules|dist)([\\/]|$)/.test(src) });
rmSync(join(dir, 'package-lock.json'), { force: true });

const edit = (rel, fn) => {
const p = join(dir, rel);
writeFileSync(p, fn(readFileSync(p, 'utf8')));
};
edit('plugin/manifest.json', (s) => {
const m = JSON.parse(s);
m.id = id;
m.name = name;
m.description = description;
m.author = author;
m.version = '0.1.0';
m.contributes.pages = [{ ...(m.contributes.pages?.[0] ?? { icon: 'plugins' }), id, title: name }];
if (platforms.length !== 1 || platforms[0] !== 'linux') {
m.platforms = platforms;
m.minCore = '0.6.1';
}
if (platforms.includes('windows') && !platforms.includes('linux')) {
m.capabilities.commands = [
{ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalHours'], description: 'Hours since the last boot', name: 'uptime' },
];
} else if (platforms.includes('windows')) {
m.minCore = '0.6.2';
m.capabilities.commands = [
{ ...m.capabilities.commands[0], platforms: ['linux'] },
{ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalHours'], description: 'Hours since the last boot', name: 'uptime', platforms: ['windows'] },
];
}
return JSON.stringify(m, null, 2) + '\n';
});
edit('package.json', (s) => {
const p = JSON.parse(s);
p.name = `ervisio-plugin-${id}`;
p.version = '0.1.0';
return JSON.stringify(p, null, 2) + '\n';
});
edit('vite.config.ts', (s) => s.replaceAll('hello', id));
edit('src/index.ts', (s) => s.replace("registerPage('hello'", `registerPage('${id}'`).replace("title: 'Hello'", `title: ${JSON.stringify(name)}`));
edit('README.md', (s) => s.replace('# Hello, an Ervisio plugin', `# ${name}, an Ervisio plugin`).replaceAll('dist/hello', `dist/${id}`).replaceAll('hello-<version>', `${id}-<version>`));
writeFileSync(join(dir, 'CHANGELOG.md'), `# Changelog\n\n## 0.1.0\n\n- First version.\n`);

sh('git', ['init', '-q', '-b', 'main'], { cwd: dir });
sh('git', ['add', '-A'], { cwd: dir });
const ident = tryOut('git', ['config', 'user.email']) ? [] : ['-c', 'user.name=Ervisio plugin', '-c', 'user.email=plugin@localhost'];
sh('git', [...ident, 'commit', '-q', '-m', `${name}: first version`], { cwd: dir });
console.log(`Created ${dir}`);

if (owner) {
const repo = `${owner}/plugin-${id}`;
try {
execFileSync('gh', ['repo', 'create', repo, '--public', '--source', dir, '--push', '--description', description], { stdio: 'inherit' });
console.log(`Pushed to https://github.com/${repo}`);
} catch {
fail(`could not create ${repo} with gh (is it installed and logged in?). The project is ready in ${dir}.`);
}
}

console.log(`
Next:
cd ${dir}
npm install
npm run build # dist/${id}/: load it from Ervisio > Plugins > Developer
${owner ? '' : ` gh repo create <owner>/plugin-${id} --public --source . --push\n`}
Release: on GitHub, Actions > Release > Run workflow (patch / minor / major).
Marketplace: the first time, add the plugin to registry.json in Ervisio/plugins
{"id": "${id}", "repo": "${owner || '<owner>'}/plugin-${id}", "trust": "team", "category": "..."}
after that every release is picked up by itself.`);
Loading
Loading