fix: harden release artifact checks and fixture isolation
This commit is contained in:
@@ -26,3 +26,15 @@ jobs:
|
|||||||
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check
|
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check
|
||||||
- name: Isolated fixture smoke
|
- name: Isolated fixture smoke
|
||||||
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture
|
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture
|
||||||
|
|
||||||
|
container:
|
||||||
|
# Requires a runner with Docker/Compose. This job is prepared but remains
|
||||||
|
# unverified until Gitea reports an actual compatible runner.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Validate Compose expansion
|
||||||
|
run: docker compose config --quiet
|
||||||
|
- name: Build immutable web artifact
|
||||||
|
run: docker build --build-arg APP_RELEASE_ID=fixture --tag eve-intelligence:fixture .
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM oven/bun:1.3.14 AS build
|
FROM oven/bun@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
# Infisical is a host-side operator tool; its binary installer is unnecessary in the image.
|
# Infisical is a host-side operator tool; its binary installer is unnecessary in the image.
|
||||||
@@ -6,7 +6,7 @@ RUN bun install --frozen-lockfile --ignore-scripts
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM oven/bun:1.3.14 AS runtime
|
FROM oven/bun@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS runtime
|
||||||
ARG APP_RELEASE_ID=local
|
ARG APP_RELEASE_ID=local
|
||||||
# Production reference imports stream XZ through the OS decoder; no archive extraction shell.
|
# Production reference imports stream XZ through the OS decoder; no archive extraction shell.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
+15
-10
@@ -1,10 +1,11 @@
|
|||||||
# Release and rollout safety (T01)
|
# Release and rollout safety (T01)
|
||||||
|
|
||||||
This repository uses Gitea Actions for CI. The workflow in `.gitea/workflows/ci.yml`
|
This repository uses Gitea Actions for CI. The workflow in `.gitea/workflows/ci.yml`
|
||||||
only installs the frozen lockfile, runs local tests/check/build, compiles the
|
installs the frozen lockfile, runs local tests/check/build, compiles the
|
||||||
checked-in synthetic reference fixture, and runs an isolated demo web smoke.
|
checked-in synthetic reference fixture, runs an isolated demo web smoke, and
|
||||||
It does not receive deployment credentials, trigger a remote deployment, or
|
prepares a separate Docker/Compose artifact job. It does not receive
|
||||||
write a persistent Convex backend.
|
deployment credentials, trigger a remote deployment, or write a persistent
|
||||||
|
Convex backend.
|
||||||
|
|
||||||
## Server-side rollout gate
|
## Server-side rollout gate
|
||||||
|
|
||||||
@@ -45,10 +46,13 @@ closed until they have reviewed enqueue and worker entry points.
|
|||||||
non-secret `x-application-release` response header. Local/fixture runs use
|
non-secret `x-application-release` response header. Local/fixture runs use
|
||||||
`local` or `fixture`. Staging and production must provide a non-placeholder
|
`local` or `fixture`. Staging and production must provide a non-placeholder
|
||||||
identifier (normally the source commit or an operator-approved image digest).
|
identifier (normally the source commit or an operator-approved image digest).
|
||||||
`bun run release:check` rejects placeholder staging/production identifiers,
|
`bun run release:check` requires an explicit `RELEASE_ENV` for live runs and
|
||||||
unpinned staging/production dashboard images, demo mode in staging/production,
|
rejects placeholder staging/production identifiers, unpinned staging/production
|
||||||
invalid capability names, and tracked secret-like files. The Dockerfile accepts
|
backend/dashboard images or Dockerfile bases, demo mode in staging/production,
|
||||||
`--build-arg APP_RELEASE_ID=...`; Compose passes the runtime value explicitly.
|
invalid capability names, tracked secret-like files, and managed secret names
|
||||||
|
in built browser artifacts. The Dockerfile uses digest-pinned Bun bases and
|
||||||
|
accepts `--build-arg APP_RELEASE_ID=...`; Compose passes the runtime value
|
||||||
|
explicitly.
|
||||||
|
|
||||||
## Isolated local validation
|
## Isolated local validation
|
||||||
|
|
||||||
@@ -76,8 +80,9 @@ persistent deployment.
|
|||||||
## Evidence boundaries
|
## Evidence boundaries
|
||||||
|
|
||||||
Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs
|
Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs
|
||||||
for `jb/eden`; runner labels and action availability are therefore not
|
for `jb/eden`; runner labels, Docker availability, and action availability are
|
||||||
validated. The workflow is prepared locally but has not run remotely.
|
therefore not validated. The workflow is prepared locally but has not run
|
||||||
|
remotely.
|
||||||
No browser automation, OAuth callback failure injection, Convex migration
|
No browser automation, OAuth callback failure injection, Convex migration
|
||||||
rollback, instance-credential backup/restore, key rotation, or live
|
rollback, instance-credential backup/restore, key rotation, or live
|
||||||
revocation/transfer evidence is claimed by this lane. Those remain explicit
|
revocation/transfer evidence is claimed by this lane. Those remain explicit
|
||||||
|
|||||||
+2
-2
@@ -103,7 +103,7 @@ Keep valuation methods distinct. Never quietly replace an average price with an
|
|||||||
- Add isolated CI for tests/typecheck/build, deployed-artifact checks, migrations and browser flows.
|
- Add isolated CI for tests/typecheck/build, deployed-artifact checks, migrations and browser flows.
|
||||||
- Add durable browser regression coverage for native POST forms, OAuth redirect/cookie paths, singleton/array scopes, linking, logout and pending-request resume. Use synthetic providers/users only in isolated test deployments, never the persistent live backend.
|
- Add durable browser regression coverage for native POST forms, OAuth redirect/cookie paths, singleton/array scopes, linking, logout and pending-request resume. Use synthetic providers/users only in isolated test deployments, never the persistent live backend.
|
||||||
- Version application images; keep backwards-compatible migrations and an explicit rollback procedure.
|
- Version application images; keep backwards-compatible migrations and an explicit rollback procedure.
|
||||||
- Define and implement server-side rollout gating; it does not exist today. A deployment configuration/capability allowlist may suffice initially, but the mechanism needs approval and tests. Gates must protect routes, assistant dispatch, jobs and subscriptions—not merely hide UI controls.
|
- Define and implement server-side rollout gating. T01 provides an approved deployment configuration/capability allowlist with tests; it protects current routes, assistant dispatch, sync entry points and subscriptions. Durable jobs and any new capability still require their own reviewed entry points. Gates must protect server behavior, not merely hide UI controls.
|
||||||
|
|
||||||
**M0.2 — Dataset capabilities and incremental consent**
|
**M0.2 — Dataset capabilities and incremental consent**
|
||||||
- Extend the model/gate/store contracts for dataset-specific scopes and freshness.
|
- Extend the model/gate/store contracts for dataset-specific scopes and freshness.
|
||||||
@@ -357,4 +357,4 @@ After the foundations, work items 4–6 (inventory) and 7 (wallet balances) can
|
|||||||
- Unit/property tests for arithmetic and invariants; isolated integration, native-browser and adversarial tenant tests for changed boundaries.
|
- Unit/property tests for arithmetic and invariants; isolated integration, native-browser and adversarial tenant tests for changed boundaries.
|
||||||
- Bounded resource use, safe errors, appropriate logs/metrics, retention/deletion behavior and rollback.
|
- Bounded resource use, safe errors, appropriate logs/metrics, retention/deletion behavior and rollback.
|
||||||
- No real-user write smoke against the persistent deployment; consented live validation is recorded separately from mocks.
|
- No real-user write smoke against the persistent deployment; consented live validation is recorded separately from mocks.
|
||||||
- The server-side rollout/config gate implemented in M0 remains closed to other users until the slice's acceptance gate is met. It is a planned mechanism, not an existing feature. Public signup remains a separate, explicit operator decision.
|
- The server-side rollout/config gate implemented in T01 remains closed to other users until the slice's acceptance gate is met. It is an operator-controlled mechanism, not a public-signup decision. Public signup remains separate and explicitly gated.
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const port = Number(process.env.SMOKE_WEB_PORT ?? 15173);
|
async function freePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const probe = createServer();
|
||||||
|
probe.once('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = probe.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
probe.close((error) => error ? reject(error) : resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredPort = process.env.SMOKE_WEB_PORT;
|
||||||
|
const port = configuredPort === undefined ? await freePort() : Number(configuredPort);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('SMOKE_WEB_PORT must be a valid TCP port');
|
||||||
const base = `http://127.0.0.1:${port}`;
|
const base = `http://127.0.0.1:${port}`;
|
||||||
const root = await mkdtemp(join(tmpdir(), 'eden-fixture-'));
|
const root = await mkdtemp(join(tmpdir(), 'eden-fixture-'));
|
||||||
let server: Bun.Subprocess | undefined;
|
let server: Bun.Subprocess | undefined;
|
||||||
|
// Never pass ambient vault/session/provider variables into repository-controlled
|
||||||
|
// fixture processes. The fixture only needs a minimal, non-secret environment.
|
||||||
|
const safeEnv = {
|
||||||
|
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||||
|
HOME: process.env.HOME ?? '/tmp',
|
||||||
|
TMPDIR: process.env.TMPDIR ?? '/tmp',
|
||||||
|
};
|
||||||
|
|
||||||
async function compileFixture(directory: string): Promise<void> {
|
async function compileFixture(directory: string): Promise<void> {
|
||||||
const child = Bun.spawn(['bun', 'workers/reference.ts', '--fixture', '--directory', directory], {
|
const child = Bun.spawn(['bun', 'workers/reference.ts', '--fixture', '--directory', directory], {
|
||||||
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||||
env: { ...process.env, APP_MODE: 'demo', REFERENCE_DIR: directory },
|
env: { ...safeEnv, APP_MODE: 'demo', REFERENCE_DIR: directory },
|
||||||
});
|
});
|
||||||
const code = await child.exited;
|
const code = await child.exited;
|
||||||
if (code !== 0) throw new Error('Fixture reference compilation failed');
|
if (code !== 0) throw new Error('Fixture reference compilation failed');
|
||||||
@@ -21,7 +43,7 @@ async function waitForServer(): Promise<void> {
|
|||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(base);
|
const response = await fetch(base);
|
||||||
if (response.status === 200) return;
|
if (response.status === 200 && response.headers.get('x-application-release') === 'fixture') return;
|
||||||
} catch { /* startup race */ }
|
} catch { /* startup race */ }
|
||||||
await Bun.sleep(250);
|
await Bun.sleep(250);
|
||||||
}
|
}
|
||||||
@@ -33,10 +55,11 @@ try {
|
|||||||
server = Bun.spawn(['bun', 'build/index.js'], {
|
server = Bun.spawn(['bun', 'build/index.js'], {
|
||||||
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...safeEnv,
|
||||||
APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base,
|
APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base,
|
||||||
HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root,
|
HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root,
|
||||||
APP_RELEASE_ID: 'fixture',
|
APP_RELEASE_ID: 'fixture',
|
||||||
|
APP_CAPABILITY_ALLOWLIST: 'asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await waitForServer();
|
await waitForServer();
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
|
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
|
||||||
|
|
||||||
|
const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']);
|
||||||
|
const DIGEST_IMAGE = /@sha256:[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
type ReleaseCheckInput = {
|
type ReleaseCheckInput = {
|
||||||
mode: string;
|
mode: string;
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
releaseEnvironment: string;
|
releaseEnvironment: string;
|
||||||
dashboardImage: string;
|
dashboardImage: string;
|
||||||
|
convexImage: string;
|
||||||
|
dockerfile: string;
|
||||||
capabilityAllowlist: string | undefined;
|
capabilityAllowlist: string | undefined;
|
||||||
trackedFiles: readonly string[];
|
trackedFiles: readonly string[];
|
||||||
|
browserBundleFiles?: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
|
export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
try { parseCapabilityAllowlist(input.capabilityAllowlist); }
|
try { parseCapabilityAllowlist(input.capabilityAllowlist); }
|
||||||
catch (error) { errors.push(error instanceof Error ? error.message : 'Invalid capability allowlist'); }
|
catch (error) { errors.push(error instanceof Error ? error.message : 'Invalid capability allowlist'); }
|
||||||
|
if (!RELEASE_ENVIRONMENTS.has(input.releaseEnvironment)) {
|
||||||
|
errors.push('RELEASE_ENV must be explicitly set to fixture, development, staging, or production');
|
||||||
|
}
|
||||||
|
|
||||||
const strictEnvironment = input.releaseEnvironment === 'production' || input.releaseEnvironment === 'staging';
|
const strictEnvironment = input.releaseEnvironment === 'production' || input.releaseEnvironment === 'staging';
|
||||||
if (!releaseIdIsImmutable(input.releaseId, strictEnvironment ? 'production' : 'development')) {
|
if (!releaseIdIsImmutable(input.releaseId, strictEnvironment ? 'production' : 'development')) {
|
||||||
errors.push('APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment');
|
errors.push('APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment');
|
||||||
}
|
}
|
||||||
if (strictEnvironment && !input.dashboardImage.includes('@sha256:')) {
|
if (strictEnvironment && !DIGEST_IMAGE.test(input.dashboardImage)) {
|
||||||
errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production');
|
errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production');
|
||||||
}
|
}
|
||||||
|
if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) {
|
||||||
|
errors.push('CONVEX_IMAGE must be digest-pinned for staging/production');
|
||||||
|
}
|
||||||
|
if (strictEnvironment) {
|
||||||
|
const baseImages = [...input.dockerfile.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+[^\s]+)?$/gim)].map((match) => match[1]);
|
||||||
|
if (baseImages.length === 0 || baseImages.some((image) => !DIGEST_IMAGE.test(image))) {
|
||||||
|
errors.push('Dockerfile base images must be digest-pinned for staging/production');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (input.mode === 'demo' && strictEnvironment) {
|
if (input.mode === 'demo' && strictEnvironment) {
|
||||||
errors.push('APP_MODE=demo is not permitted for a staging/production release');
|
errors.push('APP_MODE=demo is not permitted for a staging/production release');
|
||||||
}
|
}
|
||||||
@@ -30,6 +49,9 @@ export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
|
|||||||
errors.push(`secret-like file is tracked: ${file}`);
|
errors.push(`secret-like file is tracked: ${file}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ((input.browserBundleFiles ?? []).length > 0) {
|
||||||
|
errors.push('browser client artifacts contain managed secret names');
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,15 +61,36 @@ function trackedFiles(): string[] {
|
|||||||
return result.stdout.split('\n').filter(Boolean);
|
return result.stdout.split('\n').filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MANAGED_SECRET_NAMES = ['TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'TYPESAFE_API_KEY', 'refresh_token'];
|
||||||
|
function browserBundleFiles(directory = 'build/client'): string[] {
|
||||||
|
try { statSync(directory); } catch { return []; }
|
||||||
|
const files: string[] = [];
|
||||||
|
const visit = (path: string) => {
|
||||||
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
||||||
|
const child = `${path}/${entry.name}`;
|
||||||
|
if (entry.isDirectory()) visit(child);
|
||||||
|
else if (entry.isFile()) {
|
||||||
|
const source = readFileSync(child, 'utf8');
|
||||||
|
if (MANAGED_SECRET_NAMES.some((name) => source.includes(name))) files.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(directory);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : 'development');
|
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : '');
|
||||||
const errors = releaseSafetyErrors({
|
const errors = releaseSafetyErrors({
|
||||||
mode: process.env.APP_MODE ?? 'live',
|
mode: process.env.APP_MODE ?? 'live',
|
||||||
releaseId: process.env.APP_RELEASE_ID ?? 'local',
|
releaseId: process.env.APP_RELEASE_ID ?? 'local',
|
||||||
releaseEnvironment,
|
releaseEnvironment,
|
||||||
dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '',
|
dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '',
|
||||||
|
convexImage: process.env.CONVEX_IMAGE ?? '',
|
||||||
|
dockerfile: readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8'),
|
||||||
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
|
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
|
||||||
trackedFiles: trackedFiles(),
|
trackedFiles: trackedFiles(),
|
||||||
|
browserBundleFiles: browserBundleFiles(),
|
||||||
});
|
});
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`);
|
console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`);
|
||||||
|
|||||||
@@ -34,14 +34,19 @@ describe('deployment rollout gate', () => {
|
|||||||
test('release check catches unsafe production inputs without printing values', () => {
|
test('release check catches unsafe production inputs without printing values', () => {
|
||||||
const errors = releaseSafetyErrors({
|
const errors = releaseSafetyErrors({
|
||||||
mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest',
|
mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest',
|
||||||
|
convexImage: 'convex-backend:latest', dockerfile: 'FROM oven/bun:latest',
|
||||||
capabilityAllowlist: 'wallet_balance,unknown', trackedFiles: ['.env.example', 'secrets/app.pem'],
|
capabilityAllowlist: 'wallet_balance,unknown', trackedFiles: ['.env.example', 'secrets/app.pem'],
|
||||||
|
browserBundleFiles: ['build/client/app.js'],
|
||||||
});
|
});
|
||||||
expect(errors).toEqual(expect.arrayContaining([
|
expect(errors).toEqual(expect.arrayContaining([
|
||||||
'APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment',
|
'APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment',
|
||||||
'CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production',
|
'CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production',
|
||||||
|
'CONVEX_IMAGE must be digest-pinned for staging/production',
|
||||||
|
'Dockerfile base images must be digest-pinned for staging/production',
|
||||||
'APP_MODE=demo is not permitted for a staging/production release',
|
'APP_MODE=demo is not permitted for a staging/production release',
|
||||||
'Unknown deployment capability: unknown',
|
'Unknown deployment capability: unknown',
|
||||||
'secret-like file is tracked: secrets/app.pem',
|
'secret-like file is tracked: secrets/app.pem',
|
||||||
|
'browser client artifacts contain managed secret names',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user