Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/infra-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ jobs:
GITHUB_TOKEN: op://vers-ci/vers-infra/github-token
NEON_API_KEY: op://vers-ci/neon/api-key
DISCORD_ALARMS_WEBHOOK: op://vers-ci/discord-alarms-webhook/credential
DOTENV_APP_WEB_DEV: op://vers-ci/DOTENV_APP_WEB_DEV/notesPlain
DOTENV_APP_WEB_E2E: op://vers-ci/DOTENV_APP_WEB_E2E/notesPlain
DATABASE_URL: op://vers-ci/github-actions/database-url
DISCORD_WEBHOOK_URL: op://vers-ci/discord-ci-webhook/credential
FLY_API_TOKEN: op://vers-ci/github-actions/fly-api-token
SENTRY_AUTH_TOKEN: op://vers-ci/github-actions/sentry-auth-token

- name: Preview
uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,8 @@ test, lint, boundaries, and knip like any project.
A service's env contract is the `envShape` it passes to `createService`, merged with the shared
`baseEnvSchema` and validated against `process.env` at boot. A project that also needs a local
`.env` ships a committed `.env.example` documenting its keys; real values come from
`bun run env:pull`, which pulls each managed file from the `vers` 1Password vault via the `op` CLI.
`.env` files themselves are never committed. Each service commits its derived key lists as
`bun run env:pull`, which pulls each managed file from 1Password via the `op` CLI. `.env` files
themselves are never committed. Each service commits its derived key lists as
`env-contract.generated.json`, regenerated by `bun run env:contract`; CI fails on a stale artifact,
on a required key missing from the service's dev env files or either compose stack, and — before any
image builds — on one missing from the app's Fly env and secrets
Expand Down
4 changes: 2 additions & 2 deletions agents/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ test, lint, boundaries, and knip like any project.
A service's env contract is the `envShape` it passes to `createService`, merged with the shared
`baseEnvSchema` and validated against `process.env` at boot. A project that also needs a local
`.env` ships a committed `.env.example` documenting its keys; real values come from
`bun run env:pull`, which pulls each managed file from the `vers` 1Password vault via the `op` CLI.
`.env` files themselves are never committed. Each service commits its derived key lists as
`bun run env:pull`, which pulls each managed file from 1Password via the `op` CLI. `.env` files
themselves are never committed. Each service commits its derived key lists as
`env-contract.generated.json`, regenerated by `bun run env:contract`; CI fails on a stale artifact,
on a required key missing from the service's dev env files or either compose stack, and — before any
image builds — on one missing from the app's Fly env and secrets
Expand Down
3 changes: 2 additions & 1 deletion apps/bugsink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,5 @@ shared Neon database. It enqueues a snappea background task, so the run returns
Tokens are minted in the Bugsink UI or with `bugsink-manage create_auth_token` over
`fly ssh console`. Two exist: the MCP server's token, stored as `mcp-token` on the `bugsink` item in
the `vers` 1Password vault (read by `.mcp.json` at connect time), and CI's source-map upload token,
stored as the `SENTRY_AUTH_TOKEN` GitHub secret beside the `VITE_SENTRY_DSN` GitHub variable.
stored as `sentry-auth-token` on the `github-actions` item in the `vers-ci` vault, which the
vers-infra Pulumi program pushes to the `SENTRY_AUTH_TOKEN` GitHub secret.
3 changes: 2 additions & 1 deletion docs/architecture/platform/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,5 @@ neonctl connection-string main --project-id <new-id> --database-name vers

After provisioning, write the string into `libs/data/db/.env.local`. Then `db:migrate` and `db:seed`
(run with `--env-file=.env.local`) bring the schema and dev seed data up from zero. Update the
`DATABASE_URL` Actions secret and each Fly app's secret to the new string.
`database-url` field on the `vers-ci` vault's `github-actions` item (the vers-infra program pushes
it to the `DATABASE_URL` Actions secret) and each Fly app's secret to the new string.
17 changes: 10 additions & 7 deletions docs/architecture/platform/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ pull requests are skipped: GitHub withholds secrets from them, so the preview ca
The job authenticates through the `OP_SERVICE_ACCOUNT_TOKEN` repo secret — a non-expiring 1Password
service account scoped to read only the `vers-ci` vault — and resolves the stack's credentials from
their `op://` references at run time. `vers-ci` holds exactly the items the workflow's `op://`
references name, so a compromised job step cannot reach the signing keys, database URLs, and other
credentials in the `vers` vault. When the job gains a new credential, its item moves into `vers-ci`
— never a copy, which rots on rotation — and everything the job does not read stays in `vers`.
references name, so a compromised job step cannot reach the signing keys and other credentials in
the `vers` vault. When the job gains a new credential, its item moves into `vers-ci` — never a copy,
which rots on rotation — and everything the job does not read stays in `vers`.

