@podosoft/podokit 0.16.4 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/deploy-compose-exec.d.ts +93 -0
  2. package/dist/deploy-compose-exec.js +198 -0
  3. package/dist/deploy-compose-profile.d.ts +107 -0
  4. package/dist/deploy-compose-profile.js +361 -0
  5. package/dist/deploy-compose-render.d.ts +48 -0
  6. package/dist/deploy-compose-render.js +333 -0
  7. package/dist/deploy-compose-sync.d.ts +144 -0
  8. package/dist/deploy-compose-sync.js +412 -0
  9. package/dist/deploy-compose.d.ts +98 -0
  10. package/dist/deploy-compose.js +667 -0
  11. package/dist/deploy-dispatch.d.ts +33 -0
  12. package/dist/deploy-dispatch.js +58 -0
  13. package/dist/deploy-driver.d.ts +25 -0
  14. package/dist/deploy-driver.js +78 -0
  15. package/dist/deploy-profile.d.ts +3 -10
  16. package/dist/deploy-profile.js +82 -291
  17. package/dist/deploy-schema.d.ts +56 -0
  18. package/dist/deploy-schema.js +261 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +178 -6
  21. package/dist/lib.d.ts +9 -0
  22. package/dist/lib.js +30 -1
  23. package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
  24. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
  25. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
  26. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
  27. package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
  30. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  38. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  39. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
  40. package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
  41. package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
  42. package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
  43. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
  44. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
  45. package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +15 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
  55. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  60. package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
  61. package/dist/templates/todo/AGENTS.md +2 -1
  62. package/dist/templates/todo/Dockerfile.dev +34 -3
  63. package/dist/templates/todo/apps/api/Dockerfile +68 -7
  64. package/dist/templates/todo/apps/web/Dockerfile +60 -8
  65. package/dist/templates/todo/apps/web/server.js +31 -0
  66. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  67. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
  68. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
  69. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  70. package/dist/templates/todo/apps/web/vite.config.ts +17 -7
  71. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  72. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
  73. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  74. package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  75. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  76. package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  77. package/dist/templates/todo/dot-dockerignore +7 -0
  78. package/dist/templates/todo/dot-env.example +7 -0
  79. package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
  80. package/dist/templates/todo/infra/k3s/README.md +18 -0
  81. package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
  82. package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
  83. package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
  84. package/package.json +1 -1
