From ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:20:49 +0200 Subject: [PATCH 1/4] Put the image digest inside the attested compose identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dstack hashes the compose text it is handed, and that hash is the only part of the attested identity that says anything about the application — the platform measurement covers the base dstack OS image and nothing more. Delivering the image as a CVM environment variable left that hash binding the topology and not the bytes: anyone able to repoint the GHCR tag could have a CVM run different code under an unchanged attested identity, and every allowlist check would still pass. The image is now substituted into the compose file before the deploy, by digest rather than by the commit tag, since a tag can be repointed after the fact. The digest comes from the push itself. It no longer travels in the environment file: two sources for one fact is how they drift apart. A compose file may spell the placeholder ${VAR} or ${VAR:?message}. One that references neither fails the deploy, as does one where the variable name survives substitution — an unexpanded placeholder reaching dstack would be measured as a literal and pull nothing. The intended consequence is that the compose hash now changes on every deploy, so relying-party allowlists are re-pinned per deploy. That is what pinning means; the previous stability came from measuring nothing that varied. --- .github/workflows/phala-deploy.yml | 57 +++++++++++++++++++++++++++--- AGENTS.md | 18 +++++++++- README.md | 6 ++++ tests/workflows.test.mjs | 49 +++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index e643311..6941c64 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -78,7 +78,10 @@ jobs: IMAGE_NAME: ${{ fromJSON(needs.policy.outputs.phala).image.name }} run: | repository="$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" - echo "image=ghcr.io/${repository}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + { + echo "repository=ghcr.io/${repository}/${IMAGE_NAME}" + echo "image=ghcr.io/${repository}/${IMAGE_NAME}:${GITHUB_SHA}" + } >> "$GITHUB_OUTPUT" - name: Collect deployment credentials and runtime configuration env: ALL_SECRETS: ${{ toJSON(secrets) }} @@ -87,8 +90,6 @@ jobs: REGISTRY_PASSWORD_NAME: ${{ fromJSON(needs.policy.outputs.phala).credentials.registryPasswordSecret }} SECRET_NAMES: ${{ toJSON(fromJSON(needs.policy.outputs.phala).runtimeSecrets) }} VARIABLE_NAMES: ${{ toJSON(fromJSON(needs.policy.outputs.phala).runtimeVariables) }} - IMAGE_VARIABLE: ${{ fromJSON(needs.policy.outputs.phala).image.composeVariable }} - IMAGE: ${{ steps.image.outputs.image }} REGISTRY_USERNAME: ${{ fromJSON(needs.policy.outputs.phala).image.registryUsername }} run: | node - <<'NODE' @@ -112,7 +113,6 @@ jobs: const environment = Object.fromEntries([ ...secretNames.map((name) => [name, secrets[name]]), ...variableNames.map((name) => [name, variables[name]]), - [process.env.IMAGE_VARIABLE, process.env.IMAGE], ["DSTACK_DOCKER_REGISTRY", "ghcr.io"], ["DSTACK_DOCKER_USERNAME", process.env.REGISTRY_USERNAME], ["DSTACK_DOCKER_PASSWORD", registryPassword], @@ -145,16 +145,63 @@ jobs: username: ${{ github.actor }} password: ${{ github.token }} - name: Build and push image + id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ fromJSON(needs.policy.outputs.phala).image.context }} file: ${{ fromJSON(needs.policy.outputs.phala).image.dockerfile }} push: true tags: ${{ steps.image.outputs.image }} + # dstack hashes the compose text it is given, and that hash is what a + # relying party's allowlist checks. Substituting the image here rather + # than delivering it as a CVM environment variable is the whole point: + # expanded, the attested compose identity names the exact bytes running; + # left as a variable, it names the topology and nothing about the + # application. The reference is by digest, not by the commit tag, because + # a tag can be repointed after the deploy. + - name: Render the compose file + id: compose + env: + COMPOSE_FILE: ${{ fromJSON(needs.policy.outputs.phala).composeFile }} + WORKING_DIRECTORY: ${{ fromJSON(needs.policy.outputs.phala).workingDirectory }} + IMAGE_VARIABLE: ${{ fromJSON(needs.policy.outputs.phala).image.composeVariable }} + IMAGE_REPOSITORY: ${{ steps.image.outputs.repository }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + cd "$WORKING_DIRECTORY" + node - <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const variable = process.env.IMAGE_VARIABLE; + const digest = process.env.IMAGE_DIGEST; + if (!/^sha256:[0-9a-f]{64}$/.test(digest ?? "")) { + process.stderr.write(`::error::The image push reported no usable digest: ${digest}\n`); + process.exit(1); + } + const reference = `${process.env.IMAGE_REPOSITORY}@${digest}`; + const source = fs.readFileSync(process.env.COMPOSE_FILE, "utf8"); + // Both spellings a compose file may use for the placeholder: the bare + // ${VAR} and the required form ${VAR:?message}. Anything else that + // mentions the variable is left alone and caught by the check below. + const placeholder = new RegExp(`\\$\\{${variable}(:[-?][^}]*)?\\}`, "g"); + const rendered = source.replace(placeholder, reference); + if (rendered === source) { + process.stderr.write(`::error::${process.env.COMPOSE_FILE} does not reference \${${variable}}\n`); + process.exit(1); + } + if (rendered.includes(variable)) { + process.stderr.write(`::error::${variable} still appears in the rendered compose file\n`); + process.exit(1); + } + const target = path.join(process.env.RUNNER_TEMP, "phala-compose.yaml"); + fs.writeFileSync(target, rendered); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `file=${target}\n`); + process.stdout.write(`Deploying ${reference}\n`); + NODE - name: Deploy CVM env: CVM_NAME: ${{ fromJSON(needs.policy.outputs.phala).targets[inputs.target].cvmName }} - COMPOSE_FILE: ${{ fromJSON(needs.policy.outputs.phala).composeFile }} + COMPOSE_FILE: ${{ steps.compose.outputs.file }} WORKING_DIRECTORY: ${{ fromJSON(needs.policy.outputs.phala).workingDirectory }} run: | cd "$WORKING_DIRECTORY" diff --git a/AGENTS.md b/AGENTS.md index e219e36..6a7dd3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,7 +157,23 @@ orchestration. ``` The image is always tagged with the deploying commit SHA and pushed to GHCR. -The Actions job token pushes it. The secret named by +The Actions job token pushes it. + +`image.composeVariable` is expanded into the compose file before the deploy, +not delivered to the CVM as an environment variable. dstack hashes the compose +text it receives, and that hash is the only part of the attested identity that +says anything about the application — the platform measurement covers the base +dstack OS image. Left as a variable, the compose hash is stable across deploys +and binds the topology while the image arrives outside anything measured, so an +overwritten tag would run different bytes under an unchanged attested identity. +Expanded, and expanded _by digest_ rather than by the commit tag, the hash names +the exact bytes running. The consequence is intended: the compose hash changes +on every deploy, so a relying party's allowlist is re-pinned per deploy. That is +what pinning means. + +A compose file may write the placeholder as `${VAR}` or `${VAR:?message}`. A +compose file that does not reference the variable fails the deploy, as does one +where the name survives substitution. The secret named by `credentials.registryPasswordSecret` is a separate durable read-package credential sealed into the CVM for future pulls. Never substitute the ephemeral job token for that credential. diff --git a/README.md b/README.md index ee4d997..2d4c10e 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,12 @@ jobs: secrets: inherit ``` +`image.composeVariable` is substituted into the compose file before the deploy, +by digest, so the compose hash dstack measures names the exact image running. It +is not delivered to the CVM as an environment variable. The compose hash +therefore changes on every deploy and relying-party allowlists are re-pinned per +deploy. + The credential fields and `runtimeSecrets` select exact names from the target GitHub Environment's inherited secrets; `runtimeVariables` selects exact names from its variables. A missing declared value fails before build or deploy. The diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 2f26a65..28cd20e 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -272,6 +272,55 @@ test("Phala seals only policy-allowlisted runtime configuration", () => { assert.doesNotMatch(source, /environment-json|CUE_|TEE_SERVICE_URL/); }); +test("Phala deploys a compose file that names the image by digest", () => { + // The compose text is what dstack hashes, and that hash is the only link + // between a relying party's allowlist and the application. Delivering the + // image as a CVM environment variable instead leaves the attested identity + // covering the topology and nothing about which build is running. + const workflow = parse( + fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"), + ); + const steps = workflow.jobs.deploy.steps; + const render = steps.find((step) => step.name === "Render the compose file"); + const deploy = steps.find((step) => step.name === "Deploy CVM"); + const collect = steps.find( + (step) => + step.name === "Collect deployment credentials and runtime configuration", + ); + + assert.ok(render, "the compose file must be rendered before the deploy"); + assert.match(render.env.IMAGE_DIGEST, /steps\.build\.outputs\.digest/); + assert.match(render.env.IMAGE_VARIABLE, /composeVariable/); + // By digest, never by the commit tag: a tag can be repointed after deploy, + // which is the whole failure this closes. + assert.match(render.run, /sha256:\[0-9a-f\]\{64\}/); + assert.match(render.run, /IMAGE_REPOSITORY\}@\$\{digest\}/); + + // Silence is the danger: a placeholder that does not match must fail rather + // than deploy a compose file with an unexpanded variable in it. + assert.match(render.run, /does not reference/); + assert.match(render.run, /still appears in the rendered compose file/); + + assert.match(deploy.env.COMPOSE_FILE, /steps\.compose\.outputs\.file/); + assert.doesNotMatch(deploy.env.COMPOSE_FILE, /policy\.outputs/); + + // The image must not also travel as an environment variable; two sources for + // one fact is how they drift apart. + assert.doesNotMatch(collect.run, /IMAGE_VARIABLE/); + assert.equal(collect.env.IMAGE, undefined); + + const build = steps.find((step) => step.name === "Build and push image"); + assert.equal( + build.id, + "build", + "the digest is only available from an id'd step", + ); + assert.ok( + steps.indexOf(build) < steps.indexOf(render), + "the image must be pushed before its digest is substituted", + ); +}); + test("Phala private-image credentials are durable and separate from the push token", () => { const workflow = parse( fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"), From 992944a4d890e517aa314204e5dceb49a42555c3 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:21:09 +0200 Subject: [PATCH 2/4] Advance internal pins to the digest-render implementation Policy-tool refs name the commit the change landed in. The flow callers move next, to this commit, since a uses: has to name a revision where the called workflow is itself correctly pinned. --- .github/workflows/cloudflare-main.yml | 10 +++++----- .github/workflows/cloudflare-pr.yml | 4 ++-- .github/workflows/cloudflare-release.yml | 6 +++--- .github/workflows/npm-changesets.yml | 2 +- .github/workflows/npm-main.yml | 8 ++++---- .github/workflows/npm-pr.yml | 2 +- .github/workflows/npm-release.yml | 4 ++-- .github/workflows/phala-deploy.yml | 4 ++-- .github/workflows/required-quality.yml | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/cloudflare-main.yml b/.github/workflows/cloudflare-main.yml index 86ee969..a664d94 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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: cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 with: operation: deploy target: release diff --git a/.github/workflows/cloudflare-pr.yml b/.github/workflows/cloudflare-pr.yml index f5ee200..0d94240 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 with: operation: preview target: candidate diff --git a/.github/workflows/cloudflare-release.yml b/.github/workflows/cloudflare-release.yml index 7611e36..2dcf62b 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 with: operation: deploy target: release diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index cce9bf6..e843c3a 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} diff --git a/.github/workflows/npm-main.yml b/.github/workflows/npm-main.yml index 8d4cfce..464dc4d 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 metadata: needs: quality @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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 fcb4da2..c0e7ccc 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 pack: name: Package dry run diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 4c0fa26..aa154fb 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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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 6941c64..a1ee915 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: 8373e9454803608c670431a67bd416178c633144 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.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@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 deploy: name: Phala ${{ inputs.target }} diff --git a/.github/workflows/required-quality.yml b/.github/workflows/required-quality.yml index 38e9fce..aa06a37 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: 8373e9454803608c670431a67bd416178c633144 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 path: .burnt-workflows - id: policy name: Validate policies From eb84860d995fe455450700dd4a126eab2b0b1d6d Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:21:15 +0200 Subject: [PATCH 3/4] Point flow callers at the pin-advance revision Same reason as v1.5.0: a caller pinned at the commit that changed a workflow still resolves that workflow scripts from the previous release. 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 a664d94..9e038a4 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: deploy target: release diff --git a/.github/workflows/cloudflare-pr.yml b/.github/workflows/cloudflare-pr.yml index 0d94240..0cf8a1c 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: preview target: candidate diff --git a/.github/workflows/cloudflare-release.yml b/.github/workflows/cloudflare-release.yml index 2dcf62b..2d8c332 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: deploy target: release diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index e843c3a..7e77217 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} diff --git a/.github/workflows/npm-main.yml b/.github/workflows/npm-main.yml index 464dc4d..33ef01d 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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 c0e7ccc..4456cf4 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 pack: name: Package dry run diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index aa154fb..d578359 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.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 a1ee915..d4a96e0 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@ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 deploy: name: Phala ${{ inputs.target }} From 6d7f0ebaad5ba6fe241ea02c3a7adb33a1a82e10 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:32:12 +0200 Subject: [PATCH 4/4] Reject a default image reference in the compose placeholder The substitution regex accepted ${VAR:-fallback} alongside the two forms the docs named. That form has no business in a compose file whose hash is the attested identity: a default image reference is one a deploy could silently fall back to, which is the opposite of what measuring the file is for. It is rejected now and fails the placeholder check, and the comment and AGENTS.md say why rather than just listing what is allowed. The new test also asserted through a step it had not proven exists, so a missing build step would have surfaced as a TypeError instead of a failed assertion. --- .github/workflows/phala-deploy.yml | 11 +++++++---- AGENTS.md | 9 ++++++--- tests/workflows.test.mjs | 5 +++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index d4a96e0..e32daa9 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -180,10 +180,13 @@ jobs: } const reference = `${process.env.IMAGE_REPOSITORY}@${digest}`; const source = fs.readFileSync(process.env.COMPOSE_FILE, "utf8"); - // Both spellings a compose file may use for the placeholder: the bare - // ${VAR} and the required form ${VAR:?message}. Anything else that - // mentions the variable is left alone and caught by the check below. - const placeholder = new RegExp(`\\$\\{${variable}(:[-?][^}]*)?\\}`, "g"); + // The two spellings a compose file may use: the bare ${VAR} and the + // required form ${VAR:?message}. The default form ${VAR:-fallback} + // is deliberately not matched — a default image reference is one a + // deploy could silently fall back to, which is the opposite of what + // an attested compose identity is for. It fails the check below + // instead. Anything else mentioning the variable is caught there too. + const placeholder = new RegExp(`\\$\\{${variable}(:\\?[^}]*)?\\}`, "g"); const rendered = source.replace(placeholder, reference); if (rendered === source) { process.stderr.write(`::error::${process.env.COMPOSE_FILE} does not reference \${${variable}}\n`); diff --git a/AGENTS.md b/AGENTS.md index 6a7dd3a..5fb3c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,9 +171,12 @@ the exact bytes running. The consequence is intended: the compose hash changes on every deploy, so a relying party's allowlist is re-pinned per deploy. That is what pinning means. -A compose file may write the placeholder as `${VAR}` or `${VAR:?message}`. A -compose file that does not reference the variable fails the deploy, as does one -where the name survives substitution. The secret named by +A compose file may write the placeholder as `${VAR}` or `${VAR:?message}`. +`${VAR:-fallback}` is rejected on purpose: a default image reference is one a +deploy could silently fall back to, which is the opposite of what an attested +compose identity is for. A compose file that does not reference the variable in +an accepted form fails the deploy, as does one where the name survives +substitution. The secret named by `credentials.registryPasswordSecret` is a separate durable read-package credential sealed into the CVM for future pulls. Never substitute the ephemeral job token for that credential. diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 28cd20e..a42f355 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -300,6 +300,10 @@ test("Phala deploys a compose file that names the image by digest", () => { // than deploy a compose file with an unexpanded variable in it. assert.match(render.run, /does not reference/); assert.match(render.run, /still appears in the rendered compose file/); + // ${VAR:-fallback} must not be substituted. A default image reference is one + // a deploy could silently fall back to, which defeats the point of measuring + // the compose file at all; it has to fail the placeholder check instead. + assert.doesNotMatch(render.run, /\[-\?\]/); assert.match(deploy.env.COMPOSE_FILE, /steps\.compose\.outputs\.file/); assert.doesNotMatch(deploy.env.COMPOSE_FILE, /policy\.outputs/); @@ -310,6 +314,7 @@ test("Phala deploys a compose file that names the image by digest", () => { assert.equal(collect.env.IMAGE, undefined); const build = steps.find((step) => step.name === "Build and push image"); + assert.ok(build, "the deploy must build and push the image it deploys"); assert.equal( build.id, "build",