The job only ever previews — reconciling a reported drift is a human decision, applied with
`pulumi up` from a checkout.
Expand Down Expand Up @@ -337,10 +337,12 @@ Requires `flyctl` authenticated to the `vers` org, the Neon `DATABASE_URL` (the
done
```

3. Mint the CI deploy token and store it for the workflow:
3. Mint the CI deploy token and store it where the vers-infra program reads it (`infra/github.ts`
pushes it to the `FLY_API_TOKEN` secret on its next `pulumi up`):

```sh
fly tokens create deploy --name github-ci | gh secret set FLY_API_TOKEN
fly tokens create org -o vers --name github-actions |
op item edit github-actions --vault vers-ci 'fly-api-token[concealed]=-'
```

4. Generate the keys and set each app's secrets. The s2s public key also goes into the `vers`
Expand Down Expand Up @@ -430,8 +432,9 @@ Requires `flyctl` authenticated to the `vers` org, the Neon `DATABASE_URL` (the
`vers-app-web` secret of the same name.
- Add the alarms Discord webhook (the `bugsink-discord-webhook` item in the `vers` 1Password
vault) as each project's messaging service, so new-issue alerts reach the alarms channel.
- Mint an API token for CI source-map uploads (`SENTRY_AUTH_TOKEN` GitHub secret) and one for the
MCP server, added to the vault item as `mcp-token`.
- Mint an API token for CI source-map uploads (the `sentry-auth-token` field on the `vers-ci`
vault's `github-actions` item, pushed to the `SENTRY_AUTH_TOKEN` secret by the vers-infra
program) and one for the MCP server, added to the `bugsink` vault item as `mcp-token`.

7. Stand up web analytics. The first deploy is by hand; CI redeploys it on later config changes.
Umami boots with an `admin`/`umami` account, so the rotation to the vault value runs in the same
Expand Down
8 changes: 8 additions & 0 deletions infra/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ NEON_API_KEY=op://vers-ci/neon/api-key

# discord webhook the axiom alarms notifier posts to
DISCORD_ALARMS_WEBHOOK=op://vers-ci/discord-alarms-webhook/credential

# actions secret values the program pushes to the repo
DOTENV_APP_WEB_DEV=op://vers-ci/DOTENV_APP_WEB_DEV/notesPlain
DOTENV_APP_WEB_E2E=op://vers-ci/DOTENV_APP_WEB_E2E/notesPlain
DATABASE_URL=op://vers-ci/github-actions/database-url
DISCORD_WEBHOOK_URL=op://vers-ci/discord-ci-webhook/credential
FLY_API_TOKEN=op://vers-ci/github-actions/fly-api-token
SENTRY_AUTH_TOKEN=op://vers-ci/github-actions/sentry-auth-token
14 changes: 8 additions & 6 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ resolve from 1Password at run time, so nothing sensitive lives on disk.
the rotating.
- `github.ts` — the zgeoff/vers repo configuration: the authoritative label set (the registry the
issue-hygiene rules point at), the `main protection` branch ruleset, the `production` environment,
and the Actions variables (repo- and environment-scoped; the service-auth public key value comes
from encrypted stack config, the rest sit in code). Console-managed and therefore not drift:
milestones and the delivery board (delivery state, not schema — and Projects v2 lacks mature
provider support), Actions secrets (values live only in the 1Password vault and the console), and
the provider's own PAT, for the same reason the Axiom token is — a token cannot rotate itself
without invalidating the session doing the rotating.
the Actions variables (repo- and environment-scoped; the service-auth public key value comes from
encrypted stack config, the rest sit in code), and the Actions secrets (values resolve from the
`vers-ci` vault as environment variables at run time — GitHub cannot return a secret's value, so
the program pushes and the vault stays the source of truth). Console-managed and therefore not
drift: milestones and the delivery board (delivery state, not schema — and Projects v2 lacks
mature provider support), the `OP_SERVICE_ACCOUNT_TOKEN` secret (the credential the resolution
itself authenticates with), and the provider's own PAT, for the same reason the Axiom token is — a
token cannot rotate itself without invalidating the session doing the rotating.
- `sdks/axiom/` — committed TypeScript SDK generated from the bridged Terraform provider
(`pulumi package add terraform-provider axiomhq/axiom 1.6.2` regenerates it; the version is pinned
in `Pulumi.yaml`).
Expand Down
68 changes: 65 additions & 3 deletions infra/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,7 @@ const productionEnvironment = new github.RepositoryEnvironment(
/**
* Actions variables carry the deploy configuration. Plainly public values sit
* in code; the service-auth public key enters through encrypted stack config,
* keeping key material of any kind out of the committed source. Actions
* secrets are console-managed: their values cannot be read back through the
* API, so a declaration here could only overwrite them, never adopt them.
* keeping key material of any kind out of the committed source.
*/
const umamiWebsiteIDVariable = new github.ActionsVariable('vite-umami-website-id', {
repository,
Expand All @@ -153,9 +151,73 @@ const sentryDSNVariable = new github.ActionsEnvironmentVariable('vite-sentry-dsn
value: 'https://929f735fcaf5436db8d0910fd1c0d71d@vers-bugsink.fly.dev/1',
});

/**
* Actions secrets. Values live in the vers-ci 1Password vault and reach the
* program as environment variables resolved by `op run`; GitHub cannot return
* a secret's value, so the program pushes and the vault stays the source of
* truth. OP_SERVICE_ACCOUNT_TOKEN is console-managed: it is the credential the
* resolution itself authenticates with, and a value cannot rotate itself
* without invalidating the session doing the rotating.
*/
const dotenvAppWebDevSecret = new github.ActionsSecret('dotenv-app-web-dev', {
repository,
secretName: 'DOTENV_APP_WEB_DEV',
value: requireEnv('DOTENV_APP_WEB_DEV'),
});

const dotenvAppWebE2ESecret = new github.ActionsSecret('dotenv-app-web-e2e', {
repository,
secretName: 'DOTENV_APP_WEB_E2E',
value: requireEnv('DOTENV_APP_WEB_E2E'),
});

const databaseURLSecret = new github.ActionsEnvironmentSecret('database-url', {
repository,
environment: productionEnvironment.environment,
secretName: 'DATABASE_URL',
value: requireEnv('DATABASE_URL'),
});

const discordWebhookURLSecret = new github.ActionsEnvironmentSecret('discord-webhook-url', {
repository,
environment: productionEnvironment.environment,
secretName: 'DISCORD_WEBHOOK_URL',
value: requireEnv('DISCORD_WEBHOOK_URL'),
});

const flyAPITokenSecret = new github.ActionsEnvironmentSecret('fly-api-token', {
repository,
environment: productionEnvironment.environment,
secretName: 'FLY_API_TOKEN',
value: requireEnv('FLY_API_TOKEN'),
});

const sentryAuthTokenSecret = new github.ActionsEnvironmentSecret('sentry-auth-token', {
repository,
environment: productionEnvironment.environment,
secretName: 'SENTRY_AUTH_TOKEN',
value: requireEnv('SENTRY_AUTH_TOKEN'),
});

function requireEnv(name: string): pulumi.Output<string> {
const value = process.env[name];

if (value === undefined || value === '') {
throw new Error(`environment variable ${name} is not set — run through \`op run\``);
}

