Skip to content

Commit 1d90b85

Browse files
authored
Add Railway secrets provider (#721)
* Add Railway secrets provider * Update lockfile for Railway secrets provider * Redact Railway secret values on CLI failure
1 parent 22fd667 commit 1d90b85

7 files changed

Lines changed: 251 additions & 2 deletions

File tree

packages/cli/src/adapter-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ export const CATEGORIES: readonly AdapterCategory[] = [
9595
{
9696
id: 'secrets',
9797
pkgPrefix: '@profullstack/sh1pt-secrets',
98-
description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password',
99-
adapters: ['doppler', 'dotenvx', 'github', 'onepassword'],
98+
description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password, Railway',
99+
adapters: ['doppler', 'dotenvx', 'github', 'onepassword', 'railway'],
100100
},
101101
{
102102
id: 'security',

packages/secrets/railway/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Railway Secrets
2+
3+
Provides the Railway service variables module for sh1pt.
4+
5+
## What it does
6+
7+
- Lists Railway service variables with `railway variable list --json`.
8+
- Pushes variable values with `railway variable set` without logging secret values.
9+
- Supports optional Railway service and environment scopes.
10+
- Supports `--skip-deploys` for staged secret updates.
11+
12+
## Package
13+
14+
- Name: `@profullstack/sh1pt-secrets-railway`
15+
- Path: `packages/secrets/railway`
16+
- Adapter ID: `secrets-railway`
17+
- Homepage: https://sh1pt.com
18+
19+
## Scripts
20+
21+
- `build`: `tsc -p tsconfig.json`
22+
- `prepublishOnly`: `pnpm build`
23+
- `typecheck`: `tsc -p tsconfig.json --noEmit`
24+
25+
## Usage
26+
27+
```bash
28+
pnpm add @profullstack/sh1pt-secrets-railway
29+
```
30+
31+
## Development
32+
33+
```bash
34+
pnpm --filter @profullstack/sh1pt-secrets-railway typecheck
35+
pnpm vitest run packages/secrets/railway/src/index.test.ts
36+
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@profullstack/sh1pt-secrets-railway",
3+
"version": "0.1.15",
4+
"type": "module",
5+
"main": "./src/index.ts",
6+
"scripts": {
7+
"build": "tsc -p tsconfig.json",
8+
"typecheck": "tsc -p tsconfig.json --noEmit",
9+
"prepublishOnly": "pnpm build"
10+
},
11+
"dependencies": {
12+
"@profullstack/sh1pt-core": "workspace:*"
13+
},
14+
"license": "MIT",
15+
"repository": {
16+
"type": "git",
17+
"url": "git+https://github.com/profullstack/sh1pt.git",
18+
"directory": "packages/secrets/railway"
19+
},
20+
"homepage": "https://sh1pt.com",
21+
"bugs": "https://github.com/profullstack/sh1pt/issues",
22+
"files": [
23+
"dist"
24+
],
25+
"publishConfig": {
26+
"access": "public",
27+
"main": "./dist/index.js",
28+
"types": "./dist/index.d.ts",
29+
"exports": {
30+
".": {
31+
"types": "./dist/index.d.ts",
32+
"import": "./dist/index.js",
33+
"default": "./dist/index.js"
34+
}
35+
}
36+
}
37+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { execMock } = vi.hoisted(() => ({
5+
execMock: vi.fn(),
6+
}));
7+
8+
vi.mock('@profullstack/sh1pt-core', async () => ({
9+
...await vi.importActual<typeof import('@profullstack/sh1pt-core')>('@profullstack/sh1pt-core'),
10+
exec: execMock,
11+
}));
12+
13+
import adapter from './index.js';
14+
15+
smokeTest(adapter, { idPrefix: 'secrets' });
16+
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
});
20+
21+
describe('Railway secrets provider', () => {
22+
it('redacts secret values from Railway CLI failure messages', async () => {
23+
execMock.mockRejectedValue(new Error('railway variable set API_TOKEN=super-secret failed (exit 1): invalid value'));
24+
25+
let thrown: unknown;
26+
try {
27+
await adapter.push({ secret: () => undefined, log: () => {} }, [
28+
{ key: 'API_TOKEN', value: 'super-secret' },
29+
], {});
30+
} catch (error) {
31+
thrown = error;
32+
}
33+
34+
expect(thrown).toBeInstanceOf(Error);
35+
expect((thrown as Error).message).toContain('API_TOKEN=<redacted>');
36+
expect((thrown as Error).message).not.toContain('super-secret');
37+
});
38+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { defineSecretProvider, exec, manualSetup, type SecretRef } from '@profullstack/sh1pt-core';
2+
3+
interface Config {
4+
service?: string;
5+
environment?: string;
6+
skipDeploys?: boolean;
7+
}
8+
9+
interface RailwayVariableEntry {
10+
name?: string;
11+
key?: string;
12+
value?: string;
13+
}
14+
15+
function scopedArgs(config: Config): string[] {
16+
const args: string[] = [];
17+
const service = config.service?.trim();
18+
const environment = config.environment?.trim();
19+
if (service) args.push('--service', service);
20+
if (environment) args.push('--environment', environment);
21+
return args;
22+
}
23+
24+
function parseVariables(stdout: string): SecretRef[] {
25+
const body = stdout.trim();
26+
if (!body) return [];
27+
28+
let parsed: unknown;
29+
try {
30+
parsed = JSON.parse(body);
31+
} catch (error) {
32+
throw new Error('Unable to parse `railway variable list --json` output as JSON. Run `railway login` or set RAILWAY_TOKEN and retry.', {
33+
cause: error,
34+
});
35+
}
36+
37+
if (Array.isArray(parsed)) {
38+
return parsed.flatMap((entry) => {
39+
if (!entry || typeof entry !== 'object') return [];
40+
const variable = entry as RailwayVariableEntry;
41+
const key = variable.name ?? variable.key;
42+
return key ? [{ key, value: variable.value }] : [];
43+
});
44+
}
45+
46+
if (parsed && typeof parsed === 'object') {
47+
return Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
48+
key,
49+
value: typeof value === 'string' ? value : undefined,
50+
}));
51+
}
52+
53+
throw new Error('Expected `railway variable list --json` to return an object or array.');
54+
}
55+
56+
function assertSecretKey(key: string): string {
57+
const normalized = key.trim();
58+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) {
59+
throw new Error(`Railway variable key must be an environment-style name: ${key}`);
60+
}
61+
return normalized;
62+
}
63+
64+
function redactSecretArgError(error: unknown, key: string, value: string): Error {
65+
const leakedArg = `${key}=${value}`;
66+
const redactedArg = `${key}=<redacted>`;
67+
68+
if (error instanceof Error) {
69+
return new Error(error.message.split(leakedArg).join(redactedArg));
70+
}
71+
72+
return new Error(`railway variable set ${redactedArg} failed`);
73+
}
74+
75+
export default defineSecretProvider<Config>({
76+
id: 'secrets-railway',
77+
label: 'Railway Variables',
78+
cli: 'railway',
79+
async connect(ctx, config) {
80+
const scope = [
81+
config.service?.trim() ? `service=${config.service.trim()}` : undefined,
82+
config.environment?.trim() ? `environment=${config.environment.trim()}` : undefined,
83+
].filter(Boolean).join(' · ') || 'linked project';
84+
ctx.log(`railway whoami · scope=${scope}`);
85+
await exec('railway', ['whoami'], { log: (message) => ctx.log(message), throwOnNonZero: true });
86+
return { accountId: scope };
87+
},
88+
async pull(ctx, config): Promise<SecretRef[]> {
89+
const args = ['variable', 'list', '--json', ...scopedArgs(config)];
90+
ctx.log(`railway ${args.join(' ')}`);
91+
const result = await exec('railway', args, { log: (message) => ctx.log(message), throwOnNonZero: true });
92+
return parseVariables(result.stdout);
93+
},
94+
async push(ctx, secrets, config) {
95+
const commonArgs = ['variable', 'set', ...scopedArgs(config)];
96+
if (config.skipDeploys) commonArgs.push('--skip-deploys');
97+
98+
for (const secret of secrets) {
99+
const key = assertSecretKey(secret.key);
100+
const value = secret.value ?? ctx.secret(key);
101+
if (value === undefined) {
102+
throw new Error(`No value provided for Railway variable ${key}`);
103+
}
104+
ctx.log(`railway ${commonArgs.join(' ')} ${key}=<redacted>`);
105+
try {
106+
await exec('railway', [...commonArgs, `${key}=${value}`], {
107+
log: (message) => ctx.log(message),
108+
throwOnNonZero: true,
109+
});
110+
} catch (error) {
111+
throw redactSecretArgError(error, key, value);
112+
}
113+
}
114+
115+
return { count: secrets.length };
116+
},
117+
setup: manualSetup({
118+
label: 'Railway CLI',
119+
vendorDocUrl: 'https://docs.railway.com/cli/variable',
120+
steps: [
121+
'Install Railway CLI from the official docs',
122+
'Authenticate locally: railway login',
123+
'For CI/service use, set RAILWAY_TOKEN or RAILWAY_API_TOKEN',
124+
'Link the project with railway link or configure service/environment in sh1pt',
125+
],
126+
}),
127+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "../../../tsconfig.base.json",
3+
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
4+
"include": ["src/**/*"]
5+
}

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)