From 1aa6dc26a1846f08082760910b2eac3f0c0e58af Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Wed, 2 Sep 2026 09:19:18 +0000 Subject: [PATCH 1/4] test: specify changelog section extraction --- test/changelog-section.test.js | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 test/changelog-section.test.js diff --git a/test/changelog-section.test.js b/test/changelog-section.test.js new file mode 100644 index 0000000..18b64bb --- /dev/null +++ b/test/changelog-section.test.js @@ -0,0 +1,76 @@ +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', 'changelog-section.sh'); + +function extract(version, changelog = path.join(ROOT, 'CHANGELOG.md')) { + return spawnSync('bash', [SCRIPT, version, changelog], { encoding: 'utf8' }); +} + +function withChangelog(contents, assertion) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-changelog-test-')); + try { + const changelog = path.join(dir, 'CHANGELOG.md'); + fs.writeFileSync(changelog, contents); + assertion(changelog); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('extracts the complete real 1.4.0 changelog section', () => { + const result = extract('1.4.0'); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.stdout.split('\n').length - 1, 21); + assert.match(result.stdout, /^### Added$/m); + assert.match(result.stdout, /^### Changed$/m); + assert.match(result.stdout, /^### Fixed$/m); +}); + +test('missing version fails with a reason and no stdout', () => { + const result = extract('1.5.0'); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /no section for '1\.5\.0'/); +}); + +test('heading without a list entry is rejected as empty', () => { + withChangelog('## 2.0.0\n\n### Changed\n\nProse only.\n', (changelog) => { + const result = extract('2.0.0', changelog); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /section for '2\.0\.0' has no changelog entries/); + }); +}); + +test('extraction stops before the next version heading', () => { + withChangelog([ + '## 2.0.0', + '', + '### Added', + '', + '- Current change.', + '', + '## 1.0.0', + '', + '### Added', + '', + '- Older change.', + '', + ].join('\n'), (changelog) => { + const result = extract('2.0.0', changelog); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, '### Added\n\n- Current change.\n\n'); + assert.doesNotMatch(result.stdout, /Older change/); + }); +}); From e3f4703e137be02701e5d1f2969f714f6effd04e Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Wed, 2 Sep 2026 09:21:09 +0000 Subject: [PATCH 2/4] feat: extract publishable changelog sections --- scripts/changelog-section.sh | 73 ++++++++++++++++++++++++++++++++++ test/changelog-section.test.js | 4 +- 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100755 scripts/changelog-section.sh diff --git a/scripts/changelog-section.sh b/scripts/changelog-section.sh new file mode 100755 index 0000000..37360dd --- /dev/null +++ b/scripts/changelog-section.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Vendored from heavy-duty/ceremony 0.6.3: +# lib/changelog.sh (changelog_section + changelog_section_problem) +# bin/changelog-section + +set -euo pipefail + +changelog_section() { + awk -v ver="$2" ' + /^## / { if (found) exit; found = ($2 == ver); next } + found && !body && /^[[:space:]]*$/ { next } + found { body = 1; print } + ' "$1" +} + +changelog_section_problem() { + local file="$1" ver="$2" notes problem + + if ! awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$file"; then + printf "no section for '%s'\n" "$ver" + return 1 + fi + + [ "$ver" = "Unreleased" ] && return 0 + + notes="$(changelog_section "$file" "$ver")" + if ! printf '%s\n' "$notes" | awk '/^[[:space:]]*[-*][[:space:]]/ { found = 1; exit } END { exit !found }'; then + printf "section '%s' has no entries — a heading is not an entry\n" "$ver" + return 1 + fi + + problem="$( + printf '%s\n' "$notes" | awk ' + /^### / { + if (heading != "" && !entry) { + reported = 1 + print heading + exit + } + heading = $0 + entry = 0 + next + } + heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 } + END { + if (!reported && heading != "" && !entry) print heading + } + ' + )" + if [ -n "$problem" ]; then + printf "section '%s' has an empty heading: '%s'\n" "$ver" "$problem" + return 1 + fi +} + +ver="${1:-}" +changelog="${2:-CHANGELOG.md}" +if [ -z "$ver" ]; then + echo "usage: changelog-section.sh []" >&2 + exit 2 +fi +[ -f "$changelog" ] || { + echo "changelog-section: no such file: $changelog" >&2 + exit 1 +} + +if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then + echo "changelog-section: $changelog has no publishable section for '$ver'" >&2 + printf 'changelog-section: %s\n' "$diagnosis" >&2 + exit 1 +fi +notes="$(changelog_section "$changelog" "$ver")" +printf '%s\n' "$notes" diff --git a/test/changelog-section.test.js b/test/changelog-section.test.js index 18b64bb..e28da16 100644 --- a/test/changelog-section.test.js +++ b/test/changelog-section.test.js @@ -48,7 +48,7 @@ test('heading without a list entry is rejected as empty', () => { assert.equal(result.status, 1); assert.equal(result.stdout, ''); - assert.match(result.stderr, /section for '2\.0\.0' has no changelog entries/); + assert.match(result.stderr, /section '2\.0\.0' has no entries/); }); }); @@ -70,7 +70,7 @@ test('extraction stops before the next version heading', () => { const result = extract('2.0.0', changelog); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, '### Added\n\n- Current change.\n\n'); + assert.equal(result.stdout, '### Added\n\n- Current change.\n'); assert.doesNotMatch(result.stdout, /Older change/); }); }); From 571e1b1f1f39e072aec15bf36775c68a3233eb5e Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Wed, 2 Sep 2026 09:23:21 +0000 Subject: [PATCH 3/4] 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), []); +}); From ee88d7d395b840d8ac9593f27d6656414fba79cb Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Wed, 2 Sep 2026 09:24:17 +0000 Subject: [PATCH 4/4] ci: delegate release publication to stoke --- .forgejo/workflows/release.yml | 19 ++----------------- changelog.d/54.md | 1 + 2 files changed, 3 insertions(+), 17 deletions(-) create mode 100644 changelog.d/54.md diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7f35536..958e8c3 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -37,20 +37,5 @@ jobs: - 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" + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: bash scripts/publish-release.sh "${{ github.ref_name }}" "$(node -p "require('./package.json').version")" "$(ls dist/stoke_*_all.deb)" "${{ github.repository_owner }}" stoke diff --git a/changelog.d/54.md b/changelog.d/54.md new file mode 100644 index 0000000..3b13b81 --- /dev/null +++ b/changelog.d/54.md @@ -0,0 +1 @@ +- Publish release assets and the matching changelog section through stoke's credential-safe CLI. (#54).