From 84773155569ab0a8ea11db75f81cad902e6d41eb Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:30:31 +0200 Subject: [PATCH 1/5] Let a Phala target name the public URL it serves A service that terminates TLS inside the CVM (dstack-ingress with TLS passthrough) does not answer on the gateway-terminated URL Phala reports, so the health check fails even when the deploy succeeded. A target can now declare publicUrl, a bare https origin, and the health check and the deployment-url output use it. --- .github/workflows/phala-deploy.yml | 9 +++++++++ AGENTS.md | 6 ++++++ scripts/policy.bundle.mjs | 21 +++++++++++++++++++++ scripts/policy.mjs | 21 +++++++++++++++++++++ tests/policy.test.mjs | 18 ++++++++++++++++++ tests/workflows.test.mjs | 1 + 6 files changed, 76 insertions(+) diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index e32daa9..c50afa7 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -238,7 +238,16 @@ jobs: id: phala-url env: CVM_NAME: ${{ fromJSON(needs.policy.outputs.phala).targets[inputs.target].cvmName }} + PUBLIC_URL: ${{ fromJSON(needs.policy.outputs.phala).targets[inputs.target].publicUrl }} run: | + # A target that terminates TLS inside the CVM (dstack-ingress, TLS + # passthrough) is not reachable at the URL Phala reports: that one + # expects the gateway to terminate TLS. Such a target names the URL it + # actually serves in policy, and the health check goes there instead. + if [ -n "$PUBLIC_URL" ]; then + echo "deployment-url=${PUBLIC_URL%/}" >> "$GITHUB_OUTPUT" + exit 0 + fi PHALA_API_KEY="$(<"$RUNNER_TEMP/phala-api-key")" npx --yes phala@1.1.20 cvms get "$CVM_NAME" --json --api-key "$PHALA_API_KEY" > "$RUNNER_TEMP/phala-cvm.json" deployment_url="$(jq -r ' diff --git a/AGENTS.md b/AGENTS.md index 5fb3c8c..b8eccdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,12 @@ Missing declared values fail before build or deploy. Credential names, dstack registry variables, and `image.composeVariable` are reserved and cannot also be runtime configuration. +A target may add `publicUrl`, a bare `https://` origin. The health check and +the returned `deployment-url` use it instead of the URL Phala reports. It exists +for services that terminate TLS inside the CVM (dstack-ingress with TLS +passthrough): Phala reports a gateway-terminated URL those services do not +answer on. + The workflow returns `deployment-url` and does not mutate GitHub variables or dispatch another workflow. Application-specific URL propagation belongs in a caller job that consumes this output. diff --git a/scripts/policy.bundle.mjs b/scripts/policy.bundle.mjs index bf0b66c..38568cf 100644 --- a/scripts/policy.bundle.mjs +++ b/scripts/policy.bundle.mjs @@ -1411,6 +1411,27 @@ function validatePhalaPolicy(policy) { `Phala targets.${role}.githubEnvironment`, ); requireString(target.cvmName, `Phala targets.${role}.cvmName`); + if (target.publicUrl !== void 0) { + requireString(target.publicUrl, `Phala targets.${role}.publicUrl`); + let url; + try { + url = new URL(target.publicUrl); + } catch { + throw new Error(`Phala targets.${role}.publicUrl must be a URL`); + } + if ( + url.protocol !== "https:" || + url.pathname !== "/" || + url.search || + url.hash || + url.username || + url.password + ) { + throw new Error( + `Phala targets.${role}.publicUrl must be a bare https:// origin`, + ); + } + } if (!/^[a-z](?!.*--)[a-z0-9-]{3,61}[a-z0-9]$/.test(target.cvmName)) { throw new Error( `Phala targets.${role}.cvmName must be 5-63 characters, start with a lowercase letter, end with a letter or digit, and contain no consecutive hyphens`, diff --git a/scripts/policy.mjs b/scripts/policy.mjs index 1952d28..34d6fe8 100644 --- a/scripts/policy.mjs +++ b/scripts/policy.mjs @@ -489,6 +489,27 @@ export function validatePhalaPolicy(policy) { `Phala targets.${role}.githubEnvironment`, ); requireString(target.cvmName, `Phala targets.${role}.cvmName`); + if (target.publicUrl !== undefined) { + requireString(target.publicUrl, `Phala targets.${role}.publicUrl`); + let url; + try { + url = new URL(target.publicUrl); + } catch { + throw new Error(`Phala targets.${role}.publicUrl must be a URL`); + } + if ( + url.protocol !== "https:" || + url.pathname !== "/" || + url.search || + url.hash || + url.username || + url.password + ) { + throw new Error( + `Phala targets.${role}.publicUrl must be a bare https:// origin`, + ); + } + } if (!/^[a-z](?!.*--)[a-z0-9-]{3,61}[a-z0-9]$/.test(target.cvmName)) { throw new Error( `Phala targets.${role}.cvmName must be 5-63 characters, start with a lowercase letter, end with a letter or digit, and contain no consecutive hyphens`, diff --git a/tests/policy.test.mjs b/tests/policy.test.mjs index 5e2ce34..4d03f7e 100644 --- a/tests/policy.test.mjs +++ b/tests/policy.test.mjs @@ -477,6 +477,24 @@ test("Phala requires concrete deployment and image fields", () => { } }); +test("Phala targets may name the public URL they serve", () => { + const policy = phalaPolicy(); + policy.targets.release.publicUrl = "https://service.example.com"; + assert.deepEqual(validatePhalaPolicy(structuredClone(policy)), policy); + for (const publicUrl of [ + "", + "service.example.com", + "http://service.example.com", + "https://service.example.com/health", + "https://service.example.com?x=1", + "https://user@service.example.com", + ]) { + const invalid = phalaPolicy(); + invalid.targets.candidate.publicUrl = publicUrl; + assert.throws(() => validatePhalaPolicy(invalid), /publicUrl/); + } +}); + test("Phala environment forwarding is explicit and cannot include credentials", () => { for (const name of [ "PHALA_CLOUD_API_KEY", diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index a42f355..d20b1e6 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -358,6 +358,7 @@ test("Phala deployment is serialized and updates CVMs by id", () => { test("Phala health checks fail closed and URL propagation stays caller-owned", () => { const source = fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"); assert.match(source, /health check failed after 30 attempts/); + assert.match(source, /targets\[inputs\.target\]\.publicUrl/); assert.doesNotMatch(source, /gh variable|gh workflow run|Synchronize/); assert.doesNotMatch(source, /::warning::/); }); From d7e4d93a212430e14a60f811f6bb07e7e93d1c98 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:40:47 +0200 Subject: [PATCH 2/5] Compare publicUrl as written, not as parsed URL parsing normalizes away an empty `?` or `#`, surrounding whitespace, and embedded tabs and newlines, so a value like `https://host?` parsed to a bare origin and passed. The workflow uses the raw string and appends the health path, which would have requested `https://host?/health`. The validator now requires the raw value to equal the parsed origin, with or without one trailing slash. The deployment-url output described the URL Phala reports; with publicUrl set it is the policy's endpoint, so it now says it is the endpoint that passed the health check. --- .github/workflows/phala-deploy.yml | 2 +- scripts/policy.bundle.mjs | 7 ++----- scripts/policy.mjs | 11 ++++++----- tests/policy.test.mjs | 8 ++++++++ 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index c50afa7..6cc8e35 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -9,7 +9,7 @@ on: type: string outputs: deployment-url: - description: Public HTTPS endpoint reported by Phala + description: Public HTTPS endpoint that passed the health check (the target's publicUrl when set, otherwise the one Phala reports) value: ${{ jobs.deploy.outputs.deployment-url }} permissions: diff --git a/scripts/policy.bundle.mjs b/scripts/policy.bundle.mjs index 38568cf..e9b20e6 100644 --- a/scripts/policy.bundle.mjs +++ b/scripts/policy.bundle.mjs @@ -1421,11 +1421,8 @@ function validatePhalaPolicy(policy) { } if ( url.protocol !== "https:" || - url.pathname !== "/" || - url.search || - url.hash || - url.username || - url.password + (target.publicUrl !== url.origin && + target.publicUrl !== `${url.origin}/`) ) { throw new Error( `Phala targets.${role}.publicUrl must be a bare https:// origin`, diff --git a/scripts/policy.mjs b/scripts/policy.mjs index 34d6fe8..17f6d26 100644 --- a/scripts/policy.mjs +++ b/scripts/policy.mjs @@ -497,13 +497,14 @@ export function validatePhalaPolicy(policy) { } catch { throw new Error(`Phala targets.${role}.publicUrl must be a URL`); } + // Compare the raw value, not the parsed one: the workflow uses the raw + // string, and URL parsing drops what it normalizes away (an empty `?` or + // `#`, surrounding whitespace, embedded tabs and newlines), so a value + // that parses to a bare origin can still request something else. if ( url.protocol !== "https:" || - url.pathname !== "/" || - url.search || - url.hash || - url.username || - url.password + (target.publicUrl !== url.origin && + target.publicUrl !== `${url.origin}/`) ) { throw new Error( `Phala targets.${role}.publicUrl must be a bare https:// origin`, diff --git a/tests/policy.test.mjs b/tests/policy.test.mjs index 4d03f7e..5d13774 100644 --- a/tests/policy.test.mjs +++ b/tests/policy.test.mjs @@ -481,12 +481,20 @@ test("Phala targets may name the public URL they serve", () => { const policy = phalaPolicy(); policy.targets.release.publicUrl = "https://service.example.com"; assert.deepEqual(validatePhalaPolicy(structuredClone(policy)), policy); + policy.targets.candidate.publicUrl = "https://service.example.com/"; + assert.deepEqual(validatePhalaPolicy(structuredClone(policy)), policy); for (const publicUrl of [ "", "service.example.com", "http://service.example.com", "https://service.example.com/health", "https://service.example.com?x=1", + "https://service.example.com?", + "https://service.example.com#", + "https://service.example.com/?", + " https://service.example.com", + "https://service.example.com\n", + "https://service.exam\nple.com", "https://user@service.example.com", ]) { const invalid = phalaPolicy(); From 3332fc8e0049b837e851778cb1fb2be1f24acb1a Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:41:18 +0200 Subject: [PATCH 3/5] Advance policy-tool refs to the publicUrl implementation The workflows check the policy scripts out by SHA, so until these move a consumer runs the v1.6.0 validator: publicUrl would go unvalidated into the health check and deployment-url. The flow callers move next, to this commit. --- .github/workflows/cloudflare-main.yml | 2 +- .github/workflows/npm-main.yml | 2 +- .github/workflows/phala-deploy.yml | 2 +- .github/workflows/required-quality.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cloudflare-main.yml b/.github/workflows/cloudflare-main.yml index 9e038a4..d6f70d3 100644 --- a/.github/workflows/cloudflare-main.yml +++ b/.github/workflows/cloudflare-main.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + ref: d7e4d93a212430e14a60f811f6bb07e7e93d1c98 # v1.7.0 path: .burnt-workflows - name: Read stable release tags env: diff --git a/.github/workflows/npm-main.yml b/.github/workflows/npm-main.yml index 33ef01d..ef20bf9 100644 --- a/.github/workflows/npm-main.yml +++ b/.github/workflows/npm-main.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + ref: d7e4d93a212430e14a60f811f6bb07e7e93d1c98 # v1.7.0 path: .burnt-workflows - name: Read stable release tags env: diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index 6cc8e35..49d376c 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -39,7 +39,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + ref: d7e4d93a212430e14a60f811f6bb07e7e93d1c98 # v1.7.0 path: .burnt-workflows - id: policy name: Require and validate Phala policy diff --git a/.github/workflows/required-quality.yml b/.github/workflows/required-quality.yml index aa06a37..7558571 100644 --- a/.github/workflows/required-quality.yml +++ b/.github/workflows/required-quality.yml @@ -81,7 +81,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + ref: d7e4d93a212430e14a60f811f6bb07e7e93d1c98 # v1.7.0 path: .burnt-workflows - id: policy name: Validate policies From 081e88f5bd03eee5df02432116ed54cf403002d0 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:41:19 +0200 Subject: [PATCH 4/5] Point flow callers at the pin-advance revision A caller pinned at the implementation commit would still resolve the policy scripts from v1.6.0. The callers name the pin advance; the policy-tool refs name where the scripts landed. --- .github/workflows/cloudflare-main.yml | 8 ++++---- .github/workflows/cloudflare-pr.yml | 4 ++-- .github/workflows/cloudflare-release.yml | 6 +++--- .github/workflows/npm-changesets.yml | 2 +- .github/workflows/npm-main.yml | 6 +++--- .github/workflows/npm-pr.yml | 2 +- .github/workflows/npm-release.yml | 4 ++-- .github/workflows/phala-deploy.yml | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/cloudflare-main.yml b/.github/workflows/cloudflare-main.yml index d6f70d3..44f00dd 100644 --- a/.github/workflows/cloudflare-main.yml +++ b/.github/workflows/cloudflare-main.yml @@ -27,7 +27,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} deployment-policy-path: ${{ inputs.deployment-policy-path }} @@ -76,7 +76,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: # Under single topology the candidate and the release are the same # Worker, so deploying here would serve the merge immediately and there @@ -98,7 +98,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: operation: preview target: release @@ -184,7 +184,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: operation: deploy target: release diff --git a/.github/workflows/cloudflare-pr.yml b/.github/workflows/cloudflare-pr.yml index 0cf8a1c..1b1a2f8 100644 --- a/.github/workflows/cloudflare-pr.yml +++ b/.github/workflows/cloudflare-pr.yml @@ -31,7 +31,7 @@ jobs: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} deployment-policy-path: ${{ inputs.deployment-policy-path }} @@ -62,7 +62,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: operation: preview target: candidate diff --git a/.github/workflows/cloudflare-release.yml b/.github/workflows/cloudflare-release.yml index 2d8c332..9d1b63c 100644 --- a/.github/workflows/cloudflare-release.yml +++ b/.github/workflows/cloudflare-release.yml @@ -56,7 +56,7 @@ jobs: quality: needs: validate - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} deployment-policy-path: ${{ inputs.deployment-policy-path }} @@ -89,7 +89,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: operation: preview target: release @@ -108,7 +108,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: operation: deploy target: release diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index 7e77217..c708188 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -96,7 +96,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} diff --git a/.github/workflows/npm-main.yml b/.github/workflows/npm-main.yml index ef20bf9..892f0b3 100644 --- a/.github/workflows/npm-main.yml +++ b/.github/workflows/npm-main.yml @@ -8,7 +8,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 metadata: needs: quality @@ -107,7 +107,7 @@ jobs: permissions: contents: read id-token: write - uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy: ${{ needs.quality.outputs.quality-policy }} npm-policy: ${{ needs.quality.outputs.npm-policy }} @@ -171,7 +171,7 @@ jobs: permissions: contents: read id-token: write - uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy: ${{ needs.quality.outputs.quality-policy }} npm-policy: ${{ needs.quality.outputs.npm-policy }} diff --git a/.github/workflows/npm-pr.yml b/.github/workflows/npm-pr.yml index 4456cf4..d7312ae 100644 --- a/.github/workflows/npm-pr.yml +++ b/.github/workflows/npm-pr.yml @@ -8,7 +8,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 pack: name: Package dry run diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index d578359..ccf3a6c 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -15,7 +15,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 metadata: needs: quality @@ -52,7 +52,7 @@ jobs: permissions: contents: read id-token: write - uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 with: quality-policy: ${{ needs.quality.outputs.quality-policy }} npm-policy: ${{ needs.quality.outputs.npm-policy }} diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index 49d376c..307cec8 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -47,7 +47,7 @@ jobs: quality: needs: policy - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@3332fc8e0049b837e851778cb1fb2be1f24acb1a # v1.7.0 deploy: name: Phala ${{ inputs.target }} From 01ce81bc7dd2f284bbcc0bc7c3aebe976e788adc Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:02:48 +0200 Subject: [PATCH 5/5] Refuse to create the release CVM The deploy looked the target's CVM up by name and created one on a miss, for either role. A release CVM's identity lives outside this flow: DNS names its app id and relying-party allowlists name its measurements. A release CVM deleted or renamed after a caller's own checks came back as a new instance nothing points at, and the run reported success. Only the candidate is created on a miss now. A release deploy that finds no CVM of that name fails; the release CVM is provisioned by hand once. The rule is unconditional rather than a policy knob. --- .github/workflows/phala-deploy.yml | 12 +++- AGENTS.md | 9 +++ README.md | 5 +- tests/workflows.test.mjs | 88 ++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index 307cec8..52d3ace 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -201,8 +201,15 @@ jobs: fs.appendFileSync(process.env.GITHUB_OUTPUT, `file=${target}\n`); process.stdout.write(`Deploying ${reference}\n`); NODE + # Only a candidate CVM is created on a miss. A release CVM carries + # identity that lives outside this flow: DNS that names its app id, and + # relying-party allowlists that name its measurements. Creating a new one + # because the named CVM was deleted or renamed would deploy an instance + # none of that points at, and report success. A release target is + # provisioned by hand once, then only ever updated by id here. - name: Deploy CVM env: + TARGET_ROLE: ${{ inputs.target }} CVM_NAME: ${{ fromJSON(needs.policy.outputs.phala).targets[inputs.target].cvmName }} COMPOSE_FILE: ${{ steps.compose.outputs.file }} WORKING_DIRECTORY: ${{ fromJSON(needs.policy.outputs.phala).workingDirectory }} @@ -225,8 +232,11 @@ jobs: exit 1 fi target=(--cvm-id "$existing_id") - else + elif [ "$TARGET_ROLE" = "candidate" ]; then target=(-n "$CVM_NAME") + else + echo "::error::No Phala CVM is named $CVM_NAME. The $TARGET_ROLE target is never created by this workflow; provision it by hand, or restore the name, then rerun" + exit 1 fi npx --yes phala@1.1.20 deploy \ -c "$COMPOSE_FILE" \ diff --git a/AGENTS.md b/AGENTS.md index b8eccdb..69ee6cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,15 @@ for services that terminate TLS inside the CVM (dstack-ingress with TLS passthrough): Phala reports a gateway-terminated URL those services do not answer on. +The deploy looks the target's CVM up by exact name and updates it by id. Only +the candidate is created when no CVM has that name. **The release CVM is never +created by this workflow**: a miss fails the deploy. A release CVM's identity +lives outside this flow (DNS naming its app id, relying-party allowlists naming +its measurements), so a CVM deleted or renamed between a caller's checks and +the deploy must not come back as a new instance that nothing points at while +the run reports success. Provision the release CVM by hand once, before its +first release deploy; this is unconditional rather than a policy knob. + The workflow returns `deployment-url` and does not mutate GitHub variables or dispatch another workflow. Application-specific URL propagation belongs in a caller job that consumes this output. diff --git a/README.md b/README.md index 2d4c10e..070159c 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,10 @@ pnpm 10 already default-denies build scripts, so pnpm consumers see no change. `phala-deploy.yml` is an application-neutral deployment primitive. It requires the repository's quality gates, builds a commit-addressed private GHCR image, deploys or updates the selected Phala CVM, resolves and health-checks its public -HTTPS endpoint, and returns that URL to the caller. It does not know about a +HTTPS endpoint, and returns that URL to the caller. Only the candidate CVM is +created when missing; the release CVM must already exist (provision it by hand +once), and a release deploy that finds no CVM of that name fails rather than +creating one. It does not know about a consumer's service names, secret bundle format, GitHub variables, or dependent workflows. diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index d20b1e6..9b890c7 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -355,6 +355,94 @@ test("Phala deployment is serialized and updates CVMs by id", () => { assert.match(source, /has no id/); }); +test("Phala never creates the release CVM", (t) => { + // A release CVM's identity lives outside this flow: DNS names its app id and + // relying-party allowlists name its measurements. If the named CVM is gone + // by the time the deploy looks it up, creating a new one serves an instance + // none of that points at, and the run still goes green. Only a candidate may + // be created on a miss; a release is updated by id or the deploy fails. + const workflow = parse( + fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"), + ); + const deploy = workflow.jobs.deploy.steps.find( + (step) => step.name === "Deploy CVM", + ); + assert.equal(deploy.env.TARGET_ROLE, "${{ inputs.target }}"); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "phala-deploy-")); + t.after(() => fs.rmSync(root, { recursive: true })); + const bin = path.join(root, "bin"); + fs.mkdirSync(bin); + // Stands in for `npx --yes phala@1.1.20 …`: answers `cvms list` from a + // fixture and records every `deploy` argument vector, one per line. + fs.writeFileSync( + path.join(bin, "npx"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "shift 2 # --yes phala@1.1.20", + 'case "$1" in', + ' cvms) cat "$FIXTURE" ;;', + ' deploy) shift; printf "%s\\n" "$*" >> "$DEPLOY_LOG" ;;', + ' *) echo "unexpected phala command: $*" >&2; exit 2 ;;', + "esac", + "", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync(path.join(root, "phala-api-key"), "test-key"); + + const cvm = (name, id) => ({ name, id }); + const cases = [ + { role: "release", items: [], status: 1 }, + // A near-miss name is not the CVM; the search is a substring match. + { role: "release", items: [cvm("service-production-old", "a")], status: 1 }, + { role: "release", items: [cvm("service-production", "b")], status: 0 }, + { role: "candidate", items: [], status: 0 }, + { role: "candidate", items: [cvm("service-production", "c")], status: 0 }, + // Anything that is not the candidate role is refused creation too. + { role: "", items: [], status: 1 }, + ]; + for (const [index, { role, items, status }] of cases.entries()) { + const label = `${role || "(empty)"} with ${JSON.stringify(items)}`; + const fixture = path.join(root, `cvms-${index}.json`); + const deployLog = path.join(root, `deploy-${index}.log`); + fs.writeFileSync(fixture, JSON.stringify({ items })); + fs.writeFileSync(deployLog, ""); + const result = spawnSync("bash", ["-euo", "pipefail", "-c", deploy.run], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + RUNNER_TEMP: root, + WORKING_DIRECTORY: root, + TARGET_ROLE: role, + CVM_NAME: "service-production", + COMPOSE_FILE: path.join(root, "compose.yaml"), + FIXTURE: fixture, + DEPLOY_LOG: deployLog, + }, + }); + assert.equal(result.status, status, `${label}: ${result.stderr}`); + const deploys = fs.readFileSync(deployLog, "utf8"); + const existing = items.find((item) => item.name === "service-production"); + + if (role !== "candidate") { + // The release path never passes -n, whatever the lookup returned. + assert.doesNotMatch(deploys, /(^| )-n( |$)/m, label); + } + if (status !== 0) { + assert.equal(deploys, "", `${label}: nothing may be deployed`); + assert.match(result.stdout, /::error::No Phala CVM is named/, label); + } else if (existing) { + assert.match(deploys, new RegExp(`--cvm-id ${existing.id} `), label); + assert.doesNotMatch(deploys, /(^| )-n( |$)/m, label); + } else { + assert.match(deploys, /(^| )-n service-production /, label); + } + } +}); + test("Phala health checks fail closed and URL propagation stays caller-owned", () => { const source = fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"); assert.match(source, /health check failed after 30 attempts/);