@@ -0,0 +1,134 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createUpgradeProxy, decideUpgrade, upstreamHeaders } from "./ws-upgrade.js";
4
+
5
+ const allowed = new Set(["/events/ws"]);
6
+ const handshake = { upgrade: "websocket", connection: "Upgrade", host: "app.example.com" };
7
+
8
+ describe("decideUpgrade", () => {
9
+ it("forwards an allowed path", () => {
10
+ expect(decideUpgrade("/events/ws", handshake, allowed)).toEqual({
11
+ kind: "proxy",
12
+ path: "/events/ws",
13
+ });
14
+ });
15
+
16
+ it("keeps the query string", () => {
17
+ expect(decideUpgrade("/events/ws?room=1", handshake, allowed)).toEqual({
18
+ kind: "proxy",
19
+ path: "/events/ws?room=1",
20
+ });
21
+ });
22
+
23
+ it.each([
24
+ ["/events/wsx", "a path that only starts the same"],
25
+ ["/events/ws/", "a trailing slash"],
26
+ ["/admin/secrets", "an unrelated path"],
27
+ ["/events/../admin/secrets", "traversal that resolves elsewhere"],
28
+ ["//evil.example.com/events/ws", "a protocol-relative target"],
29
+ ["http://evil.example.com/events/ws", "an absolute target"],
30
+ ])("rejects %s (%s)", (url) => {
31
+ expect(decideUpgrade(url, handshake, allowed).kind).toBe("reject");
32
+ });
33
+
34
+ it("rejects a request that is not a websocket upgrade", () => {
35
+ expect(decideUpgrade("/events/ws", { ...handshake, upgrade: "h2c" }, allowed).kind).toBe(
36
+ "reject",
37
+ );
38
+ });
39
+
40
+ it("forwards nothing when no path is configured", () => {
41
+ expect(decideUpgrade("/events/ws", handshake, new Set()).kind).toBe("reject");
42
+ });
43
+ });
44
+
45
+ describe("upstreamHeaders", () => {
46
+ it("passes the handshake and cookie through", () => {
47
+ const headers = upstreamHeaders(
48
+ { ...handshake, cookie: "session=abc", "sec-websocket-key": "k" },
49
+ {},
50
+ );
51
+ expect(headers.cookie).toBe("session=abc");
52
+ expect(headers["sec-websocket-key"]).toBe("k");
53
+ expect(headers.upgrade).toBe("websocket");
54
+ });
55
+
56
+ it("appends to an existing forwarded chain rather than replacing it", () => {
57
+ const headers = upstreamHeaders(
58
+ { ...handshake, "x-forwarded-for": "203.0.113.7" },
59
+ { remoteAddress: "::ffff:10.0.0.2" },
60
+ );
61
+ expect(headers["x-forwarded-for"]).toBe("203.0.113.7, 10.0.0.2");
62
+ });
63
+
64
+ it("reports the scheme the client actually used", () => {
65
+ expect(upstreamHeaders(handshake, { encrypted: true })["x-forwarded-proto"]).toBe("https");
66
+ expect(upstreamHeaders(handshake, {})["x-forwarded-proto"]).toBe("http");
67
+ });
68
+ });
69
+
70
+ describe("createUpgradeProxy", () => {
71
+ function socket() {
72
+ const stream = new PassThrough();
73
+ stream.destroy = () => {
74
+ stream.destroyed = true;
75
+ return stream;
76
+ };
77
+ return stream;
78
+ }
79
+
80
+ it("destroys a rejected upgrade without contacting the API", () => {
81
+ let called = false;
82
+ const proxy = createUpgradeProxy({ allowed, connect: () => ((called = true), { abort() {} }) });
83
+ const client = socket();
84
+ proxy({ url: "/admin/secrets", headers: handshake }, client, null);
85
+ expect(called).toBe(false);
86
+ expect(client.destroyed).toBe(true);
87
+ });
88
+
89
+ it("relays the API's refusal instead of dropping the socket", () => {
90
+ const proxy = createUpgradeProxy({
91
+ allowed,
92
+ connect: (_options, hooks) => {
93
+ hooks.onResponse(401, "Unauthorized", { "content-length": "0" });
94
+ return { abort() {} };
95
+ },
96
+ });
97
+ const client = socket();
98
+ /** @type {string[]} */
99
+ const written = [];
100
+ client.on("data", (chunk) => written.push(chunk.toString()));
101
+ proxy({ url: "/events/ws", headers: handshake }, client, null);
102
+ expect(written.join("")).toContain("HTTP/1.1 401 Unauthorized");
103
+ });
104
+
105
+ it("answers 502 when the API cannot be reached", () => {
106
+ const proxy = createUpgradeProxy({
107
+ allowed,
108
+ connect: (_options, hooks) => (hooks.onError("ECONNREFUSED"), { abort() {} }),
109
+ });
110
+ const client = socket();
111
+ /** @type {string[]} */
112
+ const written = [];
113
+ client.on("data", (chunk) => written.push(chunk.toString()));
114
+ proxy({ url: "/events/ws", headers: handshake }, client, null);
115
+ expect(written.join("")).toContain("HTTP/1.1 502 Bad Gateway");
116
+ });
117
+
118
+ it("writes the 101 and pipes both directions once upstream upgrades", () => {
119
+ const upstream = socket();
120
+ const proxy = createUpgradeProxy({
121
+ allowed,
122
+ connect: (_options, hooks) => {
123
+ hooks.onUpgrade(101, { upgrade: "websocket" }, upstream, null);
124
+ return { abort() {} };
125
+ },
126
+ });
127
+ const client = socket();
128
+ /** @type {string[]} */
129
+ const written = [];
130
+ client.on("data", (chunk) => written.push(chunk.toString()));
131
+ proxy({ url: "/events/ws", headers: handshake }, client, null);
132
+ expect(written.join("")).toContain("HTTP/1.1 101 Switching Protocols");
133
+ });
134
+ });
@@ -7,13 +7,23 @@ import { defineConfig } from "vite";
7
7
  // browser origin so the same configuration works for both entry points.
8
8
  const inDocker = process.env.VITE_DOCKER === "1";
9
9
 
