Merge pull request 'feat: publish releases through stoke CLI' (#55) from build/54-publish-release into main
All checks were successful
ci / test (push) Successful in 16s
All checks were successful
ci / test (push) Successful in 16s
Reviewed-on: #55 Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders> Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders> Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
This commit is contained in:
commit
d6a21c9d9e
6 changed files with 315 additions and 17 deletions
|
|
@ -37,20 +37,5 @@ jobs:
|
||||||
|
|
||||||
- name: Create release and attach .deb
|
- name: Create release and attach .deb
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
run: bash scripts/publish-release.sh "${{ github.ref_name }}" "$(node -p "require('./package.json').version")" "$(ls dist/stoke_*_all.deb)" "${{ github.repository_owner }}" stoke
|
||||||
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
changelog.d/54.md
Normal file
1
changelog.d/54.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
- Publish release assets and the matching changelog section through stoke's credential-safe CLI. (#54).
|
||||||
73
scripts/changelog-section.sh
Executable file
73
scripts/changelog-section.sh
Executable file
|
|
@ -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 <version> [<changelog>]" >&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"
|
||||||
49
scripts/publish-release.sh
Executable file
49
scripts/publish-release.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish one release asset through stoke, creating the release when needed.
|
||||||
|
#
|
||||||
|
# Usage: publish-release.sh <tag> <version> <deb> <owner> <repo>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TAG="${1:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
|
||||||
|
VERSION="${2:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
|
||||||
|
DEB="${3:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
|
||||||
|
OWNER="${4:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
|
||||||
|
REPO="${5:?usage: publish-release.sh <tag> <version> <deb> <owner> <repo>}"
|
||||||
|
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
|
||||||
76
test/changelog-section.test.js
Normal file
76
test/changelog-section.test.js
Normal file
|
|
@ -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 '2\.0\.0' has no 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');
|
||||||
|
assert.doesNotMatch(result.stdout, /Older change/);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
test/publish-release.test.js
Normal file
114
test/publish-release.test.js
Normal file
|
|
@ -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), []);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue