Add apt distribution: deb packaging, registry publish, release automation #3

Merged
kimi-bot-andresmgsl merged 1 commit from feat/apt-packaging into main 2026-07-22 21:19:00 +00:00
12 changed files with 406 additions and 3 deletions

View file

@ -0,0 +1,56 @@
# Release automation: on every v* tag, build the Debian package, publish it
# to the Forgejo Debian registry (owner: heavy-duty) and attach the .deb to
# the tag's release page as a fallback for direct `dpkg -i` installs.
#
# Requirements:
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
# your runner actually advertises (common: docker, ubuntu-latest).
# - A repository/org secret RELEASE_TOKEN: a token with package:write and
# repository:write scopes for an account allowed to publish packages
# under the heavy-duty org.
name: release
on:
push:
tags:
- 'v*'
jobs:
deb:
runs-on: docker
container:
image: node:22-bookworm
steps:
- name: Check out tag
uses: actions/checkout@v4
- name: Run tests
run: npm ci && npm test
- name: Build .deb
run: bash scripts/build-deb.sh
- name: Publish to Debian registry
env:
STOKE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: bash scripts/publish-deb.sh dist/stoke_*_all.deb heavy-duty stable main
- name: Create release and attach .deb
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -euo pipefail
DEB=$(ls dist/stoke_*_all.deb)
# Create the release if it does not exist yet, then grab its id.
RELEASE_ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
"$API/releases" | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
fi
curl -sf -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$DEB" \
"$API/releases/$RELEASE_ID/assets?name=$(basename "$DEB")" >/dev/null
echo "Attached $(basename "$DEB") to release $TAG"

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

View file

@ -11,6 +11,33 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman
## Installation
### With apt (Debian/Ubuntu — recommended)
The package is published to the Debian registry of the forge itself. One-time setup:
```bash
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash
```
or manually:
```bash
sudo install -d /etc/apt/keyrings
curl -fsSL https://forgejo.heavyduty.builders/api/packages/heavy-duty/debian/repository.key \
| sudo tee /etc/apt/keyrings/forgejo-heavy-duty.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/forgejo-heavy-duty.asc] https://forgejo.heavyduty.builders/api/packages/heavy-duty/debian stable main" \
| sudo tee /etc/apt/sources.list.d/forgejo-heavy-duty.list
sudo apt-get update && sudo apt-get install stoke
```
Upgrades then arrive through regular `apt-get upgrade`.
Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) currently reject the signature Forgejo generates for its Debian registry (an upstream signing bug). `install-apt.sh` detects this and falls back to a `[trusted=yes]` source — integrity then relies on HTTPS to the forge. The script prefers the signed source, so setups heal automatically once the forge is fixed.
As a fallback, each release also has the `.deb` attached for direct install: `sudo dpkg -i stoke_<version>_all.deb`.
### From source
```bash
cd stoke
npm install
@ -390,6 +417,27 @@ stoke pr create -o heavy-duty -r stoke -t "Fix config handling" \
Calls `POST /api/v1/repos/{owner}/{repo}/pulls`.
### `stoke pr merge`
Merge a pull request.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-n, --number <number> pull request number (required)
--method <method> merge, rebase, rebase-merge, squash (default: merge)
--title <title> custom merge commit title
--message <message> custom merge commit message
--delete-branch delete the source branch after merging
```
```bash
stoke pr merge -o heavy-duty -r stoke -n 2 --delete-branch
```
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge`.
### `stoke branch list`
List branches in a repository.
@ -601,6 +649,12 @@ test/
├── cli.test.js # end-to-end CLI behavior (spawned processes)
├── api.test.js # API client with a mocked fetch
└── config.test.js # config path resolution and persistence
scripts/
├── build-deb.sh # build dist/stoke_<version>_all.deb
├── publish-deb.sh # upload a .deb to the Forgejo Debian registry
└── install-apt.sh # consumer-side apt source setup + install
.forgejo/workflows/
└── release.yml # tag-driven build + publish + release attachment
```
- `cli.js` defines commands and options, handles prompts and prints results.
@ -615,6 +669,30 @@ The test suite uses the Node.js built-in test runner — no extra dependencies:
npm test
```
## Packaging and releasing
The Debian package is a pure-JS `Architecture: all` package that ships the CLI to `/usr/lib/stoke` with a `/usr/bin/stoke` symlink and declares `Depends: nodejs (>= 22.12)`.
Build locally (needs `dpkg-deb`; runs `lintian` when installed):
```bash
scripts/build-deb.sh # -> dist/stoke_<version>_all.deb
```
Publish to the Forgejo Debian registry (uses `STOKE_TOKEN` or the `stoke auth login` token; the account needs package write access on the owner):
```bash
scripts/publish-deb.sh dist/stoke_<version>_all.deb heavy-duty stable main
```
Releases are automated in `.forgejo/workflows/release.yml`: pushing a `v*` tag runs the tests, builds the `.deb`, publishes it to the `heavy-duty` registry and attaches it to the tag's release page. The workflow needs a Forgejo Actions runner and a `RELEASE_TOKEN` secret (package + repository write for the `heavy-duty` org); adjust `runs-on` to a label your runner advertises.
Release checklist:
1. Bump `version` in `package.json` and `package-lock.json`.
2. Commit, tag `v<version>`, push the tag.
3. CI publishes the package; consumers get it with `apt-get update && apt-get upgrade`.
## Security notes
- Tokens are stored on disk with `0600` permissions.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "stoke",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stoke",
"version": "1.1.0",
"version": "1.2.0",
"license": "ISC",
"dependencies": {
"commander": "^15.0.0"

View file

@ -1,6 +1,6 @@
{
"name": "stoke",
"version": "1.1.0",
"version": "1.2.0",
"description": "CLI for the heavy-duty forge (Forgejo)",
"main": "src/cli.js",
"scripts": {

104
scripts/build-deb.sh Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# Build a Debian package for stoke.
#
# Usage: scripts/build-deb.sh
#
# Produces dist/stoke_<version>_all.deb from a clean staging copy of the
# working tree (only src/, package.json and package-lock.json are shipped;
# production dependencies are installed fresh with `npm ci --omit=dev`).
#
# Requirements: bash, node/npm, dpkg-deb, gzip. Runs lintian when available.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$(node -p "require('$ROOT/package.json').version")"
MAINTAINER="${DEB_MAINTAINER:-Heavy Duty Builders <equipo@lafamilia.so>}"
HOMEPAGE="https://forgejo.heavyduty.builders/heavy-duty/stoke"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
PKG="$STAGE/stoke_${VERSION}_all"
LIB="$PKG/usr/lib/stoke"
DOC="$PKG/usr/share/doc/stoke"
install -d "$PKG/DEBIAN" "$LIB" "$PKG/usr/bin" "$DOC"
# --- payload -----------------------------------------------------------------
cp -r "$ROOT/src" "$LIB/src"
cp "$ROOT/package.json" "$ROOT/package-lock.json" "$LIB/"
(cd "$LIB" && npm ci --omit=dev --silent --no-audit --no-fund)
# Launcher: the CLI has a `#!/usr/bin/env node` shebang and resolves its
# node_modules relative to its real path, so a symlink is all we need.
ln -s ../lib/stoke/src/cli.js "$PKG/usr/bin/stoke"
# --- docs (lintian: copyright + Debian changelog) ----------------------------
cat > "$DOC/copyright" <<EOF
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: stoke
Source: $HOMEPAGE
Files: *
Copyright: $(date +%Y) Heavy Duty Builders
License: ISC
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
EOF
cat > "$STAGE/changelog" <<EOF
stoke ($VERSION) stable; urgency=medium
* See the repository release page for notes.
-- $MAINTAINER $(date -R)
EOF
# Native package (no Debian revision in the version), so plain changelog.gz.
gzip -9n -c "$STAGE/changelog" > "$DOC/changelog.gz"
# Normalize permissions regardless of the builder's umask: no group/other
# write anywhere, executable entry point.
chmod -R go-w "$PKG/usr"
chmod 0755 "$LIB/src/cli.js"
# --- control -----------------------------------------------------------------
INSTALLED_SIZE="$(du -sk --exclude=DEBIAN "$PKG" | cut -f1)"
cat > "$PKG/DEBIAN/control" <<EOF
Package: stoke
Version: $VERSION
Section: utils
Priority: optional
Architecture: all
Depends: nodejs (>= 22.12)
Installed-Size: $INSTALLED_SIZE
Maintainer: $MAINTAINER
Homepage: $HOMEPAGE
Description: CLI for the heavy-duty forge (Forgejo)
stoke manages the Forgejo instance at forgejo.heavyduty.builders from the
command line: authentication, repositories, issues, pull requests,
branches, collaborators, organizations, teams and users.
EOF
# --- build -------------------------------------------------------------------
mkdir -p "$ROOT/dist"
dpkg-deb --build --root-owner-group "$PKG" "$ROOT/dist/" >/dev/null
DEB="$ROOT/dist/stoke_${VERSION}_all.deb"
echo "Built: $DEB"
if command -v lintian >/dev/null 2>&1; then
# binary-without-manpage is a known, accepted gap for now.
lintian --suppress-tags binary-without-manpage "$DEB" || true
else
echo "Note: lintian not installed; skipping package lint."
fi