10
+ // Dev parity for WebSocket upgrades. In production `server.js` proxies these paths to
11
+ // the API; without the same allowlist here, a feature would work in one and not the
12
+ // other -- which is exactly how a WebSocket gateway used to reach production dead.
13
+ // Set WS_PROXY_PATHS in the shell that runs vite; a .env file cannot reach this file.
14
+ const wsProxyPaths = (process.env.WS_PROXY_PATHS ?? "")
15
+ .split(",")
16
+ .map((entry) => entry.trim())
17
+ .filter((entry) => entry.startsWith("/"));
18
+ const backendTarget = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
19
+ const wsProxy = Object.fromEntries(
20
+ wsProxyPaths.map((path) => [path, { target: backendTarget, ws: true, changeOrigin: false }]),
21
+ );
22
+
10
23
  export default defineConfig({
11
24
  plugins: [tailwindcss(), sveltekit()],
12
- server: inDocker
13
- ? {
14
- host: true,
15
- port: 5001,
16
- allowedHosts: true,
17
- }
18
- : undefined,
25
+ server: {
26
+ ...(inDocker ? { host: true, port: 5001, allowedHosts: true } : {}),
27
+ ...(wsProxyPaths.length ? { proxy: wsProxy } : {}),
28
+ },
19
29
  });
@@ -1,20 +1,65 @@
1
1
  ---
2
2
  name: podokit-deploy
3
- description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit deployment profile and confirmation hash. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target cluster, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare an application rollback.
3
+ description: Plan, inspect, apply, verify, and roll back PodoKit releases with an explicit deployment profile and confirmation hash, on Kubernetes or Docker Compose. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare a rollback.
4
4
  ---
5
5
 
6
6
  # Deploy a PodoKit application
7
7
 
8
+ A profile records which driver it uses, so every command below is the same for both.
9
+ Read the profile's `driver` first and say which one you are operating.
10
+
11
+ | Driver | Target | Use it when |
12
+ | --- | --- | --- |
13
+ | `kubernetes-helm` | a cluster context and namespace | you already run a cluster, or you need more than one node, cluster-managed rollouts, or per-workload scaling |
14
+ | `docker-compose` | one Docker host, local or over `ssh://` | one machine is the whole deployment and a reverse proxy in front of it terminates TLS |
15
+
8
16
  1. Run `npx @podosoft/podokit status`, `npx @podosoft/podokit diff`, and `npx @podosoft/podokit doctor`. Stop if managed-file conflicts or unsupported framework versions could make the image unreliable.
9
17
  2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
10
- 3. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing Secret values.
11
- 4. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
12
- 5. Show the user the context, cluster fingerprint result, namespace, Helm release, exact images, current revision and status, actions, migration warning, and plan hash.
13
- 6. Do not apply until the user explicitly approves that exact plan.
14
- 7. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
15
- 8. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
16
- 9. Report the exact images, Helm revision, ready replicas, restart totals, and verification result.
18
+ 3. Make sure the images exist for that tag. Read [references/release.md](references/release.md): tagging `vX.Y.Z` runs the release workflow, which builds and pushes both images and stops there. Never apply a release whose images were not published.
19
+ 4. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing secret values.
20
+ 5. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
21
+ 6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions, the migration warning, and the plan hash.
22
+ 7. Do not apply until the user explicitly approves that exact plan.
23
+ 8. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
24
+ 9. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
25
+ 10. Report the exact images, the revision, running replicas, restart totals, and the verification result. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
26
+
27
+ ## Iterating against a deployment is a different command
28
+
29
+ If the user is changing code and wants to see it on the deployment, a release is the
30
+ wrong loop — it rebuilds and republishes to move a few megabytes of compiled output.
31
+ Use the `podokit-deploy-fast` skill (`podo deploy sync`, docker-compose driver only).
32
+ Reach for a release when the change needs a migration, changes runtime dependencies,
33
+ or is going to real users.
34
+
35
+ ## The migration runs against the release that is still serving
36
+
37
+ Both drivers run the migration **before** the new containers start, and both keep the
38
+ previous release serving while the new one rolls out. So a migration must be
39
+ compatible with the code already running:
40
+
41
+ - Additive changes are safe: new tables, new nullable columns, new indexes.
42
+ - Dropping or renaming a column, or tightening a constraint, breaks the running
43
+ release the moment the migration commits — before any new code is live.
44
+ - Destructive changes belong in a **second** release, after the code that stopped
45
+ using the column has fully rolled out.
46
+
47
+ Rollback does not reverse migrations on either driver. If an older image cannot read
48
+ the current schema, roll forward instead.
49
+
50
+ ## Rollback
51
+
52
+ Read [references/rollback.md](references/rollback.md), preview the target revision
53
+ without `--confirm`, show the new plan hash, and obtain explicit approval.
54
+
55
+ The compose driver can only reproduce a revision whose rendered project still matches
56
+ the current profile byte for byte; if the profile changed since, it refuses and tells
57
+ you to roll forward.
17
58
 
18
- For rollback, read [references/rollback.md](references/rollback.md), preview the target revision without `--confirm`, show the new plan hash, and obtain explicit approval.
59
+ ## Boundary
19
60
 
20
- Never create or rotate cluster credentials, registry credentials, DNS, virtual machines, Kubernetes Secrets, or backup repositories. Never put a password, token, private key, connection URL containing credentials, or Secret value in a profile, command argument, log, or report.
61
+ Never create or rotate cluster credentials, registry credentials, DNS records,
62
+ virtual machines, Kubernetes Secrets, env files on a deployment host, TLS
63
+ certificates, or backup repositories. Never put a password, token, private key,
64
+ connection URL containing credentials, or secret value in a profile, a command
65
+ argument, a log, or a report.
@@ -1,6 +1,10 @@
1
1
  # Deployment profile
2
2
 
3
- The profile at `.podokit/deploy/<name>.json` is application-owned and contains no Secret values.
3
+ The profile at `.podokit/deploy/<name>.json` is application-owned and contains no
4
+ secret values. It declares a `driver`, and that decides the shape of the rest of the
5
+ document.
6
+
7
+ ## kubernetes-helm (default)
4
8
 
5
9
  Initialize it with an explicit kubeconfig context:
6
10
 
@@ -35,3 +39,48 @@ Required keys:
35
39
  Use `inCluster` when the generated Helm release should manage a dependency, `external` when application configuration points to an existing service, and `disabled` only when the application does not require it.
36
40
 
37
41
  Use the same stable SemVer tag for API and web. `latest`, branch tags, and empty tags are rejected. Planning resolves every deployed tag to an immutable registry digest through Docker Buildx.
