diff --git a/.github/workflows/cloudflare-main.yml b/.github/workflows/cloudflare-main.yml index 9e038a4..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 }} @@ -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: @@ -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 33ef01d..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 @@ -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: @@ -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 e32daa9..52d3ace 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: @@ -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 @@ -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 }} @@ -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" \ @@ -238,7 +248,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/.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 diff --git a/AGENTS.md b/AGENTS.md index 5fb3c8c..69ee6cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,21 @@ 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 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/scripts/policy.bundle.mjs b/scripts/policy.bundle.mjs index bf0b66c..e9b20e6 100644 --- a/scripts/policy.bundle.mjs +++ b/scripts/policy.bundle.mjs @@ -1411,6 +1411,24 @@ 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:" || + (target.publicUrl !== url.origin && + target.publicUrl !== `${url.origin}/`) + ) { + 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..17f6d26 100644 --- a/scripts/policy.mjs +++ b/scripts/policy.mjs @@ -489,6 +489,28 @@ 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`); + } + // 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:" || + (target.publicUrl !== url.origin && + target.publicUrl !== `${url.origin}/`) + ) { + 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..5d13774 100644 --- a/tests/policy.test.mjs +++ b/tests/policy.test.mjs @@ -477,6 +477,32 @@ 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); + 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(); + 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..9b890c7 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -355,9 +355,98 @@ 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/); + assert.match(source, /targets\[inputs\.target\]\.publicUrl/); assert.doesNotMatch(source, /gh variable|gh workflow run|Synchronize/); assert.doesNotMatch(source, /::warning::/); });