Compare commits

...

25 commits
v1.5.0 ... main

Author SHA1 Message Date
92f2eb3949 Merge pull request 'fix: keep import-batch running after token failures' (#69) from build/65-import-batch-token-failure into main
All checks were successful
ci / test (push) Successful in 7m17s
Reviewed-on: #69
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>
2026-09-04 08:02:45 +00:00
codex-bot-andresmgsl
37e6a2ad5a test: cover explicit import token handling
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m17s
2026-09-04 07:31:26 +00:00
codex-bot-andresmgsl
82494e94fd test: support node engine floor
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 5m12s
2026-09-04 07:30:26 +00:00
33d583892e Merge pull request 'fix: normalize Debian payload modes' (#68) from build/63-normalize-deb-modes into main
All checks were successful
ci / test (push) Successful in 1m31s
Reviewed-on: #68
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>
2026-09-04 07:17:56 +00:00
codex-bot-andresmgsl
4333ce63bf docs: note import-batch token handling
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m18s
2026-09-04 07:00:17 +00:00
codex-bot-andresmgsl
5f2f58a249 test: cover import-batch result boundaries
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 2m6s
2026-09-04 06:59:58 +00:00
codex-bot-andresmgsl
1bb4bd608c fix: isolate import-batch token failures
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 20s
2026-09-04 06:58:57 +00:00
codex-bot-andresmgsl
721ba343cc test: reproduce import-batch token abort
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 18s
2026-09-04 06:58:02 +00:00
codex-bot-andresmgsl
44bbeadff6 docs: record deterministic Debian modes
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 2m38s
2026-09-04 06:28:51 +00:00
codex-bot-andresmgsl
cef903b77e fix: normalize Debian payload modes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 3m1s
2026-09-04 06:28:29 +00:00
codex-bot-andresmgsl
9404c09cae test: expose umask-dependent Debian modes
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 5m21s
2026-09-04 06:27:42 +00:00
74e52b4210 Merge pull request 'fix: keep Debian publish token out of curl argv' (#67) from build/62-publish-deb-token into main
All checks were successful
ci / test (push) Successful in 7m18s
Reviewed-on: #67
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 06:16:38 +00:00
c4110e7f7e Merge pull request 'fix: report unauthenticated auth state honestly' (#66) from build/64-auth-state into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: #66
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-04 06:16:20 +00:00
codex-bot-andresmgsl
4d17b8b113 docs: record private Debian publish headers
All checks were successful
labels / labels (pull_request) Successful in 13s
ci / test (pull_request) Successful in 2m10s
2026-09-04 02:56:37 +00:00
codex-bot-andresmgsl
dcb169e8ab fix: protect Debian publish credentials
All checks were successful
labels / labels (pull_request) Successful in 15s
ci / test (pull_request) Successful in 25s
2026-09-04 02:55:49 +00:00
codex-bot-andresmgsl
1dfa2c173d test: cover private deb publish credentials
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 1m8s
2026-09-04 02:54:40 +00:00
codex-bot-andresmgsl
1335232002 test: tolerate Node floor module warning
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 3m14s
2026-09-04 02:28:57 +00:00
codex-bot-andresmgsl
bf84b19a07 docs: record auth state fixes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 7m18s
2026-09-04 01:56:31 +00:00
codex-bot-andresmgsl
aedce42c56 fix: report unauthenticated auth state honestly
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 4m0s
2026-09-04 01:56:14 +00:00
codex-bot-andresmgsl
ed3f234b8e test: cover unauthenticated auth state
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 2m50s
2026-09-04 01:55:12 +00:00
2230ca2501 Merge pull request 'docs: correct release token scope contract' (#61) from build/60-release-token-scopes into main
All checks were successful
ci / test (push) Successful in 17s
Reviewed-on: #61
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>
2026-09-03 07:30:21 +00:00
bbde478157 Merge pull request 'fix: clarify publish-deb auth failure' (#59) from build/57-publish-deb-auth-message into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: #59
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-09-03 07:30:11 +00:00
codex-bot-andresmgsl
0edba09a8a docs: correct release token scopes
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 22:54:28 +00:00
codex-bot-andresmgsl
3068809b66 fix: clarify publish-deb auth failure
All checks were successful
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Successful in 17s
2026-09-02 22:39:32 +00:00
codex-bot-andresmgsl
56c8f00d8b test: cover publish-deb token guard
Some checks failed
labels / labels (pull_request) Successful in 11s
ci / test (pull_request) Failing after 16s
2026-09-02 22:38:38 +00:00
14 changed files with 592 additions and 39 deletions

View file

@ -5,9 +5,11 @@
# 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.
# - 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.
name: release
on:

1
changelog.d/57.md Normal file
View file

@ -0,0 +1 @@
- Clarified Debian publish authentication failures with the CI secret source and the local remedies. (#57).

1
changelog.d/60.md Normal file
View file

@ -0,0 +1 @@
- Correct the release workflow's documented token scopes to include the identity check required before publishing. (#60).

1
changelog.d/62.md Normal file
View file

@ -0,0 +1 @@
- Keep Debian registry tokens out of curl process arguments and clean upload credentials and responses on every exit. (#62).

1
changelog.d/63.md Normal file
View file

@ -0,0 +1 @@
- Normalize Debian package payload modes independently of the builder's umask. (#63).

1
changelog.d/64.md Normal file
View file

@ -0,0 +1 @@
- Report supplied tokens that remain active after logout and make unauthenticated status machine-detectable. (#64).

1
changelog.d/65.md Normal file
View file

@ -0,0 +1 @@
- Continue batch imports after one repository cannot resolve its source token, while reporting that item as failed. (#65).

View file

@ -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: no group/other
# write anywhere, executable entry point.
chmod -R go-w "$PKG/usr"
# 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"
chmod 0755 "$LIB/src/cli.js"
# --- control -----------------------------------------------------------------

View file

@ -9,7 +9,7 @@
# component APT component, default: main
#
# Authentication (first match wins):
# 1. STOKE_TOKEN environment variable
# 1. STOKE_TOKEN environment variable (set from secrets.RELEASE_TOKEN in CI)
# 2. The token stored by `stoke auth login`
#
# The Forgejo URL defaults to the instance in the stoke config, falling back
@ -30,13 +30,34 @@ 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")}"
[ -n "$TOKEN" ] || { echo "error: no token. Set STOKE_TOKEN or run: stoke auth login" >&2; exit 1; }
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"
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" \
STATUS="$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X PUT -H @"$HEADER_FILE" \
--upload-file "$DEB" "$URL")"
case "$STATUS" in
@ -44,9 +65,7 @@ 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 /tmp/stoke-publish-response.$$ >&2 || true
rm -f /tmp/stoke-publish-response.$$
cat "$RESPONSE_FILE" >&2 || true
exit 1
;;
esac
rm -f /tmp/stoke-publish-response.$$

View file

@ -295,6 +295,8 @@ 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();
@ -313,8 +315,12 @@ auth
try {
const config = loadConfig();
if (!config || !config.token) {
if (options.json) {
console.log('{"authenticated": false}');
} else {
console.log('Not authenticated.');
return;
}
process.exit(1);
}
const client = ForgejoClient.fromConfig(config);
@ -649,6 +655,7 @@ repo
continue;
}
try {
const service = item.service || 'github';
const isPrivate = item.public ? false : Boolean(item.private);
const payload = {
@ -672,7 +679,6 @@ repo
if (payload[key] === undefined) delete payload[key];
});
try {
const result = await client.migrateRepo(payload);
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
results.push({ name, status: 'ok', url: result.html_url });

65
test/build-deb.test.js Normal file
View file

@ -0,0 +1,65 @@
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);
}
});

View file

@ -40,10 +40,59 @@ 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, 0);
assert.equal(res.status, 1);
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);

248
test/import-batch.test.js Normal file
View file

@ -0,0 +1,248 @@
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 });
}
});

158
test/publish-deb.test.js Normal file
View file

@ -0,0 +1,158 @@
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);
});