42
+
43
+ ## docker-compose
44
+
45
+ Initialize it with an explicit Docker context. The context may be local or `ssh://`,
46
+ and it is pinned by a fingerprint of the endpoint plus the daemon ID, so repointing
47
+ it at a different host invalidates the profile instead of deploying somewhere else.
48
+
49
+ ```bash
50
+ npx @podosoft/podokit deploy init \
51
+ --profile production \
52
+ --driver docker-compose \
53
+ --context production \
54
+ --host app.example.com \
55
+ --secrets-dir /etc/podokit/example-app
56
+ ```
57
+
58
+ The fields that differ from the Kubernetes driver:
59
+
60
+ - `target`: Docker context, endpoint fingerprint, Compose project name
61
+ - `exposure`: `bindAddress` and `port` published from the web service. It defaults to
62
+ `127.0.0.1`, because the expected front door is a reverse proxy on the same host —
63
+ binding every interface publishes the app before TLS exists
64
+ - `workloads`: replicas plus Compose `cpus` and `memory` limits
65
+ - `dependencies`: `managed`, `external`, or `disabled`, each with a named Docker
66
+ volume and the path of its env file on the target host
67
+ - `secrets`: env-file paths and required key names, plus `registryLogin`
68
+
69
+ Credentials live in env files **on the target host**, never in the profile and never
70
+ in the rendered Compose project. The doctor reads only the key names, in a throwaway
71
+ container that mounts the file read-only. Create those files with the host's own
72
+ secret tooling before deploying; PodoKit does not write them.
73
+
74
+ Required keys:
75
+
76
+ | Env file | Required keys |
77
+ | --- | --- |
78
+ | API | Profile-declared keys plus enabled dependency connection keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `REDIS_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
79
+ | web | Only explicitly declared web keys; omitted by default |
80
+ | PostgreSQL dependency | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
81
+ | Redis dependency | `REDIS_PASSWORD` |
82
+ | Object storage dependency | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
83
+
84
+ Data volumes are declared `external`, so `docker compose down -v` cannot delete the
85
+ database. Editing an env file changes the containers' rollout label, so the next
86
+ apply actually restarts them instead of leaving stale environment values running.
@@ -0,0 +1,64 @@
1
+ # Publishing the images a release deploys
2
+
3
+ `podo deploy` consumes images. Building them is `.github/workflows/release.yml`,
4
+ which triggers on a `v*.*.*` tag, verifies the commit, builds both images from the
5
+ repository root, and pushes them under that one tag. It does not roll out.
6
+
7
+ ```bash
8
+ git tag v1.2.3
9
+ git push origin v1.2.3
10
+ ```
11
+
12
+ Then plan and apply as the main skill describes. `podo deploy plan` resolves the tag
13
+ to an immutable digest, so a tag whose images were never pushed fails there rather
14
+ than half way through a rollout.
15
+
16
+ ## Before the first tag: check the runner
17
+
18
+ **GitHub-hosted runners are free for public repositories only.** In a private
19
+ repository every minute is metered against the account's included Actions minutes and
20
+ billed beyond them, and image builds are usually the most expensive job a project
21
+ runs. A self-hosted runner consumes no Actions minutes at all.
22
+
23
+ The workflow reads the choice from a variable, so it is a setting rather than an edit:
24
+
25
+ | Variable | Unset | Notes |
26
+ | --- | --- | --- |
27
+ | `PODOKIT_RUNNER` | `["ubuntu-latest"]` | JSON array of labels. Self-hosted example: `["self-hosted","Linux","X64"]` |
28
+ | `PODOKIT_DEPLOY_PROFILE` | `production` | which `.podokit/deploy/<name>.json` names the image repositories |
29
+ | `PODOKIT_IMAGE_PLATFORM` | `linux/amd64` | the architecture the **deployment target** runs, not the runner's |
30
+
31
+ Nothing warns when a private repository is still on the default: the workflow
32
+ succeeds either way and the difference appears on a bill. **When asked to set up
33
+ releases, check the repository's visibility first and say which case it is.**
34
+
35
+ `PODOKIT_IMAGE_PLATFORM` is the other one worth stating out loud. Building for the
36
+ wrong architecture produces `exec format error` at rollout — after the migration has
37
+ already run — and building for the right one on a runner of a different architecture
38
+ works but runs under emulation, which is slow enough to be mistaken for a problem
39
+ with Docker.
40
+
41
+ ## Secrets
42
+
43
+ `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`, for the registry named by the profile's
44
+ `release.apiRepository`. A robot or deploy account is enough for pushing, but some
45
+ registries do not let a robot **create** a repository — push the first tag of a new
46
+ repository with an account that can, or create the repository in the registry first.
47
+
48
+ Never print either value, and never put one in a profile, a command argument, or a
49
+ report.
50
+
51
+ ## Building by hand
52
+
53
+ Only when a tag is not the right trigger. Build for the target's architecture, tag
54
+ both images identically, and push before planning:
55
+
56
+ ```bash
57
+ docker build --platform linux/amd64 -f apps/api/Dockerfile -t <api-repository>:v1.2.3 .
58
+ docker build --platform linux/amd64 -f apps/web/Dockerfile -t <web-repository>:v1.2.3 .
59
+ docker push <api-repository>:v1.2.3
60
+ docker push <web-repository>:v1.2.3
61
+ ```
62
+
63
+ A bare local tag cannot be deployed: it does not match the profile's stable SemVer
64
+ pattern and cannot be resolved to a digest.
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: podokit-deploy-fast
3
+ description: Push locally built artifacts into a running Docker Compose deployment and restart it, without building or publishing an image. Use when iterating on code against a deployed environment and a full release round trip is too slow. Not for releases, migrations, dependency changes, or anything going to real users.
4
+ ---
5
+
6
+ # Sync code into a running deployment
7
+
8
+ `podo deploy sync` copies this project's build output into the containers that are
9
+ already running and restarts them. `docker-compose` driver only — read the profile's
10
+ `driver` first and stop if it is `kubernetes-helm`, where the equivalent would have
11
+ to reach every node and would stop being a shortcut.
12
+
13
+ ```bash
14
+ npx @podosoft/podokit deploy sync --profile <name> --build
15
+ ```
16
+
17
+ ## Say what this does before doing it
18
+
19
+ It is not a release, and the user has to understand three consequences before you run
20
+ it:
21
+
22
+ 1. **The deployment will run code its image tag does not describe.** A marker file
23
+ records the sync and `podo deploy status` reports it under `syncDrift`.
24
+ 2. **Every connection the restarted containers hold is dropped** — WebSocket sessions,
25
+ in-flight requests, anything attached.
26
+ 3. **It disappears on the next release.** Container writable layers do not survive
27
+ recreation, so `podo deploy apply` restores the image with no cleanup step. That is
28
+ the property that makes this safe; do not build anything that depends on the copy
29
+ persisting.
30
+
31
+ ## When NOT to use it
32
+
33
+ Use a release instead — build the images and `podo deploy apply` — when any of these
34
+ is true. The first three are refusals the command makes itself; the last two it
35
+ cannot detect, which is why they are your job.
36
+
37
+ | Situation | Why |
38
+ | --- | --- |
39
+ | runtime dependencies changed | the container's `node_modules` was installed `--omit=dev` from the old manifest, so the new code imports something that is not there |
40
+ | a release holds the deployment lock | an apply is in progress |
41
+ | nothing is running for that project | there is no container to copy into |
42
+ | **the change needs a migration** | sync never migrates, and a migration belongs with the release whose code needs it |
43
+ | **the change is going to real users** | nothing is pushed, so nothing is reproducible from it |
44
+
45
+ ## Procedure
46
+
47
+ 1. Read the profile's `driver`. Stop unless it is `docker-compose`.
48
+ 2. Tell the user what will restart and that connections drop. Get agreement if anyone
49
+ is using the deployment.
50
+ 3. Run with `--build` unless the project is already built. `--build` compiles in the
51
+ order the images do — every `packages/*` before the apps — because a root build
52
+ script runs workspaces in manifest order and an app listed first would compile
53
+ against a workspace package's previous output.
54
+ 4. Report what was copied, which containers restarted, and that the deployment is now
55
+ in sync drift.
56
+ 5. Verify against the running deployment, not against the build: request a path the
57
+ change affects. If part of the build output is excluded (below), check one of
58
+ those paths too — that is the failure this exclusion exists to prevent, and it
59
+ does not make the page look broken.
60
+
61
+ ## Restoring the image
62
+
63
+ ```bash
64
+ npx @podosoft/podokit deploy sync --profile <name> --revert
65
+ ```
66
+
67
+ This recreates the containers from the Compose project **already on the target**,
68
+ which is the description of what was actually applied — not a fresh render, which
69
+ would reproduce the profile as it is now.
70
+
71
+ A release does the same thing as a side effect, so `--revert` is for abandoning a
72
+ sync, not a required cleanup step.
73
+
74
+ ## Excluding part of the build output
75
+
76
+ Some of a build output can come from a toolchain the developer's machine does not
77
+ have. Overwriting that part replaces real artifacts with an index of artifacts that
78
+ are no longer there, and the application keeps serving pages while the missing files
79
+ 404 — so nothing looks broken. Those paths belong in the profile:
80
+
81
+ ```json
82
+ { "sync": { "exclude": ["apps/web/build/static-assets/generated"] } }
83
+ ```
84
+
85
+ If you find yourself about to sync over something this machine cannot rebuild, add it
86
+ here rather than remembering not to. `--clean` refuses while an exclude sits beneath
87
+ a synced path, for the same reason.
88
+
89
+ ## Boundary
90
+
91
+ Never use this to ship. Never run migrations alongside it. Never create or rotate
92
+ credentials, and never print a secret value.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: podokit-deploy
3
- description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit profile and confirmation hash. Use for Kubernetes deployment and rollback work in a generated PodoKit project.
3
+ description: Plan, inspect, apply, verify, and roll back PodoKit releases with an explicit profile and confirmation hash, on Kubernetes or Docker Compose. Use for release, rollout, and rollback work in a generated PodoKit project.
4
4
  ---
5
5
 
6
6
  # PodoKit deployment
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: podokit-deploy-fast
3
+ description: Push locally built artifacts into a running Docker Compose deployment and restart it, without building or publishing an image. Use when iterating on code against a deployed environment and a full release round trip is too slow. Not for releases, migrations, dependency changes, or anything going to real users.
4
+ ---
5
+
6
+ # PodoKit fast deployment sync
7
+
8
+ Read and follow the canonical skill at `../../../.agents/skills/podokit-deploy-fast/SKILL.md`.
@@ -6,3 +6,10 @@ dist
6
6
  **/.svelte-kit
