Compare commits
No commits in common. "main" and "build/50-issue-templates" have entirely different histories.
main
...
build/50-i
26 changed files with 64 additions and 926 deletions
|
|
@ -5,11 +5,9 @@
|
|||
# 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 read:user,
|
||||
# write:package and write:repository scopes for an account allowed to
|
||||
# publish packages under the heavy-duty org. read:user is required because
|
||||
# publish-release.sh runs `stoke auth login`, which validates the token
|
||||
# against /user.
|
||||
# - 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:
|
||||
|
|
@ -39,5 +37,20 @@ jobs:
|
|||
|
||||
- name: Create release and attach .deb
|
||||
env:
|
||||
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
|
||||
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"
|
||||
|
|
|
|||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -1,22 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## 1.5.0 — 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).
|
||||
- Added guided proposal and triage work-order forms, with the contributor guide linking directly to proposal intake. (#50).
|
||||
|
||||
### Changed
|
||||
|
||||
- Enforced the exact Forgejo ceremony source and version across governance records and workflow pins. (#36).
|
||||
- Publish release assets and the matching changelog section through stoke's credential-safe CLI. (#54).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep package-lock metadata aligned with the package version and guard against future drift. (#43).
|
||||
- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48).
|
||||
|
||||
## 1.4.0 — 2026-08-31
|
||||
|
||||
### Added
|
||||
|
|
|
|||
1
changelog.d/23.md
Normal file
1
changelog.d/23.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).
|
||||
1
changelog.d/36.md
Normal file
1
changelog.d/36.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Enforced the exact Forgejo ceremony source and version across governance records and workflow pins. (#36).
|
||||
1
changelog.d/43.md
Normal file
1
changelog.d/43.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Keep package-lock metadata aligned with the package version and guard against future drift. (#43).
|
||||
1
changelog.d/48.md
Normal file
1
changelog.d/48.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48).
|
||||
1
changelog.d/50.md
Normal file
1
changelog.d/50.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added guided proposal and triage work-order forms, with the contributor guide linking directly to proposal intake. (#50).
|
||||
|
|
@ -1 +0,0 @@
|
|||
- Clarified Debian publish authentication failures with the CI secret source and the local remedies. (#57).
|
||||
|
|
@ -1 +0,0 @@
|
|||
- Correct the release workflow's documented token scopes to include the identity check required before publishing. (#60).
|
||||
|
|
@ -1 +0,0 @@
|
|||
- Keep Debian registry tokens out of curl process arguments and clean upload credentials and responses on every exit. (#62).
|
||||
|
|
@ -1 +0,0 @@
|
|||
- Normalize Debian package payload modes independently of the builder's umask. (#63).
|
||||
|
|
@ -1 +0,0 @@
|
|||
- Report supplied tokens that remain active after logout and make unauthenticated status machine-detectable. (#64).
|
||||
|
|
@ -1 +0,0 @@
|
|||
- Continue batch imports after one repository cannot resolve its source token, while reporting that item as failed. (#65).
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.5.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stoke",
|
||||
"version": "1.5.0",
|
||||
"version": "1.4.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.5.0",
|
||||
"version": "1.4.0",
|
||||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||
"main": "src/cli.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -67,9 +67,9 @@ 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: traversable
|
||||
# directories, readable files, and execute bits retained only where intended.
|
||||
chmod -R u+rwX,go=rX "$PKG/usr"
|
||||
# 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 -----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
# component APT component, default: main
|
||||
#
|
||||
# Authentication (first match wins):
|
||||
# 1. STOKE_TOKEN environment variable (set from secrets.RELEASE_TOKEN in CI)
|
||||
# 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
|
||||
|
|
@ -30,34 +30,13 @@ CONFIG_JSON="$(node -e "const c = require('$ROOT/src/config').loadConfig(); if (
|
|||
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")}"
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
cat >&2 <<'EOF'
|
||||
error: no token.
|
||||
In CI, this step reads STOKE_TOKEN from secrets.RELEASE_TOKEN; an empty value
|
||||
means the secret is unset or unreadable by this workflow, not that the tool is missing.
|
||||
Locally: export STOKE_TOKEN, or run `stoke auth login`.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${RUNNER_TEMP:-}" ]; then
|
||||
TMP="$(mktemp -d "$RUNNER_TEMP/stoke-publish.XXXXXX")"
|
||||
else
|
||||
TMP="$(mktemp -d)"
|
||||
fi
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
HEADER_FILE="$TMP/authorization-header"
|
||||
RESPONSE_FILE="$TMP/response"
|
||||
umask 077
|
||||
printf 'Authorization: token %s\n' "$TOKEN" >"$HEADER_FILE"
|
||||
chmod 0600 "$HEADER_FILE"
|
||||
[ -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 "$RESPONSE_FILE" -w '%{http_code}' \
|
||||
-X PUT -H @"$HEADER_FILE" \
|
||||
STATUS="$(curl -sS -o /tmp/stoke-publish-response.$$ -w '%{http_code}' \
|
||||
-X PUT -H "Authorization: token $TOKEN" \
|
||||
--upload-file "$DEB" "$URL")"
|
||||
|
||||
case "$STATUS" in
|
||||
|
|
@ -65,7 +44,9 @@ case "$STATUS" in
|
|||
409) echo "Already published (409): this exact version already exists in the registry." ;;
|
||||
*)
|
||||
echo "error: upload failed with HTTP $STATUS" >&2
|
||||
cat "$RESPONSE_FILE" >&2 || true
|
||||
cat /tmp/stoke-publish-response.$$ >&2 || true
|
||||
rm -f /tmp/stoke-publish-response.$$
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
rm -f /tmp/stoke-publish-response.$$
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
#!/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
|
||||
56
src/cli.js
56
src/cli.js
|
|
@ -295,8 +295,6 @@ auth
|
|||
} else {
|
||||
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||
}
|
||||
} else if (!config.tokenId && !options.localOnly) {
|
||||
console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||
}
|
||||
|
||||
clearConfig();
|
||||
|
|
@ -315,12 +313,8 @@ auth
|
|||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.token) {
|
||||
if (options.json) {
|
||||
console.log('{"authenticated": false}');
|
||||
} else {
|
||||
console.log('Not authenticated.');
|
||||
}
|
||||
process.exit(1);
|
||||
console.log('Not authenticated.');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
|
|
@ -655,30 +649,30 @@ repo
|
|||
continue;
|
||||
}
|
||||
|
||||
const service = item.service || 'github';
|
||||
const isPrivate = item.public ? false : Boolean(item.private);
|
||||
const payload = {
|
||||
clone_addr: from,
|
||||
repo_name: name,
|
||||
repo_owner: item.owner || item.repo_owner || config.login,
|
||||
service,
|
||||
description: item.description || undefined,
|
||||
private: isPrivate,
|
||||
issues: normalizeBool(item.issues, true),
|
||||
labels: normalizeBool(item.labels, true),
|
||||
milestones: normalizeBool(item.milestones, true),
|
||||
pull_requests: normalizeBool(item.pull_requests, true),
|
||||
releases: normalizeBool(item.releases, true),
|
||||
wiki: normalizeBool(item.wiki, true),
|
||||
lfs: normalizeBool(item.lfs, false),
|
||||
auth_token: resolveSourceToken(item.github_token, service),
|
||||
};
|
||||
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (payload[key] === undefined) delete payload[key];
|
||||
});
|
||||
|
||||
try {
|
||||
const service = item.service || 'github';
|
||||
const isPrivate = item.public ? false : Boolean(item.private);
|
||||
const payload = {
|
||||
clone_addr: from,
|
||||
repo_name: name,
|
||||
repo_owner: item.owner || item.repo_owner || config.login,
|
||||
service,
|
||||
description: item.description || undefined,
|
||||
private: isPrivate,
|
||||
issues: normalizeBool(item.issues, true),
|
||||
labels: normalizeBool(item.labels, true),
|
||||
milestones: normalizeBool(item.milestones, true),
|
||||
pull_requests: normalizeBool(item.pull_requests, true),
|
||||
releases: normalizeBool(item.releases, true),
|
||||
wiki: normalizeBool(item.wiki, true),
|
||||
lfs: normalizeBool(item.lfs, false),
|
||||
auth_token: resolveSourceToken(item.github_token, service),
|
||||
};
|
||||
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (payload[key] === undefined) delete payload[key];
|
||||
});
|
||||
|
||||
const result = await client.migrateRepo(payload);
|
||||
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
|
||||
results.push({ name, status: 'ok', url: result.html_url });
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
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, '..');
|
||||
|
||||
function copyTree(source, destination) {
|
||||
fs.cpSync(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
function buildPackage(umask) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-build-deb-test-'));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(path.join(root, 'scripts'));
|
||||
fs.mkdirSync(bin);
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts', 'build-deb.sh'), path.join(root, 'scripts', 'build-deb.sh'));
|
||||
copyTree(path.join(ROOT, 'src'), path.join(root, 'src'));
|
||||
fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(root, 'package.json'));
|
||||
fs.copyFileSync(path.join(ROOT, 'package-lock.json'), path.join(root, 'package-lock.json'));
|
||||
|
||||
const npm = path.join(bin, 'npm');
|
||||
fs.writeFileSync(npm, '#!/usr/bin/env bash\nexit 0\n');
|
||||
fs.chmodSync(npm, 0o755);
|
||||
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
['-c', 'umask "$1"; exec bash "$2"', 'build-deb-test', umask, path.join(root, 'scripts', 'build-deb.sh')],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const deb = path.join(root, 'dist', 'stoke_1.5.0_all.deb');
|
||||
const listing = spawnSync('dpkg-deb', ['-c', deb], { encoding: 'utf8' });
|
||||
assert.equal(listing.status, 0, listing.stderr);
|
||||
|
||||
const modes = new Map();
|
||||
for (const line of listing.stdout.trim().split('\n')) {
|
||||
const fields = line.trim().split(/\s+/);
|
||||
const archivePath = fields.find((field) => field.startsWith('./usr/'));
|
||||
if (archivePath && (fields[0].startsWith('d') || fields[0].startsWith('-'))) {
|
||||
modes.set(archivePath, fields[0]);
|
||||
}
|
||||
}
|
||||
return { root, modes };
|
||||
}
|
||||
|
||||
test('Debian payload modes are identical under umask 077 and 022', (t) => {
|
||||
const restrictive = buildPackage('077');
|
||||
const standard = buildPackage('022');
|
||||
t.after(() => {
|
||||
fs.rmSync(restrictive.root, { recursive: true, force: true });
|
||||
fs.rmSync(standard.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
assert.deepEqual(restrictive.modes, standard.modes);
|
||||
for (const [archivePath, mode] of restrictive.modes) {
|
||||
assert.equal(mode, archivePath.endsWith('/') ? 'drwxr-xr-x' : archivePath === './usr/lib/stoke/src/cli.js' ? '-rwxr-xr-x' : '-rw-r--r--', archivePath);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
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('0.0.0');
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, /no section for '0\.0\.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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,59 +40,10 @@ test('global --config flag overrides the config location', () => {
|
|||
// "Not authenticated" instead of silently using the default config.
|
||||
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
|
||||
const res = run(['--config', missing, 'auth', 'status']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.equal(res.status, 0);
|
||||
assert.match(res.stdout, /Not authenticated/);
|
||||
});
|
||||
|
||||
test('auth status reports an absent session in text and JSON with a failing status', () => {
|
||||
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`);
|
||||
|
||||
const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing });
|
||||
assert.equal(text.status, 1);
|
||||
assert.equal(text.stdout, 'Not authenticated.\n');
|
||||
assert.equal(text.stderr, '');
|
||||
|
||||
const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing });
|
||||
assert.equal(json.status, 1);
|
||||
assert.equal(json.stdout, '{"authenticated": false}\n');
|
||||
assert.equal(json.stderr, '');
|
||||
});
|
||||
|
||||
test('auth logout identifies a supplied token that remains active without changing local-only output', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-'));
|
||||
const cfg = path.join(dir, 'config.json');
|
||||
const config = {
|
||||
url: 'https://forge.test',
|
||||
login: 'bot',
|
||||
username: 'bot',
|
||||
token: 'token-that-must-not-be-printed',
|
||||
tokenId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(cfg, JSON.stringify(config));
|
||||
const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(logout.status, 0, logout.stderr);
|
||||
assert.match(logout.stdout, /local credentials/i);
|
||||
assert.match(logout.stdout, /did not create this token/i);
|
||||
assert.match(logout.stdout, /cannot revoke it/i);
|
||||
assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i);
|
||||
assert.match(logout.stdout, /Settings > Applications/);
|
||||
assert.doesNotMatch(logout.stdout, /Revoked token/);
|
||||
assert.doesNotMatch(logout.stdout, /Password for/);
|
||||
assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/);
|
||||
assert.equal(fs.existsSync(cfg), false);
|
||||
|
||||
fs.writeFileSync(cfg, JSON.stringify(config));
|
||||
const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(localOnly.status, 0, localOnly.stderr);
|
||||
assert.equal(localOnly.stdout, 'Local credentials removed.\n');
|
||||
assert.equal(fs.existsSync(cfg), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid --limit is rejected before any network call', () => {
|
||||
const res = run(['repo', 'list', '-l', 'abc']);
|
||||
assert.equal(res.status, 1);
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
||||
|
||||
function run(args, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const childEnv = { ...process.env, ...env };
|
||||
childEnv.NODE_OPTIONS = [
|
||||
childEnv.NODE_OPTIONS,
|
||||
'--disable-warning=ExperimentalWarning',
|
||||
].filter(Boolean).join(' ');
|
||||
const child = spawn(process.execPath, [CLI, ...args], {
|
||||
env: childEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function startMigrationServer() {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
const payload = JSON.parse(body);
|
||||
requests.push({ method: req.method, url: req.url, body: payload });
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
full_name: `destination/${payload.repo_name}`,
|
||||
html_url: `https://forge.test/destination/${payload.repo_name}`,
|
||||
}));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, requests };
|
||||
}
|
||||
|
||||
test('repo import-batch continues after one item has no source token', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const emptyPath = path.join(dir, 'bin');
|
||||
const forgeToken = 'forge-token-must-not-be-printed';
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.mkdirSync(emptyPath);
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: forgeToken,
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([
|
||||
{ name: 'missing-token', from: 'https://github.com/source/first.git', service: 'github' },
|
||||
{ name: 'imported-second', from: 'https://git.example/source/second.git', service: 'git' },
|
||||
]));
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
['--config', configFile, 'repo', 'import-batch', '--file', manifestFile],
|
||||
{ PATH: emptyPath, GITHUB_TOKEN: undefined },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Failed to import missing-token: No GitHub token found\./);
|
||||
assert.equal(result.stdout,
|
||||
'Imported: destination/imported-second -> https://forge.test/destination/imported-second\n'
|
||||
+ '\nBatch complete: 1/2 imported.\n');
|
||||
assert.deepEqual(requests, [{
|
||||
method: 'POST',
|
||||
url: '/api/v1/repos/migrate',
|
||||
body: {
|
||||
clone_addr: 'https://git.example/source/second.git',
|
||||
repo_name: 'imported-second',
|
||||
repo_owner: 'destination',
|
||||
service: 'git',
|
||||
private: false,
|
||||
issues: true,
|
||||
labels: true,
|
||||
milestones: true,
|
||||
pull_requests: true,
|
||||
releases: true,
|
||||
wiki: true,
|
||||
lfs: false,
|
||||
},
|
||||
}]);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(forgeToken));
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch preserves successful batch output', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-success-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([
|
||||
{ name: 'first', from: 'https://git.example/source/first.git', service: 'git' },
|
||||
{ name: 'second', from: 'https://git.example/source/second.git', service: 'git' },
|
||||
]));
|
||||
|
||||
try {
|
||||
const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout,
|
||||
'Imported: destination/first -> https://forge.test/destination/first\n'
|
||||
+ 'Imported: destination/second -> https://forge.test/destination/second\n'
|
||||
+ '\nBatch complete: 2/2 imported.\n');
|
||||
assert.deepEqual(requests.map(({ body }) => body.repo_name), ['first', 'second']);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch sends an explicit GitHub token without printing it', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-token-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const emptyPath = path.join(dir, 'bin');
|
||||
const sourceToken = 'github-token-must-not-be-printed';
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.mkdirSync(emptyPath);
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([{
|
||||
name: 'from-github',
|
||||
from: 'https://github.com/source/repository.git',
|
||||
service: 'github',
|
||||
github_token: sourceToken,
|
||||
}]));
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
['--config', configFile, 'repo', 'import-batch', '--file', manifestFile],
|
||||
{ PATH: emptyPath, GITHUB_TOKEN: undefined },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout,
|
||||
'Imported: destination/from-github -> https://forge.test/destination/from-github\n'
|
||||
+ '\nBatch complete: 1/1 imported.\n');
|
||||
assert.deepEqual(requests, [{
|
||||
method: 'POST',
|
||||
url: '/api/v1/repos/migrate',
|
||||
body: {
|
||||
clone_addr: 'https://github.com/source/repository.git',
|
||||
repo_name: 'from-github',
|
||||
repo_owner: 'destination',
|
||||
service: 'github',
|
||||
private: false,
|
||||
issues: true,
|
||||
labels: true,
|
||||
milestones: true,
|
||||
pull_requests: true,
|
||||
releases: true,
|
||||
wiki: true,
|
||||
lfs: false,
|
||||
auth_token: sourceToken,
|
||||
},
|
||||
}]);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(sourceToken));
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch keeps file and JSON errors at batch level', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-invalid-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const missingFile = path.join(dir, 'missing.json');
|
||||
const malformedFile = path.join(dir, 'malformed.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: 'https://forge.test',
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(malformedFile, '{not json');
|
||||
|
||||
try {
|
||||
const missing = await run(['--config', configFile, 'repo', 'import-batch', '--file', missingFile]);
|
||||
const malformed = await run(['--config', configFile, 'repo', 'import-batch', '--file', malformedFile]);
|
||||
|
||||
assert.equal(missing.status, 1);
|
||||
assert.match(missing.stderr, /^Batch import failed: ENOENT:/);
|
||||
assert.equal(missing.stdout, '');
|
||||
assert.equal(malformed.status, 1);
|
||||
assert.match(malformed.stderr, /^Batch import failed: /);
|
||||
assert.match(malformed.stderr, /JSON/);
|
||||
assert.equal(malformed.stdout, '');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('repo import-batch excludes skipped invalid entries from the summary', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-skip-'));
|
||||
const configFile = path.join(dir, 'config.json');
|
||||
const manifestFile = path.join(dir, 'manifest.json');
|
||||
const { server, requests } = await startMigrationServer();
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
login: 'destination',
|
||||
token: 'forge-token-must-not-be-printed',
|
||||
}));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify([
|
||||
{ name: 'missing-source' },
|
||||
{ name: 'valid', from: 'https://git.example/source/valid.git', service: 'git' },
|
||||
]));
|
||||
|
||||
try {
|
||||
const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, 'Skipping invalid manifest entry: {"name":"missing-source"}\n');
|
||||
assert.match(result.stdout, /Batch complete: 1\/1 imported\./);
|
||||
assert.deepEqual(requests.map(({ body }) => body.repo_name), ['valid']);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
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-deb.sh');
|
||||
const TOKEN = 'deb-token-that-must-not-enter-argv';
|
||||
const UPLOAD_LINE = 'Uploading stoke_2.0.0_all.deb to https://forge.example.test/api/packages/heavy-duty/debian/pool/stable/main/upload\n';
|
||||
|
||||
function runScenario({ token = '', httpStatus = 201, responseBody = '', curlStatus = 0 } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-publish-deb-test-'));
|
||||
const home = path.join(dir, 'home');
|
||||
const bin = path.join(dir, 'bin');
|
||||
const runnerTemp = path.join(dir, 'runner-temp');
|
||||
const log = path.join(dir, 'curl.json');
|
||||
const deb = path.join(dir, 'stoke_2.0.0_all.deb');
|
||||
const legacyBefore = new Set(fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('stoke-publish-response.')));
|
||||
let call = null;
|
||||
let result;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(home);
|
||||
fs.mkdirSync(bin);
|
||||
fs.mkdirSync(runnerTemp);
|
||||
fs.writeFileSync(deb, 'package');
|
||||
fs.writeFileSync(path.join(bin, 'curl'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
const headerArg = args[args.indexOf('-H') + 1];
|
||||
const headerFile = headerArg && headerArg.startsWith('@') ? headerArg.slice(1) : null;
|
||||
const responseFile = args[args.indexOf('-o') + 1];
|
||||
const record = { args, headerFile, responseFile };
|
||||
if (headerFile) {
|
||||
record.header = fs.readFileSync(headerFile, 'utf8');
|
||||
record.headerMode = fs.statSync(headerFile).mode & 0o777;
|
||||
record.tempDir = path.dirname(headerFile);
|
||||
}
|
||||
fs.writeFileSync(responseFile, process.env.CURL_RESPONSE_BODY);
|
||||
fs.writeFileSync(process.env.CURL_CALL_LOG, JSON.stringify(record));
|
||||
if (Number(process.env.CURL_STATUS)) process.exit(Number(process.env.CURL_STATUS));
|
||||
process.stdout.write(process.env.CURL_HTTP_STATUS);
|
||||
`);
|
||||
fs.chmodSync(path.join(bin, 'curl'), 0o755);
|
||||
|
||||
result = spawnSync('bash', [SCRIPT, deb], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
HOME: home,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
RUNNER_TEMP: runnerTemp,
|
||||
STOKE_CONFIG_FILE: path.join(dir, 'missing-config.json'),
|
||||
STOKE_TOKEN: token,
|
||||
FORGE_URL: 'https://forge.example.test',
|
||||
CURL_CALL_LOG: log,
|
||||
CURL_HTTP_STATUS: String(httpStatus),
|
||||
CURL_RESPONSE_BODY: responseBody,
|
||||
CURL_STATUS: String(curlStatus),
|
||||
},
|
||||
});
|
||||
call = fs.existsSync(log) ? JSON.parse(fs.readFileSync(log, 'utf8')) : null;
|
||||
const remainingTempEntries = fs.readdirSync(runnerTemp);
|
||||
const legacyAfter = fs.readdirSync(os.tmpdir()).filter(
|
||||
(name) => name.startsWith('stoke-publish-response.') && !legacyBefore.has(name),
|
||||
);
|
||||
|
||||
return {
|
||||
result,
|
||||
call,
|
||||
runnerTemp,
|
||||
remainingTempEntries,
|
||||
legacyAfter,
|
||||
headerExistsAfter: call?.headerFile ? fs.existsSync(call.headerFile) : false,
|
||||
responseExistsAfter: call?.responseFile ? fs.existsSync(call.responseFile) : false,
|
||||
};
|
||||
} finally {
|
||||
if (call?.responseFile && !call.responseFile.startsWith(`${dir}${path.sep}`)) {
|
||||
fs.rmSync(call.responseFile, { force: true });
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function assertCleaned(scenario) {
|
||||
assert.deepEqual(scenario.remainingTempEntries, []);
|
||||
assert.deepEqual(scenario.legacyAfter, []);
|
||||
assert.equal(scenario.headerExistsAfter, false);
|
||||
assert.equal(scenario.responseExistsAfter, false);
|
||||
}
|
||||
|
||||
test('empty token identifies the CI secret before offering the local remedy', () => {
|
||||
const scenario = runScenario();
|
||||
|
||||
assert.equal(scenario.result.status, 1);
|
||||
assert.equal(scenario.result.stdout, '');
|
||||
assert.match(scenario.result.stderr, /^error: no token\./);
|
||||
assert.match(scenario.result.stderr, /STOKE_TOKEN/);
|
||||
assert.match(scenario.result.stderr, /RELEASE_TOKEN/);
|
||||
assert.match(scenario.result.stderr, /empty value.*secret/is);
|
||||
assert.ok(scenario.result.stderr.indexOf('RELEASE_TOKEN') < scenario.result.stderr.indexOf('stoke auth login'));
|
||||
assert.equal(scenario.call, null);
|
||||
assertCleaned(scenario);
|
||||
});
|
||||
|
||||
test('curl reads a private authorization header file without receiving the token in argv', () => {
|
||||
const scenario = runScenario({ token: TOKEN });
|
||||
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr);
|
||||
assert.ok(scenario.call.args.includes('-H'));
|
||||
assert.equal(scenario.call.args.every((arg) => !arg.includes(TOKEN)), true);
|
||||
assert.equal(path.dirname(scenario.call.tempDir), scenario.runnerTemp);
|
||||
assert.ok(scenario.call.headerFile.startsWith(`${scenario.call.tempDir}${path.sep}`));
|
||||
assert.equal(scenario.call.header, `Authorization: token ${TOKEN}\n`);
|
||||
assert.equal(scenario.call.headerMode, 0o600);
|
||||
assert.equal(path.dirname(scenario.call.responseFile), scenario.call.tempDir);
|
||||
assert.doesNotMatch(scenario.result.stdout, new RegExp(TOKEN));
|
||||
assert.doesNotMatch(scenario.result.stderr, new RegExp(TOKEN));
|
||||
assertCleaned(scenario);
|
||||
});
|
||||
|
||||
test('201 response preserves the success transcript and removes temporary files', () => {
|
||||
const scenario = runScenario({ token: TOKEN, httpStatus: 201 });
|
||||
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr);
|
||||
assert.equal(scenario.result.stdout, `${UPLOAD_LINE}Published.\n`);
|
||||
assert.equal(scenario.result.stderr, '');
|
||||
assertCleaned(scenario);
|
||||
});
|
||||
|
||||
test('409 response preserves the already-published transcript and removes temporary files', () => {
|
||||
const scenario = runScenario({ token: TOKEN, httpStatus: 409 });
|
||||
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr);
|
||||
assert.equal(scenario.result.stdout, `${UPLOAD_LINE}Already published (409): this exact version already exists in the registry.\n`);
|
||||
assert.equal(scenario.result.stderr, '');
|
||||
assertCleaned(scenario);
|
||||
});
|
||||
|
||||
test('HTTP failure preserves the response body on stderr and removes temporary files', () => {
|
||||
const scenario = runScenario({ token: TOKEN, httpStatus: 500, responseBody: 'registry rejected\n' });
|
||||
|
||||
assert.equal(scenario.result.status, 1);
|
||||
assert.equal(scenario.result.stdout, UPLOAD_LINE);
|
||||
assert.equal(scenario.result.stderr, 'error: upload failed with HTTP 500\nregistry rejected\n');
|
||||
assertCleaned(scenario);
|
||||
});
|
||||
|
||||
test('curl failure propagates its status and still removes temporary files', () => {
|
||||
const scenario = runScenario({ token: TOKEN, curlStatus: 7, responseBody: 'transport failed\n' });
|
||||
|
||||
assert.equal(scenario.result.status, 7);
|
||||
assert.equal(scenario.result.stdout, UPLOAD_LINE);
|
||||
assert.equal(scenario.result.stderr, '');
|
||||
assertCleaned(scenario);
|
||||
});
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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