63
scripts/install-apt.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
#
# One-time setup to install stoke via apt on Debian/Ubuntu.
#
# Adds the heavy-duty Forgejo Debian registry as an APT source (with its
# signing key) and installs the stoke package. Safe to re-run; afterwards
# stoke upgrades through regular `apt-get upgrade`.
#
# Usage:
# ./scripts/install-apt.sh
# FORGE_URL=... OWNER=... ./scripts/install-apt.sh # non-default instance
#
# Run as root or as a user with sudo.
set -euo pipefail
FORGE_URL="${FORGE_URL:-https://forgejo.heavyduty.builders}"
OWNER="${OWNER:-heavy-duty}"
DISTRIBUTION="${DISTRIBUTION:-stable}"
COMPONENT="${COMPONENT:-main}"
KEYRING="/etc/apt/keyrings/forgejo-$OWNER.asc"
LIST="/etc/apt/sources.list.d/forgejo-$OWNER.list"
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 || { echo "error: run as root or install sudo" >&2; exit 1; }
SUDO="sudo"
fi
update_only_this_source() {
$SUDO apt-get update \
-o Dir::Etc::sourcelist="$LIST" \
-o Dir::Etc::sourceparts=/dev/null \
-o APT::Get::List-Cleanup=0
}
echo "Adding APT source for $FORGE_URL/$OWNER ..."
$SUDO install -d -m 0755 /etc/apt/keyrings
curl -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null
echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
| $SUDO tee "$LIST" >/dev/null
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
# in the upstream signing library). Try the properly signed source first so
# this heals automatically once the forge is fixed; otherwise fall back to
# [trusted=yes] — package integrity then relies on HTTPS to our own forge.
if ! update_only_this_source; then
echo
echo "WARNING: signature verification failed (known Forgejo registry issue" >&2
echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2
echo "security is provided by HTTPS to $FORGE_URL." >&2
echo
echo "deb [trusted=yes] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \
| $SUDO tee "$LIST" >/dev/null
update_only_this_source
fi
$SUDO apt-get install -y stoke
echo
stoke --version >/dev/null && echo "stoke $(stoke --version) installed. Run: stoke auth login"