7
7
  .git
8
8
  *.log
9
+
10
+ # Real environment files never enter a build context. The build stages copy the
11
+ # whole repository, so an .env left here is baked into an image layer even when the
12
+ # runtime stage does not copy it -- and getting-started tells you to create one.
13
+ .env
14
+ .env.*
15
+ !.env.example
@@ -12,6 +12,13 @@ POSTGRES_DB=podokit
12
12
 
13
13
  # Web -> API boundary (server-side only; not exposed to the browser)
14
14
  BACKEND_INTERNAL_URL=http://localhost:5002
15
+
16
+ # Exact paths the web server may proxy as WebSocket upgrades to the API, comma
17
+ # separated. Empty means none: a SvelteKit route cannot answer an upgrade, so a
18
+ # WebSocket gateway on the API is unreachable until it is named here. Each path is
19
+ # matched whole and must be authorised by the API at handshake time -- a relay that
20
+ # forwards anything is an open tunnel to every private route.
21
+ WS_PROXY_PATHS=
15
22
  CORS_ORIGIN=http://localhost:5001
16
23
  # adapter-node request limit; leave room for a 2 MiB multipart upload
17
24
  BODY_SIZE_LIMIT=3M
@@ -0,0 +1,142 @@
1
+ # Build and publish the release images for a tag.
2
+ #
3
+ # `podo deploy` consumes images; it does not build them. This is the other half:
4
+ # tagging a commit produces the two images that `podo deploy apply` then rolls out.
5
+ # It stops at the push, because rolling out requires confirming a plan hash and that
6
+ # approval belongs to a person.
7
+ #
8
+ # ── Which runner, and what it costs ──────────────────────────────────────────────
9
+ #
10
+ # GitHub-hosted runners are free for PUBLIC repositories only. In a PRIVATE
11
+ # repository every minute is metered against the account's included Actions minutes
12
+ # and billed beyond them -- and image builds are the most expensive job most
13
+ # projects run. A self-hosted runner consumes no Actions minutes at all, whatever
14
+ # the repository's visibility.
15
+ #
16
+ # So the runner is a variable rather than a constant:
17
+ #
18
+ # PODOKIT_RUNNER repository or organization variable, a JSON array of labels.
19
+ # Unset -> ["ubuntu-latest"], which is right for a public
20
+ # repository and costs money in a private one.
21
+ # Self-hosted example: ["self-hosted","Linux","X64"]
22
+ #
23
+ # Set it before the first tag if this repository is private. Nothing here warns you
24
+ # when you have not: the workflow succeeds either way, and the difference shows up
25
+ # on a bill.
26
+ #
27
+ # ── The other two variables ──────────────────────────────────────────────────────
28
+ #
29
+ # PODOKIT_DEPLOY_PROFILE which .podokit/deploy/<name>.json names the image
30
+ # repositories. Unset -> production.
31
+ # PODOKIT_IMAGE_PLATFORM the architecture the deployment target runs.
32
+ # Unset -> linux/amd64. Building for the wrong one
33
+ # produces "exec format error" at rollout, after the
34
+ # migration has already run.
35
+ #
36
+ # ── Secrets ──────────────────────────────────────────────────────────────────────
37
+ #
38
+ # REGISTRY_USERNAME / REGISTRY_PASSWORD credentials for the registry named by
39
+ # the profile. A robot or deploy account is enough, but note that some registries
40
+ # refuse to let a robot CREATE a repository -- push the first tag of a new
41
+ # repository with an account that can, or create it in the registry first.
42
+
43
+ name: release
44
+
45
+ on:
46
+ push:
47
+ tags:
48
+ - "v*.*.*"
49
+
50
+ concurrency:
51
+ group: release-${{ github.ref }}
52
+ cancel-in-progress: false
53
+
54
+ jobs:
55
+ images:
56
+ # One job builds both images rather than a matrix of two. A self-hosted pool is
57
+ # usually small, and fanning out here takes runners away from every other
58
+ # repository that shares it for no wall-clock gain on a multi-core machine.
59
+ runs-on: ${{ fromJSON(vars.PODOKIT_RUNNER || '["ubuntu-latest"]') }}
60
+ steps:
61
+ - uses: actions/checkout@v5
62
+
63
+ - uses: actions/setup-node@v6
64
+ with:
65
+ node-version: 22
66
+ cache: npm
67
+
68
+ - run: npm ci --no-audit --no-fund
69
+
70
+ # Verify before publishing. An image built from a commit that never passed is
71
+ # indistinguishable from one that did, once it is in the registry.
72
+ - run: npm run lint
73
+ - run: npm test
74
+
75
+ - name: Read the release target from the deployment profile
76
+ id: profile
77
+ run: |
78
+ node --input-type=module -e '
79
+ import { readFileSync } from "node:fs";
80
+ const name = process.env.PROFILE || "production";
81
+ const path = `.podokit/deploy/${name}.json`;
82
+ let profile;
83
+ try {
84
+ profile = JSON.parse(readFileSync(path, "utf8"));
85
+ } catch {
86
+ throw new Error(`No deployment profile at ${path}. Run podo deploy init first.`);
87
+ }
88
+ const { apiRepository, webRepository } = profile.release ?? {};
89
+ if (!apiRepository || !webRepository) {
90
+ throw new Error(`${path} does not declare release.apiRepository and release.webRepository.`);
91
+ }
92
+ // Everything before the first slash is a registry host only when it
93
+ // looks like one; otherwise the reference is a Docker Hub short name.
94
+ const first = apiRepository.split("/")[0];
95
+ const registry = /[.:]/.test(first) ? first : "";
96
+ const out = [
97
+ `api=${apiRepository}`,
98
+ `web=${webRepository}`,
99
+ `registry=${registry}`,
100
+ `tag=${process.env.GITHUB_REF_NAME}`,
101
+ ].join("\n");
102
+ process.stdout.write(out + "\n");
103
+ ' >> "$GITHUB_OUTPUT"
104
+ env:
105
+ PROFILE: ${{ vars.PODOKIT_DEPLOY_PROFILE }}
106
+
107
+ - uses: docker/setup-buildx-action@v3
108
+
109
+ - uses: docker/login-action@v3
110
+ with:
111
+ registry: ${{ steps.profile.outputs.registry }}
112
+ username: ${{ secrets.REGISTRY_USERNAME }}
113
+ password: ${{ secrets.REGISTRY_PASSWORD }}
114
+
115
+ - name: Build and push the API image
116
+ uses: docker/build-push-action@v6
117
+ with:
118
+ context: .
119
+ file: apps/api/Dockerfile
120
+ platforms: ${{ vars.PODOKIT_IMAGE_PLATFORM || 'linux/amd64' }}
121
+ push: true
122
+ tags: ${{ steps.profile.outputs.api }}:${{ steps.profile.outputs.tag }}
123
+ cache-from: type=gha,scope=api
124
+ cache-to: type=gha,scope=api,mode=max
125
+
126
+ - name: Build and push the web image
127
+ uses: docker/build-push-action@v6
128
+ with:
129
+ context: .
130
+ file: apps/web/Dockerfile
131
+ platforms: ${{ vars.PODOKIT_IMAGE_PLATFORM || 'linux/amd64' }}
132
+ push: true
133
+ tags: ${{ steps.profile.outputs.web }}:${{ steps.profile.outputs.tag }}
134
+ cache-from: type=gha,scope=web
135
+ cache-to: type=gha,scope=web,mode=max
136
+
137
+ - name: Report what to deploy
138
+ run: |
139
+ echo "Pushed ${{ steps.profile.outputs.api }}:${{ steps.profile.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
140
+ echo "Pushed ${{ steps.profile.outputs.web }}:${{ steps.profile.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
141
+ echo "" >> "$GITHUB_STEP_SUMMARY"
142
+ echo 'Roll out with `podo deploy plan --profile <name> --release ${{ steps.profile.outputs.tag }}`, then apply the printed hash.' >> "$GITHUB_STEP_SUMMARY"