Skip to content

Commit 3f8efcf

Browse files
authored
Merge branch 'main' into renovate/github-actions
2 parents c8620e8 + 78c7de5 commit 3f8efcf

32 files changed

Lines changed: 3237 additions & 191 deletions

‎docs/guide/deployment.md‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,30 +64,56 @@ If your CI pipeline runs typechecking or other static analysis before deploy, ru
6464

6565
## GitHub Actions
6666

67-
You can deploy from a GitHub repo with a simple GitHub Actions workflow. Running `void init --github` will generate this as `.github/workflows/deploy.yml` in your project:
67+
You can deploy from a GitHub repo on every push to `main`. Running `void init --github` generates `.github/workflows/void-deploy.yml` in your project.
68+
69+
The workflow authenticates with [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — there is **no long-lived `VOID_TOKEN` secret to store or rotate**. The job requests a short-lived OIDC token from GitHub, exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token (about 15 minutes on the free tier; longer on higher plans, capped at ~60 minutes), then runs `void deploy --project <slug>`:
6870

6971
```yaml
70-
name: Deploy
72+
name: Deploy to Void
7173
on:
7274
push:
7375
branches: [main]
7476

77+
# Latest push wins: a newer commit cancels an in-flight deploy for the same
78+
# repo + branch, so an older commit can never overtake a newer one.
79+
concurrency:
80+
group: void-deploy-${{ github.repository }}-${{ github.ref }}
81+
cancel-in-progress: true
82+
83+
permissions:
84+
id-token: write
85+
contents: read
86+
87+
env:
88+
VOID_API_URL: ${{ vars.VOID_API_URL || 'https://api.void.cloud' }}
89+
VOID_PROJECT: my-app
90+
7591
jobs:
7692
deploy:
7793
runs-on: ubuntu-latest
7894
steps:
79-
- uses: actions/checkout@v4
80-
- uses: voidzero-dev/setup-vp@v1
95+
- uses: actions/checkout@v6
96+
- uses: actions/setup-node@v6
8197
with:
82-
node-version: '22'
83-
cache: true
84-
- run: vp exec void deploy
85-
env:
86-
VOID_TOKEN: ${{ secrets.VOID_TOKEN }}
87-
VOID_PROJECT: my-app
98+
node-version: lts/*
99+
cache: npm
100+
- run: npm ci
101+
- name: Exchange GitHub OIDC token for a Void deploy token
102+
run: |
103+
OIDC_TOKEN="$(curl -sf \
104+
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
105+
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void" | jq -r '.value')"
106+
VOID_TOKEN="$(curl -sf \
107+
-H 'Content-Type: application/json' \
108+
-d "$(jq -n --arg t "$OIDC_TOKEN" --arg p "$VOID_PROJECT" '{oidcToken: $t, project: $p}')" \
109+
"$VOID_API_URL/auth/github-oidc" | jq -r '.token')"
110+
echo "::add-mask::$VOID_TOKEN"
111+
echo "VOID_TOKEN=$VOID_TOKEN" >> "$GITHUB_ENV"
112+
- name: Deploy
113+
run: npx void deploy --project "$VOID_PROJECT"
88114
```
89115
90-
To get a deploy token, run `void auth token` to copy it to your clipboard, then add it as a GitHub Actions secret.
116+
Connect the repository to your project once with `void github connect <project> --repo <owner/repo> --executor github_actions` to authorize it. This GitHub Actions path requires the `github_actions` build executor — the default `container` executor will not work with this workflow. The exact steps vary by package manager (the scaffold detects pnpm, yarn, bun, or npm). The `VOID_PROJECT` value is baked in from your linked project when known; otherwise the workflow reads a `VOID_PROJECT` repository variable. To target staging, set a `VOID_API_URL` repository variable to `https://api.staging.void.cloud`.
91117

92118
## Other Targets
93119

‎docs/guide/edge/headers.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,18 @@ Define custom response headers in [`void.json`](../../reference/config) using th
5656

5757
## Scope
5858

59-
Header rules apply to **all responses** served through the dispatch worker, including static assets, SSR pages, and API routes. They do not apply to:
59+
Header rules apply to **all responses** served through the dispatch worker, including static assets, SSR pages, API routes, and hashed asset responses (which browsers fetch over `GET` from the immutable edge cache). Serving headers on hashed assets is what lets a cross-origin-isolated app attach `Cross-Origin-Embedder-Policy` and `Cross-Origin-Resource-Policy` to its hashed worker and wasm files — without them, a module worker spawned from a `require-corp` document is blocked.
60+
61+
On hashed (content-addressed, immutable) assets, rules are **additive**. You can add headers such as COOP/COEP/CORP or other security headers, but the platform-managed caching, representation, and framing headers are preserved so an immutable asset cannot be mis-cached or corrupted: `Cache-Control`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Content-Range`, `Accept-Ranges`, and `Transfer-Encoding` keep their platform values even if a broad rule tries to overwrite them. (This is the one case where a user `Cache-Control` does not override the default.)
62+
63+
Header rules do not apply to:
6064

61-
- Hashed asset cache hits (these use immutable caching set by the dispatch worker itself)
6265
- ISR cache responses (these have their own cache-control headers)
6366

67+
### Blocked headers
68+
69+
Two headers can never be set through rules: `Set-Cookie` and `Clear-Site-Data`. On the shared `*.void.app` domain, `void.app` is the registrable domain, so a cookie operation from one project would reach every project's subdomain. Both are dropped from rule output to keep that tenant boundary intact.
70+
6471
## Framework `_headers` files
6572

6673
Meta-frameworks like SvelteKit, Nuxt, and Astro generate a `_headers` file with cache rules for their hashed asset directories. Void automatically parses this file during deploy and merges the rules into the deploy manifest.
@@ -74,6 +81,6 @@ No configuration is needed. If the framework generates a `_headers` file, it is
7481

7582
1. `void deploy` reads header rules from the framework `_headers` file (if present) and `routing.headers` in `void.json`, then includes them in the deploy manifest.
7683
2. The platform stores the rules in the KV routing entry for your project.
77-
3. The dispatch worker applies matching rules to matching responses before returning them. Cacheable responses are cached with the final headers.
84+
3. The dispatch worker applies matching rules to matching responses before returning them. Most cacheable responses are cached with the final headers; hashed assets are the exception — their rules are applied on every serve (including cache hits), so a later header-rule change takes effect even when the content hash, and thus the cache entry, is unchanged.
7885

7986
Because rules are evaluated at the edge, there is no extra latency cost. Headers are applied inline before the response is returned and cached.

‎docs/reference/cli.md‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ After that, the full interactive flow walks through:
8686
4. **Skills:** links Void skills using the same detected or selected agent context.
8787
5. **MCP config:** writes MCP server config using that same agent context.
8888
6. **Demo code:** for existing non-Pages projects, optionally scaffolds a `db/migrations/` directory plus an API route and typed fetch example.
89-
7. **GitHub Actions:** optionally creates `.github/workflows/deploy.yml` with the right package manager commands.
89+
7. **GitHub Actions:** optionally creates `.github/workflows/void-deploy.yml`. The workflow deploys on pushes to `main` and authenticates via [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — no long-lived `VOID_TOKEN` secret is stored in the repo. The job requests an OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token, then runs `void deploy --project <slug>` with the right package manager. The project slug is baked in from your linked project (`.void/project.json`) when known; otherwise the workflow reads a `VOID_PROJECT` repository variable.
9090
8. **`env.ts` scaffold:** if the project has no `env.ts` but has `.env` / `.env.example` / `.env.local` / `.env.development*` files on disk, generates an `env.ts` pre-populated with their keys. Values get conservative type inference (`boolean`/`url`/`number`/`string`) — the file carries a banner nudging you to tighten anything the heuristic got wrong.
9191
9. **Project setup:** optionally logs you in, lets you select or create a project, and writes `.void/project.json` so your first deploy can just be `void deploy`.
9292

@@ -98,10 +98,12 @@ Use flags to run individual steps without prompts:
9898
| ------------ | ------------------------------------------------- |
9999
| `--tsconfig` | Only update `tsconfig.json` |
100100
| `--agents` | Set up agent instructions, skills, and MCP config |
101-
| `--github` | Only create GitHub Actions workflow |
101+
| `--github` | Only create the GitHub Actions deploy workflow |
102102

103103
Flags can be combined. When any flag is provided, only the specified steps run and interactive prompts are skipped.
104104

105+
The `--github` workflow uses GitHub OIDC: you connect the repository to your Void project once with `void github connect <project> --repo <owner/repo> --executor github_actions` (see the GitHub section below), and pushes to `main` deploy automatically. There is no `VOID_TOKEN` secret to create or rotate. To target staging, set a `VOID_API_URL` repository variable to `https://api.staging.void.cloud`.
106+
105107
For projects that already have `"extends"`, `void init --tsconfig` preserves the existing config and adds `./.void/tsconfig.json`. If the existing config defines `files` or `compilerOptions.paths`, Void also merges its generated declaration files and aliases into the root config because TypeScript replaces those fields across `extends` instead of deeply merging them.
106108

107109
### `void prepare`
@@ -564,6 +566,67 @@ PORT=3000
564566

565567
See [Environment Variables](../guide/env-vars.md) for the full guide.
566568

569+
## GitHub
570+
571+
Deploy-on-GitHub works from **any** Void login — Google, GitHub, or other SSO. The first time you connect GitHub, Void links your GitHub identity to your current account (a one-time step, independent of how you logged in); it never creates a second account.
572+
573+
### `void github link`
574+
575+
```
576+
void github link
577+
```
578+
579+
Link your current Void account to a GitHub identity. Opens your browser to authorize Void on GitHub (a localhost + PKCE handshake, the same mechanics as `void auth login`), then binds that GitHub identity to the logged-in account. Requires an authenticated CLI (`void auth login` first).
580+
581+
You normally don't need to run this directly — `void github install` runs the link automatically when your account has no GitHub identity yet. Run it on its own to link ahead of time, or to link a GitHub identity without installing the App. If that GitHub account is already linked to a different Void account, the command fails and asks you to use a different GitHub account.
582+
583+
### `void github install`
584+
585+
```
586+
void github install
587+
```
588+
589+
Open the GitHub App install page in your browser. If your account has no linked GitHub identity yet, `void github install` first runs the GitHub link automatically (browser authorize), then continues. After installing, run `void github connect` to link a repository to your project.
590+
591+
### `void github installations`
592+
593+
```
594+
void github installations
595+
```
596+
597+
List all GitHub App installations linked to your account. Each entry includes the `[id: <installation_id>]` needed for `--installation` in non-interactive use.
598+
599+
### `void github connect`
600+
601+
```
602+
void github connect [project] [options]
603+
```
604+
605+
Connect a GitHub repository to a Void project for automatic deploys. On every push to the configured branch, Void builds and deploys your project automatically.
606+
607+
**Options**
608+
609+
| Flag | Description |
610+
| --------------------- | ------------------------------------------------------------------------------- |
611+
| `--project <name>` | Project name (alias for the positional argument) |
612+
| `--installation <id>` | GitHub App installation ID (required when you have multiple installations) |
613+
| `--repo <owner/repo>` | Repository full name — required unless the installation grants exactly one repo |
614+
| `--branch <name>` | Branch to deploy from — **required in non-interactive mode** |
615+
| `--executor <type>` | Build executor: `container` (default) or `github_actions` |
616+
617+
**Non-interactive use (CI)**
618+
619+
When stdin is not a TTY, `void github connect` never prompts — it fails closed and names any flag it needs. `--branch` is always required. `--project` must be resolvable (positional / `--project` / `VOID_PROJECT` / linked `.void/project.json`). `--installation` is required only when your account has more than one installation; otherwise the sole installation is used. `--repo` is required when the installation grants access to all repos or to more than one selected repo; when it grants exactly one repo that repo is used automatically. Use `void github installations` to discover the `installation_id`.
620+
621+
```
622+
void github connect my-app \
623+
--installation 42 \
624+
--repo owner/my-app \
625+
--branch main
626+
```
627+
628+
**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`).
629+
567630
## Custom Domains
568631

569632
### `void domain add`

‎packages/create-void/src/index.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ async function main() {
3131
if (!appName) {
3232
const name = await p.text({
3333
message: 'Project name:',
34+
defaultValue: 'my-app',
3435
placeholder: 'my-app',
3536
validate(value) {
36-
if (!value?.trim()) {
37-
return 'Project name is required';
37+
if (value && !value.trim()) {
38+
return 'Project name cannot be blank';
3839
}
3940
},
4041
});

‎packages/create-void/src/pm.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ export function pmExec(pm: PM): ReadonlyArray<string> {
3838
export function run(argv: ReadonlyArray<string>, cwd?: string): Promise<void> {
3939
return new Promise((resolve, reject) => {
4040
const [cmd, ...args] = argv;
41-
const child = spawn(cmd, args, { stdio: 'inherit', cwd });
41+
// On Windows the package-manager entry points (npm, npx, pnpm, yarn, bunx)
42+
// are .cmd/.ps1 shims that spawn() cannot resolve without a shell, so it
43+
// throws ENOENT. Enable a shell only on win32; every arg here is a fixed
44+
// literal (no user input reaches argv), so there is no injection surface.
45+
const child = spawn(cmd, args, { stdio: 'inherit', cwd, shell: process.platform === 'win32' });
4246
child.on('close', (code) => {
4347
if (code === 0) {
4448
resolve();

‎packages/md/src/compile.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@shikijs/transformers';
99
import matter from 'gray-matter';
1010
import MarkdownExit from 'markdown-exit';
11-
import type { Token } from 'markdown-exit';
11+
import type { RuleCore, Token } from 'markdown-exit';
1212
import anchor from 'markdown-it-anchor';
1313
import attrs from 'markdown-it-attrs';
1414
import { full as emoji } from 'markdown-it-emoji';
@@ -82,12 +82,20 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
8282
lang = 'text';
8383
}
8484
}
85+
// `markdown-it-attrs` strips the `{1,3-5}` line-highlight ranges out of
86+
// `token.info` at parse time (it treats `{…}` as an attribute block), so by
87+
// render time only the language remains. The `stash_fence_info` core rule
88+
// preserves the original info string on `token.meta.rawInfo` before that
89+
// happens; fall back to `token.info` when it is absent (a fence with no
90+
// curly meta). Without this, `transformerMetaHighlight` never sees the
91+
// ranges and line highlighting silently no-ops.
92+
const rawInfo = (token.meta as { rawInfo?: string } | null)?.rawInfo ?? token.info;
8593
return highlighter.codeToHtml(code, {
8694
lang,
8795
themes,
8896
defaultColor: 'light',
8997
transformers: shikiTransformers,
90-
meta: { __raw: token.info },
98+
meta: { __raw: rawInfo },
9199
});
92100
};
93101

@@ -109,6 +117,20 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
109117
// GitHub alerts: > [!NOTE], > [!TIP], etc.
110118
markdown.use(gitHubAlertsPlugin);
111119

120+
// Preserve the raw fence info string (e.g. `ts {1,3-5}`) on the token before
121+
// `markdown-it-attrs` consumes the `{…}` as an attribute block. Anchored
122+
// after `block` so fence tokens exist, and before `curly_attributes` (which
123+
// runs after `inline`), so the fence renderer can still feed the ranges to
124+
// Shiki's `transformerMetaHighlight` via `meta.__raw`.
125+
const stashFenceInfo: RuleCore = (state) => {
126+
for (const token of state.tokens) {
127+
if (token.type === 'fence') {
128+
token.meta = { ...token.meta, rawInfo: token.info };
129+
}
130+
}
131+
};
132+
markdown.core.ruler.after('block', 'stash_fence_info', stashFenceInfo);
133+
112134
// Attribute syntax: {.class #id}
113135
markdown.use(attrs);
114136

‎packages/md/test/unit/compile.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,44 @@ describe('compile', () => {
3838
expect(result.html).toContain('<span');
3939
});
4040

41+
// The 0-based indices of code lines that carry the shiki `highlighted` class.
42+
// (Shiki emits one `<span class="line">` per line plus a trailing empty line,
43+
// so we compare highlighted positions rather than a fixed line count.)
44+
const highlightedLineIndices = (html: string): ReadonlyArray<number> =>
45+
[...html.matchAll(/<span class="(line[^"]*)"/g)]
46+
.map((match, index) => (match[1].includes('highlighted') ? index : -1))
47+
.filter((index) => index >= 0);
48+
49+
it('highlights a single {N} fence line (markdown-it-attrs must not eat it)', async () => {
50+
const result = await compile('```ts {2}\nconst a = 1;\nconst b = 2;\nconst c = 3;\n```');
51+
expect(highlightedLineIndices(result.html)).toEqual([1]);
52+
});
53+
54+
it('highlights a {N-M} line range and a combined {a,c-d} set', async () => {
55+
const range = await compile('```ts {2-3}\nlet a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n```');
56+
expect(highlightedLineIndices(range.html)).toEqual([1, 2]);
57+
58+
const combined = await compile(
59+
'```ts {1,3-4}\nlet a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n```',
60+
);
61+
expect(highlightedLineIndices(combined.html)).toEqual([0, 2, 3]);
62+
});
63+
64+
it('highlights {N} even for languages without comment syntax (e.g. text)', async () => {
65+
const result = await compile('```text {1}\nalpha\nbeta\n```');
66+
expect(highlightedLineIndices(result.html)).toEqual([0]);
67+
});
68+
69+
it('leaves fences without curly meta unhighlighted', async () => {
70+
const result = await compile('```ts\nconst a = 1;\nconst b = 2;\n```');
71+
expect(result.html).not.toContain('highlighted');
72+
});
73+
74+
it('still applies markdown-it-attrs to non-fence content', async () => {
75+
const result = await compile('Hello {.greeting}\n');
76+
expect(result.html).toContain('greeting');
77+
});
78+
4179
it('converts emoji shortcodes', async () => {
4280
const result = await compile('Hello :tada:');
4381
expect(result.html).toContain('\u{1F389}'); // 🎉

0 commit comments

Comments
 (0)