52
scripts/publish-deb.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
#
# Publish a .deb to the Forgejo Debian package registry.
#
# Usage: scripts/publish-deb.sh <path-to-deb> [owner] [distribution] [component]
#
# owner registry owner (user or org), default: heavy-duty
# distribution APT distribution, default: stable
# component APT component, default: main
#
# Authentication (first match wins):
# 1. STOKE_TOKEN environment variable
# 2. The token stored by `stoke auth login`
#
# The Forgejo URL defaults to the instance in the stoke config, falling back
# to https://forgejo.heavyduty.builders. Override with FORGE_URL.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEB="${1:?usage: publish-deb.sh <path-to-deb> [owner] [distribution] [component]}"
OWNER="${2:-heavy-duty}"
DISTRIBUTION="${3:-stable}"
COMPONENT="${4:-main}"
[ -f "$DEB" ] || { echo "error: no such file: $DEB" >&2; exit 1; }
CONFIG_JSON="$(node -e "const c = require('$ROOT/src/config').loadConfig(); if (c) process.stdout.write(JSON.stringify(c));" 2>/dev/null || true)"
TOKEN="${STOKE_TOKEN:-$(node -pe "(JSON.parse(process.argv[1] || '{}').token) || ''" "$CONFIG_JSON")}"
FORGE_URL="${FORGE_URL:-$(node -pe "(JSON.parse(process.argv[1] || '{}').url) || 'https://forgejo.heavyduty.builders'" "$CONFIG_JSON")}"
[ -n "$TOKEN" ] || { echo "error: no token. Set STOKE_TOKEN or run: stoke auth login" >&2; exit 1; }
URL="$FORGE_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
echo "Uploading $(basename "$DEB") to $URL"
STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \
-X PUT -H "Authorization: token $TOKEN" \
--upload-file "$DEB" "$URL")"
case "$STATUS" in
201) echo "Published." ;;
409) echo "Already published (409): this exact version already exists in the registry." ;;
*)
echo "error: upload failed with HTTP $STATUS" >&2
cat /tmp/stoke-publish-response.$$ >&2 || true
rm -f /tmp/stoke-publish-response.$$
exit 1
;;
esac
rm -f /tmp/stoke-publish-response.$$

View file

@ -167,6 +167,10 @@ class ForgejoClient {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, payload);
}
async mergePullRequest(owner, repo, index, payload) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/merge`, payload);
}
async listBranches(owner, repo, opts = {}) {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
}

View file

@ -649,6 +649,36 @@ pr
}
});
pr
.command('merge')
.description('Merge a pull request')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'pull request number', parseId)
.option('--method <method>', 'merge method: merge, rebase, rebase-merge, squash', 'merge')
.option('--title <title>', 'custom merge commit title')
.option('--message <message>', 'custom merge commit message')
.option('--delete-branch', 'delete the source branch after merging', false)
.action(async (options) => {
try {
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const payload = {
Do: options.method,
delete_branch_after_merge: options.deleteBranch,
};
if (options.title) payload.MergeTitleField = options.title;
if (options.message) payload.MergeMessageField = options.message;
await client.mergePullRequest(options.owner, options.repo, options.number, payload);
console.log(`Merged !${options.number} in ${options.owner}/${options.repo} (${options.method}).`);
if (options.deleteBranch) console.log('Source branch deleted.');
} catch (err) {
console.error(`Pull request merge failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
process.exit(1);
}
});
const branchCmd = program
.command('branch')
.description('Manage branches');

View file

@ -122,6 +122,15 @@ test('searchUsers unwraps the {data: []} envelope and paginates', async () => {
assert.equal(users.at(-1).login, 'last');
});
test('mergePullRequest posts the merge payload to the merge endpoint', async () => {
const calls = mockFetch(() => jsonResponse(null, 200));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.mergePullRequest('owner', 'repo', 7, { Do: 'squash', delete_branch_after_merge: true });
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7/merge');
assert.equal(calls[0].opts.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].opts.body), { Do: 'squash', delete_branch_after_merge: true });
});
test('createIssue and createPullRequest hit the expected endpoints', async () => {
const calls = mockFetch(() => jsonResponse({ number: 1 }));
const client = new ForgejoClient('https://forge.test', 'tok');

View file

@ -55,6 +55,12 @@ test('invalid --team-id is rejected before any network call', () => {
assert.match(res.stderr, /Id must be a positive integer/);
});
test('pr merge validates --number before any network call', () => {
const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Id must be a positive integer/);
});
test('issue create --body-file reports unreadable files cleanly', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));