Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/cloudflare-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy-path: ${{ inputs.quality-policy-path }}
deployment-policy-path: ${{ inputs.deployment-policy-path }}
Expand All @@ -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:
Expand Down Expand Up @@ -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@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
Expand All @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
operation: preview
target: release
Expand Down Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
operation: deploy
target: release
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/cloudflare-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy-path: ${{ inputs.quality-policy-path }}
deployment-policy-path: ${{ inputs.deployment-policy-path }}
Expand Down Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
operation: preview
target: candidate
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/cloudflare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy-path: ${{ inputs.quality-policy-path }}
deployment-policy-path: ${{ inputs.deployment-policy-path }}
Expand Down Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
operation: preview
target: release
Expand All @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
operation: deploy
target: release
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/npm-changesets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy-path: ${{ inputs.quality-policy-path }}

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/npm-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0

metadata:
needs: quality
Expand All @@ -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:
Expand Down Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy: ${{ needs.quality.outputs.quality-policy }}
npm-policy: ${{ needs.quality.outputs.npm-policy }}
Expand Down Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy: ${{ needs.quality.outputs.quality-policy }}
npm-policy: ${{ needs.quality.outputs.npm-policy }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/npm-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0

pack:
name: Package dry run
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0

metadata:
needs: quality
Expand Down Expand Up @@ -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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0
with:
quality-policy: ${{ needs.quality.outputs.quality-policy }}
npm-policy: ${{ needs.quality.outputs.npm-policy }}
Expand Down
64 changes: 57 additions & 7 deletions .github/workflows/phala-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ 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
run: node .burnt-workflows/scripts/policy.bundle.mjs --phala

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@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0

deploy:
name: Phala ${{ inputs.target }}
Expand Down Expand Up @@ -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) }}
Expand All @@ -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'
Expand All @@ -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],
Expand Down Expand Up @@ -145,16 +145,66 @@ 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");
// 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`);
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"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/required-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,26 @@ 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}`.
`${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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions tests/workflows.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,60 @@ 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/);
// ${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/);

// 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.ok(build, "the deploy must build and push the image it deploys");
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"),
Expand Down