return pulumi.secret(value);
}

export const labelCount = labels.labels.apply((entries) => entries?.length ?? 0);
export const mainProtectionRulesetName = mainProtectionRuleset.name;
export const productionEnvironmentName = productionEnvironment.environment;
export const umamiWebsiteIDVariableName = umamiWebsiteIDVariable.variableName;
export const serviceAuthPublicKeyVariableName = serviceAuthPublicKeyVariable.variableName;
export const sentryDSNVariableName = sentryDSNVariable.variableName;
export const dotenvAppWebDevSecretName = dotenvAppWebDevSecret.secretName;
export const dotenvAppWebE2ESecretName = dotenvAppWebE2ESecret.secretName;
export const databaseURLSecretName = databaseURLSecret.secretName;
export const discordWebhookURLSecretName = discordWebhookURLSecret.secretName;
export const flyAPITokenSecretName = flyAPITokenSecret.secretName;
export const sentryAuthTokenSecretName = sentryAuthTokenSecret.secretName;
6 changes: 6 additions & 0 deletions infra/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ export {
} from './axiom.ts';

export {
databaseURLSecretName,
discordWebhookURLSecretName,
dotenvAppWebDevSecretName,
dotenvAppWebE2ESecretName,
flyAPITokenSecretName,
labelCount,
mainProtectionRulesetName,
productionEnvironmentName,
sentryAuthTokenSecretName,
sentryDSNVariableName,
serviceAuthPublicKeyVariableName,
umamiWebsiteIDVariableName,
Expand Down
15 changes: 8 additions & 7 deletions scripts/src/env/env-file-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import type { EnvFileManifestEntry } from './types';

/**
* The gitignored local env files `env:pull` materializes from the `vers`
* 1Password vault. Each entry's `itemTitle` item carries the file's full
* contents in its `notesPlain` field; `targetPath` is repo-root relative.
* The gitignored local env files `env:pull` materializes from 1Password. Each
* entry's `itemTitle` item carries the file's full contents in its `notesPlain`
* field; `targetPath` is repo-root relative. Items whose contents CI also
* consumes live in the `vers-ci` vault; the rest live in `vers`.
*/
export const ENV_FILE_MANIFEST: ReadonlyArray<EnvFileManifestEntry> = [
{
itemTitle: 'DOTENV_APP_WEB_E2E (app-web-e2e CI .env)',
itemTitle: 'DOTENV_APP_WEB_E2E',
targetPath: 'apps/web-e2e/.env',
vault: 'vers',
vault: 'vers-ci',
},
{
itemTitle: 'DOTENV_APP_WEB_DEV (app-web CI .env.development.local)',
itemTitle: 'DOTENV_APP_WEB_DEV',
targetPath: 'apps/web/.env.development.local',
vault: 'vers',
vault: 'vers-ci',
},
{
itemTitle: 'DOTENV_LIBS_DATA_DB (libs/data/db .env.local)',
Expand Down
2 changes: 1 addition & 1 deletion scripts/src/env/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface EnvKeySource {

export interface EnvFileManifestEntry {
readonly itemTitle: string;
readonly vault: 'vers';
readonly vault: 'vers' | 'vers-ci';
readonly targetPath: string;
}

Expand Down
Loading