From 571e1b1f1f39e072aec15bf36775c68a3233eb5e Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Wed, 2 Sep 2026 09:23:21 +0000 Subject: [PATCH] feat: publish release assets through stoke --- scripts/publish-release.sh | 49 +++++++++++++++ test/publish-release.test.js | 114 +++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100755 scripts/publish-release.sh create mode 100644 test/publish-release.test.js diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..347953e --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Publish one release asset through stoke, creating the release when needed. +# +# Usage: publish-release.sh + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TAG="${1:?usage: publish-release.sh }" +VERSION="${2:?usage: publish-release.sh }" +DEB="${3:?usage: publish-release.sh }" +OWNER="${4:?usage: publish-release.sh }" +REPO="${5:?usage: publish-release.sh }" +FORGE_URL="${FORGE_URL:-${GITHUB_SERVER_URL:?GITHUB_SERVER_URL or FORGE_URL is required}}" +RELEASE_TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}" + +[ -f "$DEB" ] || { echo "publish-release: no such asset: $DEB" >&2; exit 1; } + +if [ -n "${RUNNER_TEMP:-}" ]; then + TMP="$(mktemp -d "$RUNNER_TEMP/stoke-release.XXXXXX")" +else + TMP="$(mktemp -d)" +fi +trap 'rm -rf "$TMP"' EXIT + +TOKEN_FILE="$TMP/token" +CONFIG_FILE="$TMP/config.json" +NOTES_FILE="$TMP/notes.md" +umask 077 +printf '%s' "$RELEASE_TOKEN" > "$TOKEN_FILE" +chmod 0600 "$TOKEN_FILE" + +run_stoke() { + if [ -n "${STOKE:-}" ]; then + "$STOKE" --config "$CONFIG_FILE" "$@" + else + node "$ROOT/src/cli.js" --config "$CONFIG_FILE" "$@" + fi +} + +run_stoke auth login --url "$FORGE_URL" --token-file "$TOKEN_FILE" +"$ROOT/scripts/changelog-section.sh" "$VERSION" CHANGELOG.md > "$NOTES_FILE" + +if run_stoke release view --owner "$OWNER" --repo "$REPO" --tag "$TAG" --json >/dev/null 2>&1; then + run_stoke release upload --owner "$OWNER" --repo "$REPO" --tag "$TAG" --asset "$DEB" +else + run_stoke release create --owner "$OWNER" --repo "$REPO" --tag "$TAG" \ + --title "$TAG" --body-file "$NOTES_FILE" --asset "$DEB" +fi diff --git a/test/publish-release.test.js b/test/publish-release.test.js new file mode 100644 index 0000000..6392064 --- /dev/null +++ b/test/publish-release.test.js @@ -0,0 +1,114 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'publish-release.sh'); +const TOKEN = 'release-token-that-must-not-enter-argv'; + +function runScenario({ viewStatus = 0, changelog = '## 2.0.0\n\n### Added\n\n- New release flow.\n' } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-test-')); + try { + const runnerTemp = path.join(dir, 'runner-temp'); + const log = path.join(dir, 'calls.jsonl'); + const stub = path.join(dir, 'stoke-stub.js'); + const deb = path.join(dir, 'stoke_2.0.0_all.deb'); + fs.mkdirSync(runnerTemp); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog); + fs.writeFileSync(deb, 'package'); + fs.writeFileSync(stub, `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +const tokenIndex = args.indexOf('--token-file'); +const configIndex = args.indexOf('--config'); +const record = { args }; +if (tokenIndex !== -1) { + const tokenFile = args[tokenIndex + 1]; + record.tokenFile = tokenFile; + record.token = fs.readFileSync(tokenFile, 'utf8'); + record.tokenMode = fs.statSync(tokenFile).mode & 0o777; +} +if (configIndex !== -1) record.config = args[configIndex + 1]; +fs.appendFileSync(process.env.STOKE_CALL_LOG, JSON.stringify(record) + '\\n'); +if (args.includes('release') && args.includes('view')) process.exit(Number(process.env.VIEW_STATUS)); +`); + fs.chmodSync(stub, 0o755); + + const result = spawnSync('bash', [SCRIPT, 'v2.0.0', '2.0.0', deb, 'heavy-duty', 'stoke'], { + cwd: dir, + encoding: 'utf8', + env: { + ...process.env, + RELEASE_TOKEN: TOKEN, + GITHUB_SERVER_URL: 'https://forge.example.test', + RUNNER_TEMP: runnerTemp, + STOKE: stub, + STOKE_CALL_LOG: log, + VIEW_STATUS: String(viewStatus), + }, + }); + const calls = fs.existsSync(log) + ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) + : []; + return { result, calls, runnerTemp }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function command(call) { + const index = call.args.indexOf('release'); + return index === -1 ? '' : call.args[index + 1]; +} + +test('existing release uploads the asset without creating another release', () => { + const scenario = runScenario({ viewStatus: 0 }); + + assert.equal(scenario.result.status, 0, scenario.result.stderr); + assert.deepEqual(scenario.calls.map(command).filter(Boolean), ['view', 'upload']); + assert.equal(scenario.calls.some((call) => command(call) === 'create'), false); + const upload = scenario.calls.find((call) => command(call) === 'upload'); + assert.ok(upload.args.includes('--tag')); + assert.ok(upload.args.includes('v2.0.0')); + assert.ok(upload.args.includes('--asset')); + assert.ok(upload.args.some((arg) => arg.endsWith('stoke_2.0.0_all.deb'))); +}); + +test('missing release creates it with changelog notes and the asset', () => { + const scenario = runScenario({ viewStatus: 1 }); + + assert.equal(scenario.result.status, 0, scenario.result.stderr); + assert.deepEqual(scenario.calls.map(command).filter(Boolean), ['view', 'create']); + const create = scenario.calls.find((call) => command(call) === 'create'); + assert.ok(create.args.includes('--title')); + assert.ok(create.args.includes('v2.0.0')); + assert.ok(create.args.includes('--body-file')); + assert.ok(create.args.includes('--asset')); +}); + +test('authentication uses a 0600 token file and never puts the token in argv', () => { + const scenario = runScenario(); + + assert.equal(scenario.result.status, 0, scenario.result.stderr); + const auth = scenario.calls[0]; + assert.ok(auth.args.includes('auth')); + assert.ok(auth.args.includes('login')); + assert.ok(auth.args.includes('--token-file')); + assert.equal(auth.token, TOKEN); + assert.equal(auth.tokenMode, 0o600); + assert.equal(auth.args.includes('https://forge.example.test'), true); + assert.equal(scenario.calls.every((call) => call.args.every((arg) => !arg.includes(TOKEN))), true); + assert.equal(scenario.calls.every((call) => call.config === auth.config), true); + assert.equal(fs.existsSync(auth.tokenFile), false, 'temporary credential file must be removed'); +}); + +test('missing changelog section aborts before any release command', () => { + const scenario = runScenario({ changelog: '## 1.0.0\n\n- Old release.\n' }); + + assert.equal(scenario.result.status, 1); + assert.match(scenario.result.stderr, /no section for '2\.0\.0'/); + assert.deepEqual(scenario.calls.map(command).filter(Boolean), []); +});