mirror of
https://github.com/we-promise/sure.git
synced 2026-06-04 02:09:01 +00:00
ci(preview): stabilize Cloudflare preview deployments (#2062)
* ci(preview): stabilize Cloudflare preview deployments * ci(preview): bound diagnostics and cover artifact fallback * ci(preview): isolate artifact deploy permissions * ci(preview): tidy deployment comment rendering * ci(preview): harden preview manifest generation * ci(preview): fail on preview diagnostics failure
This commit is contained in:
24
.github/workflows/pr.yml
vendored
24
.github/workflows/pr.yml
vendored
@@ -38,6 +38,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
image_archive="$RUNNER_TEMP/sure-preview-image.tar.gz"
|
||||
manifest_file="$RUNNER_TEMP/sure-preview-image.manifest.json"
|
||||
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
@@ -48,7 +49,27 @@ jobs:
|
||||
|
||||
docker image inspect "${IMAGE_TAG}" >/dev/null
|
||||
docker save "${IMAGE_TAG}" | gzip -1 > "$image_archive"
|
||||
sha256sum "$image_archive" | awk '{print $1}' > "$RUNNER_TEMP/sure-preview-image.sha256"
|
||||
archive_sha256="$(sha256sum "$image_archive" | awk '{print $1}')"
|
||||
image_id="$(docker image inspect --format '{{.Id}}' "${IMAGE_TAG}")"
|
||||
|
||||
printf '%s\n' "$archive_sha256" > "$RUNNER_TEMP/sure-preview-image.sha256"
|
||||
ARCHIVE_SHA256="$archive_sha256" IMAGE_ID="$image_id" node - "$manifest_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
const manifestPath = process.argv[2];
|
||||
const manifest = {
|
||||
artifactVersion: 1,
|
||||
archivePath: 'sure-preview-image.tar.gz',
|
||||
archiveSha256: process.env.ARCHIVE_SHA256,
|
||||
headSha: process.env.HEAD_SHA,
|
||||
imageId: process.env.IMAGE_ID,
|
||||
imageTag: process.env.IMAGE_TAG,
|
||||
prNumber: process.env.PR_NUMBER,
|
||||
};
|
||||
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
NODE
|
||||
jq -e . "$manifest_file" >/dev/null
|
||||
|
||||
- name: Upload preview image artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -57,5 +78,6 @@ jobs:
|
||||
path: |
|
||||
${{ runner.temp }}/sure-preview-image.tar.gz
|
||||
${{ runner.temp }}/sure-preview-image.sha256
|
||||
${{ runner.temp }}/sure-preview-image.manifest.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
2
.github/workflows/preview-cleanup.yml
vendored
2
.github/workflows/preview-cleanup.yml
vendored
@@ -195,7 +195,7 @@ jobs:
|
||||
echo "Worker $WORKER is older than 24 hours, deleting..."
|
||||
if wrangler delete --name "$WORKER" --force; then
|
||||
# Extract PR number and cleanup GitHub deployment
|
||||
PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//')
|
||||
PR_NUM="${WORKER#sure-preview-}"
|
||||
if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
|
||||
gh api \
|
||||
|
||||
350
.github/workflows/preview-deploy.yml
vendored
350
.github/workflows/preview-deploy.yml
vendored
@@ -23,82 +23,71 @@ jobs:
|
||||
outputs:
|
||||
artifact_name: ${{ steps.preview.outputs.artifact_name }}
|
||||
head_sha: ${{ steps.preview.outputs.head_sha }}
|
||||
is_fork: ${{ steps.preview.outputs.is_fork }}
|
||||
pr_number: ${{ steps.preview.outputs.pr_number }}
|
||||
should_deploy: ${{ steps.preview.outputs.should_deploy }}
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted preview resolver
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
path: trusted-preview-resolver
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
workers/preview/deploy
|
||||
|
||||
- name: Resolve preview request
|
||||
id: preview
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const runPr = workflowRun.pull_requests?.[0];
|
||||
const { resolvePreviewRequest } = require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs');
|
||||
await resolvePreviewRequest({ github, context, core });
|
||||
|
||||
core.setOutput('should_deploy', 'false');
|
||||
|
||||
if (!runPr) {
|
||||
core.info('Workflow run is not associated with a pull request');
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = runPr.number;
|
||||
const headSha = workflowRun.head_sha;
|
||||
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
if (pullRequest.head.sha !== headSha) {
|
||||
core.setFailed(`Workflow run head SHA ${headSha} does not match PR head ${pullRequest.head.sha}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPreviewLabel = pullRequest.labels.some((label) => label.name === 'preview-cf');
|
||||
if (!hasPreviewLabel) {
|
||||
core.info(`PR ${prNumber} does not have the preview-cf label`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const workflowChanges = files
|
||||
.map((file) => file.filename)
|
||||
.filter((filename) => filename.startsWith('.github/workflows/'));
|
||||
|
||||
if (workflowChanges.length > 0) {
|
||||
core.setFailed(`Preview deployment requires base-trusted workflow definitions; changed workflow files: ${workflowChanges.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactName = `preview-image-pr-${prNumber}-${headSha}`;
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: workflowRun.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifact = artifacts.find((item) => item.name === artifactName && !item.expired);
|
||||
|
||||
if (!artifact) {
|
||||
core.setFailed(`Pull Request workflow run ${workflowRun.id} did not publish ${artifactName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('artifact_name', artifactName);
|
||||
core.setOutput('head_sha', headSha);
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('should_deploy', 'true');
|
||||
|
||||
deploy-preview:
|
||||
deployment_record:
|
||||
needs: preview-gate
|
||||
if: needs.preview-gate.outputs.should_deploy == 'true'
|
||||
name: Create GitHub Deployment
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
outputs:
|
||||
deployment_id: ${{ steps.deployment.outputs.result }}
|
||||
env:
|
||||
HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }}
|
||||
IS_FORK: ${{ needs.preview-gate.outputs.is_fork }}
|
||||
PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }}
|
||||
|
||||
steps:
|
||||
- name: Create GitHub Deployment
|
||||
if: env.IS_FORK == 'false'
|
||||
id: deployment
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = process.env.PR_NUMBER;
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const deployment = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: headSha,
|
||||
environment: `preview-pr-${prNumber}`,
|
||||
auto_merge: false,
|
||||
required_contexts: [],
|
||||
description: 'PR Preview Deployment'
|
||||
});
|
||||
return deployment.data.id;
|
||||
result-encoding: string
|
||||
|
||||
deploy-preview:
|
||||
needs: [preview-gate, deployment_record]
|
||||
if: |
|
||||
always() &&
|
||||
needs.preview-gate.outputs.should_deploy == 'true' &&
|
||||
(needs.deployment_record.result == 'success' || needs.deployment_record.result == 'skipped')
|
||||
name: Deploy to Cloudflare Containers
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
@@ -109,11 +98,12 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
deployments: write
|
||||
outputs:
|
||||
preview_url: ${{ steps.deploy.outputs.preview_url }}
|
||||
env:
|
||||
ARTIFACT_NAME: ${{ needs.preview-gate.outputs.artifact_name }}
|
||||
HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }}
|
||||
IS_FORK: ${{ needs.preview-gate.outputs.is_fork }}
|
||||
PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }}
|
||||
|
||||
steps:
|
||||
@@ -134,15 +124,35 @@ jobs:
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}/preview-image
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Verify preview image artifact checksum
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
image_archive="$RUNNER_TEMP/preview-image/sure-preview-image.tar.gz"
|
||||
checksum_file="$RUNNER_TEMP/preview-image/sure-preview-image.sha256"
|
||||
manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json"
|
||||
expected_files="$(mktemp)"
|
||||
actual_files="$(mktemp)"
|
||||
|
||||
test -f "$image_archive"
|
||||
test -f "$checksum_file"
|
||||
test -f "$manifest_file"
|
||||
|
||||
printf '%s\n' \
|
||||
sure-preview-image.manifest.json \
|
||||
sure-preview-image.sha256 \
|
||||
sure-preview-image.tar.gz | sort > "$expected_files"
|
||||
find "$RUNNER_TEMP/preview-image" -maxdepth 1 -type f -printf '%f\n' | sort > "$actual_files"
|
||||
|
||||
if ! diff -u "$expected_files" "$actual_files"; then
|
||||
echo "Preview image artifact contained unexpected files" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_checksum="$(tr -d '[:space:]' < "$checksum_file")"
|
||||
actual_checksum="$(sha256sum "$image_archive" | awk '{print $1}')"
|
||||
@@ -152,10 +162,32 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
node - "$manifest_file" "$expected_checksum" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
const manifestPath = process.argv[2];
|
||||
const expectedChecksum = process.argv[3];
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const expectedImageTag = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`;
|
||||
const expected = {
|
||||
artifactVersion: 1,
|
||||
archivePath: 'sure-preview-image.tar.gz',
|
||||
archiveSha256: expectedChecksum,
|
||||
headSha: process.env.HEAD_SHA,
|
||||
imageTag: expectedImageTag,
|
||||
prNumber: process.env.PR_NUMBER,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (manifest[key] !== value) {
|
||||
throw new Error(`Preview image manifest ${key} mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(manifest.imageId || '')) {
|
||||
throw new Error('Preview image manifest imageId is invalid');
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Prepare trusted preview deploy workspace
|
||||
run: |
|
||||
@@ -182,10 +214,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
image_archive="$RUNNER_TEMP/preview-image/sure-preview-image.tar.gz"
|
||||
manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json"
|
||||
expected_image="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}"
|
||||
|
||||
gzip -dc "$image_archive" | docker load
|
||||
docker image inspect "$expected_image" >/dev/null
|
||||
expected_image_id="$(node -e 'const fs = require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).imageId);' "$manifest_file")"
|
||||
actual_image_id="$(docker image inspect --format '{{.Id}}' "$expected_image")"
|
||||
|
||||
if [ "$expected_image_id" != "$actual_image_id" ]; then
|
||||
echo "Loaded preview image ID did not match artifact manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Push preview image to Cloudflare registry
|
||||
id: image
|
||||
@@ -239,25 +279,6 @@ jobs:
|
||||
|
||||
cat "$config_path"
|
||||
|
||||
- name: Create GitHub Deployment
|
||||
id: deployment
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = process.env.PR_NUMBER;
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const deployment = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: headSha,
|
||||
environment: `preview-pr-${prNumber}`,
|
||||
auto_merge: false,
|
||||
required_contexts: [],
|
||||
description: 'PR Preview Deployment'
|
||||
});
|
||||
return deployment.data.id;
|
||||
result-encoding: string
|
||||
|
||||
- name: Deploy to Cloudflare Containers
|
||||
id: deploy
|
||||
env:
|
||||
@@ -268,7 +289,26 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
cd "$RUNNER_TEMP/sure-preview-worker"
|
||||
./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}"
|
||||
deploy_log="$RUNNER_TEMP/wrangler-deploy.log"
|
||||
clean_deploy_log="$RUNNER_TEMP/wrangler-deploy.clean.log"
|
||||
|
||||
deploy_once() {
|
||||
./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}" 2>&1 | tee "$deploy_log"
|
||||
}
|
||||
|
||||
if ! deploy_once; then
|
||||
perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' "$deploy_log" > "$clean_deploy_log"
|
||||
|
||||
if grep -F "associated with a different durable object namespace" "$clean_deploy_log" >/dev/null; then
|
||||
echo "Detected stale Cloudflare container app state for PR ${PR_NUMBER}; deleting preview Worker and retrying once."
|
||||
if ! ./node_modules/.bin/wrangler delete --name "sure-preview-${PR_NUMBER}" --force; then
|
||||
echo "Preview Worker delete failed; continuing to the single retry so wrangler deploy reports the final error if the stale state remains." >&2
|
||||
fi
|
||||
deploy_once
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get the deployment URL
|
||||
PREVIEW_URL="https://sure-preview-${PR_NUMBER}.${CLOUDFLARE_WORKERS_SUBDOMAIN}.workers.dev"
|
||||
@@ -279,17 +319,85 @@ jobs:
|
||||
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
|
||||
run: |
|
||||
echo "Triggering preview wake-up..."
|
||||
curl -fsS "$PREVIEW_URL/" >/dev/null || true
|
||||
curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" >/dev/null || true
|
||||
|
||||
- name: Update Deployment Status
|
||||
if: always() && steps.deployment.outputs.result
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
- name: Collect preview diagnostics
|
||||
if: success()
|
||||
env:
|
||||
DEPLOYMENT_ID: ${{ steps.deployment.outputs.result }}
|
||||
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
diagnostics_file="$RUNNER_TEMP/preview-diagnostics.json"
|
||||
last_error=""
|
||||
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" -o "$diagnostics_file"; then
|
||||
if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
else
|
||||
last_error="curl failed on attempt ${attempt}"
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ ! -s "$diagnostics_file" ]; then
|
||||
jq -n --arg error "${last_error:-preview diagnostics unavailable}" \
|
||||
--arg url "$PREVIEW_URL" \
|
||||
'{previewReady: false, previewFailed: false, error: $error, previewUrl: $url}' > "$diagnostics_file"
|
||||
fi
|
||||
|
||||
jq -c . "$diagnostics_file"
|
||||
|
||||
if jq -e '.previewFailed == true' "$diagnostics_file" >/dev/null; then
|
||||
echo "Preview diagnostics from _container_status reported previewFailed=true:" >&2
|
||||
jq -c . "$diagnostics_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload preview diagnostics
|
||||
if: success()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}
|
||||
path: ${{ runner.temp }}/preview-diagnostics.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- name: Store cleanup metadata
|
||||
if: success()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: preview-cleanup-pr-${{ env.PR_NUMBER }}
|
||||
path: ${{ runner.temp }}/sure-preview-worker/wrangler.toml
|
||||
retention-days: 2
|
||||
|
||||
deployment_status:
|
||||
needs: [preview-gate, deployment_record, deploy-preview]
|
||||
if: |
|
||||
always() &&
|
||||
needs.preview-gate.outputs.should_deploy == 'true' &&
|
||||
needs.preview-gate.outputs.is_fork == 'false' &&
|
||||
needs.deployment_record.result == 'success'
|
||||
name: Update GitHub Deployment Status
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
env:
|
||||
DEPLOYMENT_ID: ${{ needs.deployment_record.outputs.deployment_id }}
|
||||
DEPLOY_RESULT: ${{ needs.deploy-preview.result }}
|
||||
PREVIEW_URL: ${{ needs.deploy-preview.outputs.preview_url }}
|
||||
|
||||
steps:
|
||||
- name: Update Deployment Status
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const state = '${{ job.status }}' === 'success' ? 'success' : 'failure';
|
||||
const state = process.env.DEPLOY_RESULT === 'success' ? 'success' : 'failure';
|
||||
const previewUrl = process.env.PREVIEW_URL || undefined;
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
@@ -300,27 +408,41 @@ jobs:
|
||||
description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed'
|
||||
});
|
||||
|
||||
preview_comment:
|
||||
needs: [preview-gate, deploy-preview]
|
||||
if: needs.deploy-preview.result == 'success'
|
||||
name: Comment on PR
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }}
|
||||
PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }}
|
||||
PREVIEW_URL: ${{ needs.deploy-preview.outputs.preview_url }}
|
||||
|
||||
steps:
|
||||
- name: Comment on PR
|
||||
if: success()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
env:
|
||||
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
|
||||
with:
|
||||
script: |
|
||||
const previewUrl = process.env.PREVIEW_URL;
|
||||
const issueNumber = Number(process.env.PR_NUMBER);
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const commentBody = `## 🚀 Preview Deployment Ready
|
||||
|
||||
Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.
|
||||
|
||||
**Preview URL:** ${previewUrl}
|
||||
|
||||
> ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch.
|
||||
> 💤 The container will sleep after 30 minutes of inactivity and wake on the next request.
|
||||
|
||||
---
|
||||
<sub>Deployed from commit ${headSha}</sub>`;
|
||||
const commentBody = [
|
||||
'## 🚀 Preview Deployment Ready',
|
||||
'',
|
||||
"Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.",
|
||||
'',
|
||||
`**Preview URL:** ${previewUrl}`,
|
||||
'',
|
||||
'> ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch.',
|
||||
'> 💤 The container will sleep after 30 minutes of inactivity and wake on the next request.',
|
||||
'',
|
||||
'---',
|
||||
`<sub>Deployed from commit ${headSha}</sub>`,
|
||||
].join('\n');
|
||||
|
||||
// Find existing comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
@@ -349,11 +471,3 @@ jobs:
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
|
||||
- name: Store cleanup metadata
|
||||
if: success()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: preview-cleanup-pr-${{ env.PR_NUMBER }}
|
||||
path: ${{ runner.temp }}/sure-preview-worker/wrangler.toml
|
||||
retention-days: 2
|
||||
|
||||
@@ -7,6 +7,7 @@ ROOT = File.expand_path("..", __dir__)
|
||||
PREVIEW_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/preview-deploy.yml")
|
||||
PR_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/pr.yml")
|
||||
LOCKFILE_PATH = File.join(ROOT, "workers/preview/package-lock.json")
|
||||
RESOLVER_PATH = File.join(ROOT, "workers/preview/deploy/resolve_preview_request.cjs")
|
||||
PINNED_ACTION = /\A[^@\s]+@[a-f0-9]{40}\z/
|
||||
EXPECTED_ACTION_PINS = {
|
||||
"actions/checkout" => "93cb6efe18208431cddfb8368fd83d5badbf9bfd", # v5
|
||||
@@ -34,10 +35,16 @@ GITHUB_WORKSPACE_PREFIX = %r{
|
||||
EXPECTED_TOP_LEVEL_PERMISSIONS = { "contents" => "read" }.freeze
|
||||
EXPECTED_GATE_PERMISSIONS = { "actions" => "read", "contents" => "read", "pull-requests" => "read" }.freeze
|
||||
EXPECTED_IMAGE_PERMISSIONS = { "contents" => "read" }.freeze
|
||||
EXPECTED_DEPLOYMENT_PERMISSIONS = {
|
||||
"contents" => "read",
|
||||
"deployments" => "write"
|
||||
}.freeze
|
||||
EXPECTED_DEPLOY_PERMISSIONS = {
|
||||
"actions" => "read",
|
||||
"contents" => "read"
|
||||
}.freeze
|
||||
EXPECTED_COMMENT_PERMISSIONS = {
|
||||
"contents" => "read",
|
||||
"deployments" => "write",
|
||||
"pull-requests" => "write"
|
||||
}.freeze
|
||||
EXPECTED_DEPLOY_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_WORKERS_SUBDOMAIN].freeze
|
||||
@@ -57,7 +64,11 @@ REQUIRED_IMAGE_BUILD_LINES = [
|
||||
"-f Dockerfile.preview",
|
||||
'-t "${IMAGE_TAG}"',
|
||||
'docker save "${IMAGE_TAG}" | gzip -1 > "$image_archive"',
|
||||
'sha256sum "$image_archive"'
|
||||
'sha256sum "$image_archive"',
|
||||
"sure-preview-image.manifest.json",
|
||||
'ARCHIVE_SHA256="$archive_sha256" IMAGE_ID="$image_id" node - "$manifest_file"',
|
||||
"JSON.stringify(manifest, null, 2)",
|
||||
'jq -e . "$manifest_file"'
|
||||
].freeze
|
||||
|
||||
def fail_check(message)
|
||||
@@ -137,6 +148,7 @@ end
|
||||
preview_workflow = YAML.safe_load_file(PREVIEW_WORKFLOW_PATH, aliases: true)
|
||||
pr_workflow = YAML.safe_load_file(PR_WORKFLOW_PATH, aliases: true)
|
||||
lockfile = JSON.parse(File.read(LOCKFILE_PATH))
|
||||
resolver_script = File.read(RESOLVER_PATH)
|
||||
|
||||
preview_on = workflow_on(preview_workflow)
|
||||
pr_on = workflow_on(pr_workflow)
|
||||
@@ -144,19 +156,27 @@ preview_jobs = preview_workflow.fetch("jobs")
|
||||
pr_jobs = pr_workflow.fetch("jobs")
|
||||
gate_job = preview_jobs.fetch("preview-gate")
|
||||
image_job = pr_jobs.fetch("preview_image")
|
||||
deployment_record_job = preview_jobs.fetch("deployment_record")
|
||||
deploy_job = preview_jobs.fetch("deploy-preview")
|
||||
deployment_status_job = preview_jobs.fetch("deployment_status")
|
||||
preview_comment_job = preview_jobs.fetch("preview_comment")
|
||||
gate_steps = gate_job.fetch("steps")
|
||||
image_steps = image_job.fetch("steps")
|
||||
deployment_record_steps = deployment_record_job.fetch("steps")
|
||||
deploy_steps = deploy_job.fetch("steps")
|
||||
deployment_status_steps = deployment_status_job.fetch("steps")
|
||||
preview_comment_steps = preview_comment_job.fetch("steps")
|
||||
deploy_step_names = deploy_steps.map { |step| step["name"] }
|
||||
wrangler = lockfile.fetch("packages").fetch("node_modules/wrangler")
|
||||
|
||||
gate_trusted_checkout = step!(gate_steps, "Checkout trusted preview resolver")
|
||||
resolve_preview = step!(gate_steps, "Resolve preview request")
|
||||
|
||||
pr_checkout = step!(image_steps, "Checkout PR code")
|
||||
build_image = step!(image_steps, "Build preview image without secrets")
|
||||
upload_image = step!(image_steps, "Upload preview image artifact")
|
||||
|
||||
create_deployment = step!(deployment_record_steps, "Create GitHub Deployment")
|
||||
trusted_checkout = step!(deploy_steps, "Checkout trusted preview tooling")
|
||||
download_artifact = step!(deploy_steps, "Download preview image artifact")
|
||||
verify_checksum = step!(deploy_steps, "Verify preview image artifact checksum")
|
||||
@@ -165,13 +185,18 @@ load_image = step!(deploy_steps, "Load preview image artifact")
|
||||
push_image = step!(deploy_steps, "Push preview image to Cloudflare registry")
|
||||
configure_image = step!(deploy_steps, "Configure trusted preview image reference")
|
||||
deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
|
||||
warm_preview = step!(deploy_steps, "Warm preview container")
|
||||
collect_diagnostics = step!(deploy_steps, "Collect preview diagnostics")
|
||||
upload_diagnostics = step!(deploy_steps, "Upload preview diagnostics")
|
||||
update_deployment_status = step!(deployment_status_steps, "Update Deployment Status")
|
||||
comment_on_pr = step!(preview_comment_steps, "Comment on PR")
|
||||
|
||||
[
|
||||
[ "preview trigger", preview_on.keys, [ "workflow_run" ] ],
|
||||
[ "preview trigger workflows", preview_on.dig("workflow_run", "workflows"), [ "Pull Request" ] ],
|
||||
[ "preview trigger types", preview_on.dig("workflow_run", "types"), [ "completed" ] ],
|
||||
[ "preview top-level permissions", preview_workflow.fetch("permissions"), EXPECTED_TOP_LEVEL_PERMISSIONS ],
|
||||
[ "preview workflow jobs", preview_jobs.keys, [ "preview-gate", "deploy-preview" ] ],
|
||||
[ "preview workflow jobs", preview_jobs.keys, [ "preview-gate", "deployment_record", "deploy-preview", "deployment_status", "preview_comment" ] ],
|
||||
[ "PR workflow trigger types", pr_on.dig("pull_request", "types"), %w[opened synchronize reopened labeled] ],
|
||||
[ "PR workflow paths-ignore", pr_on.dig("pull_request", "paths-ignore"), [ "charts/**" ] ],
|
||||
[ "PR workflow permissions", pr_workflow.fetch("permissions"), EXPECTED_TOP_LEVEL_PERMISSIONS ],
|
||||
@@ -181,6 +206,7 @@ deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
|
||||
[ "preview gate should_deploy output", gate_job.dig("outputs", "should_deploy"), "${{ steps.preview.outputs.should_deploy }}" ],
|
||||
[ "preview gate artifact output", gate_job.dig("outputs", "artifact_name"), "${{ steps.preview.outputs.artifact_name }}" ],
|
||||
[ "preview gate head output", gate_job.dig("outputs", "head_sha"), "${{ steps.preview.outputs.head_sha }}" ],
|
||||
[ "preview gate fork output", gate_job.dig("outputs", "is_fork"), "${{ steps.preview.outputs.is_fork }}" ],
|
||||
[ "preview gate PR output", gate_job.dig("outputs", "pr_number"), "${{ steps.preview.outputs.pr_number }}" ],
|
||||
[ "preview image needs", image_job.fetch("needs"), "ci" ],
|
||||
[ "preview image permissions", image_job.fetch("permissions"), EXPECTED_IMAGE_PERMISSIONS ],
|
||||
@@ -188,15 +214,41 @@ deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
|
||||
[ "image PR_NUMBER env", image_job.dig("env", "PR_NUMBER"), "${{ github.event.pull_request.number }}" ],
|
||||
[ "image HEAD_SHA env", image_job.dig("env", "HEAD_SHA"), "${{ github.event.pull_request.head.sha }}" ],
|
||||
[ "image tag env", image_job.dig("env", "IMAGE_TAG"), "sure-preview-pr-${{ github.event.pull_request.number }}:${{ github.event.pull_request.head.sha }}" ],
|
||||
[ "deploy job needs", deploy_job.fetch("needs"), "preview-gate" ],
|
||||
[ "deployment record needs", deployment_record_job.fetch("needs"), "preview-gate" ],
|
||||
[ "deployment record if", deployment_record_job.fetch("if"), "needs.preview-gate.outputs.should_deploy == 'true'" ],
|
||||
[ "deployment record permissions", deployment_record_job.fetch("permissions"), EXPECTED_DEPLOYMENT_PERMISSIONS ],
|
||||
[ "deployment record timeout", deployment_record_job.fetch("timeout-minutes"), 5 ],
|
||||
[ "deployment record output", deployment_record_job.dig("outputs", "deployment_id"), "${{ steps.deployment.outputs.result }}" ],
|
||||
[ "deployment record HEAD_SHA env", deployment_record_job.dig("env", "HEAD_SHA"), "${{ needs.preview-gate.outputs.head_sha }}" ],
|
||||
[ "deployment record IS_FORK env", deployment_record_job.dig("env", "IS_FORK"), "${{ needs.preview-gate.outputs.is_fork }}" ],
|
||||
[ "deployment record PR_NUMBER env", deployment_record_job.dig("env", "PR_NUMBER"), "${{ needs.preview-gate.outputs.pr_number }}" ],
|
||||
[ "deploy job needs", deploy_job.fetch("needs"), [ "preview-gate", "deployment_record" ] ],
|
||||
[ "deploy job permissions", deploy_job.fetch("permissions"), EXPECTED_DEPLOY_PERMISSIONS ],
|
||||
[ "deploy job environment", environment_name(deploy_job), "preview" ],
|
||||
[ "deploy job timeout", deploy_job.fetch("timeout-minutes"), 45 ],
|
||||
[ "deploy preview output", deploy_job.dig("outputs", "preview_url"), "${{ steps.deploy.outputs.preview_url }}" ],
|
||||
[ "deploy concurrency group", deploy_job.dig("concurrency", "group"), "preview-deploy-${{ needs.preview-gate.outputs.pr_number }}" ],
|
||||
[ "deploy concurrency cancellation", deploy_job.dig("concurrency", "cancel-in-progress"), true ],
|
||||
[ "deploy ARTIFACT_NAME env", deploy_job.dig("env", "ARTIFACT_NAME"), "${{ needs.preview-gate.outputs.artifact_name }}" ],
|
||||
[ "deploy HEAD_SHA env", deploy_job.dig("env", "HEAD_SHA"), "${{ needs.preview-gate.outputs.head_sha }}" ],
|
||||
[ "deploy IS_FORK env", deploy_job.dig("env", "IS_FORK"), "${{ needs.preview-gate.outputs.is_fork }}" ],
|
||||
[ "deploy PR_NUMBER env", deploy_job.dig("env", "PR_NUMBER"), "${{ needs.preview-gate.outputs.pr_number }}" ],
|
||||
[ "deployment status needs", deployment_status_job.fetch("needs"), [ "preview-gate", "deployment_record", "deploy-preview" ] ],
|
||||
[ "deployment status permissions", deployment_status_job.fetch("permissions"), EXPECTED_DEPLOYMENT_PERMISSIONS ],
|
||||
[ "deployment status timeout", deployment_status_job.fetch("timeout-minutes"), 5 ],
|
||||
[ "deployment status DEPLOYMENT_ID env", deployment_status_job.dig("env", "DEPLOYMENT_ID"), "${{ needs.deployment_record.outputs.deployment_id }}" ],
|
||||
[ "deployment status DEPLOY_RESULT env", deployment_status_job.dig("env", "DEPLOY_RESULT"), "${{ needs.deploy-preview.result }}" ],
|
||||
[ "deployment status PREVIEW_URL env", deployment_status_job.dig("env", "PREVIEW_URL"), "${{ needs.deploy-preview.outputs.preview_url }}" ],
|
||||
[ "preview comment needs", preview_comment_job.fetch("needs"), [ "preview-gate", "deploy-preview" ] ],
|
||||
[ "preview comment if", preview_comment_job.fetch("if"), "needs.deploy-preview.result == 'success'" ],
|
||||
[ "preview comment permissions", preview_comment_job.fetch("permissions"), EXPECTED_COMMENT_PERMISSIONS ],
|
||||
[ "preview comment timeout", preview_comment_job.fetch("timeout-minutes"), 5 ],
|
||||
[ "preview comment HEAD_SHA env", preview_comment_job.dig("env", "HEAD_SHA"), "${{ needs.preview-gate.outputs.head_sha }}" ],
|
||||
[ "preview comment PR_NUMBER env", preview_comment_job.dig("env", "PR_NUMBER"), "${{ needs.preview-gate.outputs.pr_number }}" ],
|
||||
[ "preview comment PREVIEW_URL env", preview_comment_job.dig("env", "PREVIEW_URL"), "${{ needs.deploy-preview.outputs.preview_url }}" ],
|
||||
[ "gate trusted checkout ref", gate_trusted_checkout.dig("with", "ref"), "${{ github.event.repository.default_branch }}" ],
|
||||
[ "gate trusted checkout path", gate_trusted_checkout.dig("with", "path"), "trusted-preview-resolver" ],
|
||||
[ "gate trusted checkout credentials", gate_trusted_checkout.dig("with", "persist-credentials"), false ],
|
||||
[ "PR checkout credentials", pr_checkout.dig("with", "persist-credentials"), false ],
|
||||
[ "upload artifact name", upload_image.dig("with", "name"), "preview-image-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ],
|
||||
[ "upload artifact retention", upload_image.dig("with", "retention-days"), 3 ],
|
||||
@@ -207,6 +259,10 @@ deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
|
||||
[ "download artifact run id", download_artifact.dig("with", "run-id"), "${{ github.event.workflow_run.id }}" ],
|
||||
[ "download artifact token", download_artifact.dig("with", "github-token"), "${{ github.token }}" ],
|
||||
[ "download artifact path", download_artifact.dig("with", "path"), "${{ runner.temp }}/preview-image" ],
|
||||
[ "fork deployment record guard", create_deployment.fetch("if"), "env.IS_FORK == 'false'" ],
|
||||
[ "diagnostics upload name", upload_diagnostics.dig("with", "name"), "preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ],
|
||||
[ "diagnostics upload path", upload_diagnostics.dig("with", "path"), "${{ runner.temp }}/preview-diagnostics.json" ],
|
||||
[ "diagnostics upload retention", upload_diagnostics.dig("with", "retention-days"), 3 ],
|
||||
[ "Wrangler binary", wrangler.dig("bin", "wrangler"), "bin/wrangler.js" ]
|
||||
].each { |label, actual, expected| assert(actual == expected, "#{label}: expected #{actual.inspect} to equal #{expected.inspect}") }
|
||||
|
||||
@@ -216,54 +272,80 @@ assert(!preview_on.key?("pull_request"), "privileged preview deploy workflow mus
|
||||
assert(gate_job.fetch("if").include?("github.event.workflow_run.event == 'pull_request'"), "preview gate must only accept pull_request workflow runs")
|
||||
assert(gate_job.fetch("if").include?("github.event.workflow_run.conclusion == 'success'"), "preview gate must only accept successful PR workflow runs")
|
||||
assert(image_job.fetch("if").include?("preview-cf"), "preview image build must stay gated by preview-cf")
|
||||
assert(deploy_job.fetch("if") == "needs.preview-gate.outputs.should_deploy == 'true'", "privileged preview deploy must depend on the gate output")
|
||||
assert(deploy_job.fetch("if").include?("needs.preview-gate.outputs.should_deploy == 'true'"), "privileged preview deploy must depend on the gate output")
|
||||
assert(deploy_job.fetch("if").include?("needs.deployment_record.result == 'success'"), "privileged preview deploy must require deployment record success or skip")
|
||||
assert(deploy_job.fetch("if").include?("needs.deployment_record.result == 'skipped'"), "privileged preview deploy must allow skipped deployment records")
|
||||
assert(deployment_status_job.fetch("if").include?("needs.preview-gate.outputs.is_fork == 'false'"), "deployment status job must only run for same-repository PRs")
|
||||
assert(deployment_status_job.fetch("if").include?("needs.deployment_record.result == 'success'"), "deployment status job must require a created deployment")
|
||||
assert(gate_job["environment"].nil?, "preview gate must not use a protected secret-bearing environment")
|
||||
assert(image_job["environment"].nil?, "preview image build must not use a protected secret-bearing environment")
|
||||
assert(deployment_record_job["environment"].nil?, "deployment record job must not use a protected secret-bearing environment")
|
||||
assert(deployment_status_job["environment"].nil?, "deployment status job must not use a protected secret-bearing environment")
|
||||
assert(preview_comment_job["environment"].nil?, "preview comment job must not use a protected secret-bearing environment")
|
||||
assert(lockfile.dig("packages", "", "devDependencies", "wrangler"), "Wrangler must stay a root dev dependency")
|
||||
assert(lockfile.fetch("lockfileVersion") >= 3, "preview tooling lockfile must preserve npm ci integrity metadata")
|
||||
assert(wrangler.fetch("resolved").start_with?("https://registry.npmjs.org/wrangler/-/wrangler-"), "Wrangler must resolve from npm registry")
|
||||
assert(wrangler.fetch("integrity").start_with?("sha512-"), "Wrangler lockfile entry must keep npm integrity metadata")
|
||||
assert(gate_trusted_checkout.dig("with", "sparse-checkout").to_s.include?("workers/preview/deploy"), "trusted gate checkout must include preview resolver")
|
||||
assert(trusted_checkout.dig("with", "sparse-checkout").to_s.include?("workers/preview"), "trusted checkout must include preview tooling")
|
||||
assert(deploy_step_names.compact.uniq == deploy_step_names.compact, "workflow step names must stay unique for security checks")
|
||||
assert([ trusted_checkout, download_artifact, verify_checksum, prepare, load_image, push_image, configure_image, deploy ].map { |step| deploy_steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "deploy workflow steps must preserve safe cross-run artifact deploy order")
|
||||
assert([ gate_trusted_checkout, resolve_preview ].map { |step| gate_steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "gate workflow steps must checkout trusted resolver before use")
|
||||
assert([ trusted_checkout, download_artifact, verify_checksum, prepare, load_image, push_image, configure_image, deploy, warm_preview, collect_diagnostics, upload_diagnostics ].map { |step| deploy_steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "deploy workflow steps must preserve safe cross-run artifact deploy order")
|
||||
assert(deploy_steps.none? { |step| step["name"] == "Checkout PR code" }, "privileged deploy job must not checkout PR code")
|
||||
assert(env_hash(deploy_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "Cloudflare secrets must not be job-wide")
|
||||
assert(env_hash(gate_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "preview gate must not receive Cloudflare secrets")
|
||||
assert(env_hash(image_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "preview image build must not receive Cloudflare secrets")
|
||||
assert(env_hash(deployment_record_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "deployment record job must not receive Cloudflare secrets")
|
||||
assert(env_hash(deployment_status_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "deployment status job must not receive Cloudflare secrets")
|
||||
assert(env_hash(preview_comment_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "preview comment job must not receive Cloudflare secrets")
|
||||
assert(upload_image.dig("with", "path").to_s.include?("sure-preview-image.tar.gz"), "preview image artifact must include the image archive")
|
||||
assert(upload_image.dig("with", "path").to_s.include?("sure-preview-image.sha256"), "preview image artifact must include the checksum")
|
||||
assert(upload_image.dig("with", "path").to_s.include?("sure-preview-image.manifest.json"), "preview image artifact must include the manifest")
|
||||
|
||||
assert_pinned_actions!(gate_steps)
|
||||
assert_pinned_actions!(image_steps)
|
||||
assert_pinned_actions!(deployment_record_steps)
|
||||
assert_pinned_actions!(deploy_steps)
|
||||
assert_pinned_actions!(deployment_status_steps)
|
||||
assert_pinned_actions!(preview_comment_steps)
|
||||
assert_no_inline_expressions!(gate_steps)
|
||||
assert_no_inline_expressions!(image_steps)
|
||||
assert_no_inline_expressions!(deployment_record_steps)
|
||||
assert_no_inline_expressions!(deploy_steps)
|
||||
assert_no_inline_expressions!(deployment_status_steps)
|
||||
assert_no_inline_expressions!(preview_comment_steps)
|
||||
|
||||
all_steps = gate_steps + image_steps + deploy_steps
|
||||
all_steps = gate_steps + image_steps + deployment_record_steps + deploy_steps + deployment_status_steps + preview_comment_steps
|
||||
assert(all_steps.none? { |step| env_hash(step).values.join("\n").match?(INLINE_SECRET_EXPRESSION) && ![ push_image, deploy ].include?(step) }, "only Cloudflare steps may reference GitHub secrets")
|
||||
assert(deploy_steps.none? { |step| normalized_working_directory(step["working-directory"]).match?(PR_CONTROLLED_WORKDIR) }, "privileged deploy steps must not run from PR-controlled dirs")
|
||||
assert(deploy_steps.none? { |step| run(step).include?("npx wrangler") }, "privileged deploy workflow must not use npx wrangler")
|
||||
assert(deploy_steps.none? { |step| run(step).match?(/Dockerfile\.preview|docker build|docker save/) }, "privileged deploy job must not build PR Dockerfiles")
|
||||
assert(deploy_steps.none? { |step| run(step).include?("${GITHUB_WORKSPACE}/pr") || run(step).include?(" pr/") }, "privileged deploy job must not reference PR checkout paths")
|
||||
assert((deployment_record_steps + deployment_status_steps + preview_comment_steps).none? { |step| [ step["uses"], run(step) ].compact.join("\n").include?("download-artifact") }, "GitHub write jobs must not download PR artifacts")
|
||||
assert((deployment_record_steps + deployment_status_steps + preview_comment_steps).none? { |step| run(step).match?(/docker |wrangler|npm /) }, "GitHub write jobs must not execute preview artifact, deploy, or package tooling")
|
||||
assert(image_steps.none? { |step| env_hash(step).keys.any? { |key| key.start_with?("CLOUDFLARE_") } }, "preview image workflow must not expose Cloudflare secret env")
|
||||
assert(image_steps.none? { |step| [ run(step), env_hash(step).values.join("\n") ].join("\n").match?(INLINE_SECRET_EXPRESSION) }, "preview image workflow must not reference GitHub secrets")
|
||||
|
||||
assert_run_includes(
|
||||
resolve_preview,
|
||||
"context.payload.workflow_run",
|
||||
"require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs')",
|
||||
"resolvePreviewRequest({ github, context, core })"
|
||||
)
|
||||
|
||||
[
|
||||
"workflowRun.pull_requests?.[0]",
|
||||
"github.rest.pulls.get",
|
||||
"github.rest.repos.listPullRequestsAssociatedWithCommit",
|
||||
"parsePreviewArtifactName",
|
||||
"pullRequest.head.sha !== headSha",
|
||||
"is stale for PR",
|
||||
"preview-cf",
|
||||
"github.rest.pulls.listFiles",
|
||||
"filename.startsWith('.github/workflows/')",
|
||||
"github.rest.actions.listWorkflowRunArtifacts",
|
||||
"filename.startsWith(\".github/workflows/\")",
|
||||
"preview-image-pr-${prNumber}-${headSha}",
|
||||
"!item.expired",
|
||||
"core.setOutput('artifact_name', artifactName)",
|
||||
"core.setOutput('should_deploy', 'true')"
|
||||
)
|
||||
"core.setOutput(\"artifact_name\", artifactName)",
|
||||
"core.setOutput(\"is_fork\", String(isFork))",
|
||||
"core.setOutput(\"should_deploy\", \"true\")"
|
||||
].each { |needle| assert(resolver_script.include?(needle), "preview resolver must include #{needle.inspect}") }
|
||||
|
||||
prepare_run = assert_run_includes(prepare, *REQUIRED_PREPARE_LINES)
|
||||
assert(!prepare_run.include?("npm install"), "prepare step must not use npm install")
|
||||
@@ -274,12 +356,18 @@ assert(deploy_steps.select { |step| run(step).match?(/npm (ci|install)/) }.map {
|
||||
image_build_run = assert_run_includes(build_image, *REQUIRED_IMAGE_BUILD_LINES)
|
||||
assert(image_build_run.include?("set -euo pipefail"), "preview image build must fail closed")
|
||||
assert(!image_build_run.include?("CLOUDFLARE_"), "preview image build must not receive Cloudflare secrets")
|
||||
assert(!image_build_run.include?('cat > "$manifest_file" <<JSON'), "preview image manifest must be generated with JSON escaping")
|
||||
|
||||
assert_run_includes(verify_checksum, 'expected_checksum="$(tr -d', 'actual_checksum="$(sha256sum "$image_archive"', "Preview image artifact checksum mismatch")
|
||||
assert_run_includes(load_image, 'gzip -dc "$image_archive" | docker load', 'docker image inspect "$expected_image"')
|
||||
assert_run_includes(verify_checksum, 'expected_checksum="$(tr -d', 'actual_checksum="$(sha256sum "$image_archive"', "Preview image artifact checksum mismatch", "Preview image artifact contained unexpected files", "sure-preview-image.manifest.json", "Preview image manifest", "imageId is invalid")
|
||||
assert_run_includes(load_image, 'gzip -dc "$image_archive" | docker load', 'docker image inspect "$expected_image"', "Loaded preview image ID did not match artifact manifest")
|
||||
assert_run_includes(push_image, "./node_modules/.bin/wrangler containers push", "registry\\.cloudflare\\.com/", "image_ref=")
|
||||
assert_run_includes(configure_image, "imageRef.startsWith('registry.cloudflare.com/')", 'const original = fs.readFileSync', 'const updated = original.replace(/image = "[^"]+"/', "updated === original", "Expected wrangler.toml to contain an image entry to rewrite", "JSON.stringify(imageRef)")
|
||||
assert_run_includes(deploy, 'cd "$RUNNER_TEMP/sure-preview-worker"', "./node_modules/.bin/wrangler deploy --config wrangler.toml", '--var "PR_NUMBER:${PR_NUMBER}"')
|
||||
assert_run_includes(create_deployment, "github.rest.repos.createDeployment", "ref: headSha", "preview-pr-${prNumber}")
|
||||
assert_run_includes(deploy, 'cd "$RUNNER_TEMP/sure-preview-worker"', "deploy_once()", "./node_modules/.bin/wrangler deploy --config wrangler.toml", '--var "PR_NUMBER:${PR_NUMBER}"', "associated with a different durable object namespace", 'if ! ./node_modules/.bin/wrangler delete --name "sure-preview-${PR_NUMBER}" --force', "Preview Worker delete failed", "retrying once")
|
||||
assert_run_includes(warm_preview, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15")
|
||||
assert_run_includes(collect_diagnostics, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15", "preview-diagnostics.json", "jq -e '.previewReady == true or .previewFailed == true'", "jq -e '.previewFailed == true'", "Preview diagnostics from _container_status reported previewFailed=true", "exit 1")
|
||||
assert_run_includes(update_deployment_status, "github.rest.repos.createDeploymentStatus", "process.env.DEPLOY_RESULT === 'success'", "deployment_id: Number(process.env.DEPLOYMENT_ID)")
|
||||
assert_run_includes(comment_on_pr, "github.rest.issues.listComments", "github.rest.issues.updateComment", "github.rest.issues.createComment", "Preview Deployment Ready")
|
||||
|
||||
secret_steps = deploy_steps.select { |step| env_hash(step).then { |env| env.key?("CLOUDFLARE_API_TOKEN") || env.key?("CLOUDFLARE_ACCOUNT_ID") } }
|
||||
assert(secret_steps.map { |step| step["name"] } == [ push_image["name"], deploy["name"] ], "only image push and deploy may receive Cloudflare secrets")
|
||||
|
||||
285
test/javascript/preview_deploy/resolve_preview_request_test.cjs
Normal file
285
test/javascript/preview_deploy/resolve_preview_request_test.cjs
Normal file
@@ -0,0 +1,285 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const {
|
||||
parsePreviewArtifactName,
|
||||
resolvePreviewRequest,
|
||||
selectPullRequestNumber,
|
||||
} = require("../../../workers/preview/deploy/resolve_preview_request.cjs");
|
||||
|
||||
function contextFor(workflowRun) {
|
||||
return {
|
||||
repo: {
|
||||
owner: "we-promise",
|
||||
repo: "sure",
|
||||
},
|
||||
payload: {
|
||||
workflow_run: workflowRun,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function previewArtifact(prNumber, headSha, extra = {}) {
|
||||
return {
|
||||
name: `preview-image-pr-${prNumber}-${headSha}`,
|
||||
expired: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function openPullRequest(number, headSha, fullName = "we-promise/sure", extra = {}) {
|
||||
return {
|
||||
number,
|
||||
state: "open",
|
||||
labels: [{ name: "preview-cf" }],
|
||||
head: {
|
||||
sha: headSha,
|
||||
repo: {
|
||||
full_name: fullName,
|
||||
},
|
||||
},
|
||||
base: {
|
||||
repo: {
|
||||
full_name: "we-promise/sure",
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGithub({ artifacts = [], associatedPullRequests = [], pullRequest, files = [] }) {
|
||||
return {
|
||||
paginate: async (endpoint, params) => {
|
||||
if (endpoint.endpointName === "listWorkflowRunArtifacts") {
|
||||
assert.equal(params.run_id, 123);
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
if (endpoint.endpointName === "listFiles") {
|
||||
return files;
|
||||
}
|
||||
|
||||
throw new Error(`unexpected paginate endpoint ${endpoint.endpointName}`);
|
||||
},
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRunArtifacts: { endpointName: "listWorkflowRunArtifacts" },
|
||||
},
|
||||
pulls: {
|
||||
get: async ({ pull_number }) => {
|
||||
assert.equal(pull_number, pullRequest.number);
|
||||
return { data: pullRequest };
|
||||
},
|
||||
listFiles: { endpointName: "listFiles" },
|
||||
},
|
||||
repos: {
|
||||
listPullRequestsAssociatedWithCommit: async () => ({ data: associatedPullRequests }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCore() {
|
||||
const outputs = {};
|
||||
const messages = [];
|
||||
let failure = null;
|
||||
|
||||
return {
|
||||
core: {
|
||||
info: (message) => messages.push(message),
|
||||
setFailed: (message) => {
|
||||
failure = message;
|
||||
},
|
||||
setOutput: (name, value) => {
|
||||
outputs[name] = value;
|
||||
},
|
||||
},
|
||||
get failure() {
|
||||
return failure;
|
||||
},
|
||||
messages,
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parsePreviewArtifactName", () => {
|
||||
it("parses preview image artifact names", () => {
|
||||
const parsed = parsePreviewArtifactName("preview-image-pr-2017-4f1159e99c7785bc370f53510284c251fabdb75b");
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
prNumber: 2017,
|
||||
headSha: "4f1159e99c7785bc370f53510284c251fabdb75b",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed names", () => {
|
||||
assert.equal(parsePreviewArtifactName("preview-image-pr-0-4f1159e99c7785bc370f53510284c251fabdb75b"), null);
|
||||
assert.equal(parsePreviewArtifactName("preview-image-pr-2017-notasha"), null);
|
||||
assert.equal(parsePreviewArtifactName("other-artifact"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPullRequestNumber", () => {
|
||||
const headSha = "4f1159e99c7785bc370f53510284c251fabdb75b";
|
||||
const context = contextFor({ id: 123, head_sha: headSha });
|
||||
|
||||
it("falls back to commit association when workflow_run has no PR payload", () => {
|
||||
const selected = selectPullRequestNumber({
|
||||
runPullRequest: undefined,
|
||||
artifacts: [previewArtifact(2017, headSha)],
|
||||
associatedPullRequests: [openPullRequest(2017, headSha, "Rene0422/sure")],
|
||||
context,
|
||||
headSha,
|
||||
});
|
||||
|
||||
assert.deepEqual(selected, {
|
||||
prNumber: 2017,
|
||||
source: "commit_association",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a matching artifact when the same head SHA is associated with more than one PR", () => {
|
||||
const selected = selectPullRequestNumber({
|
||||
runPullRequest: undefined,
|
||||
artifacts: [previewArtifact(2060, headSha)],
|
||||
associatedPullRequests: [
|
||||
openPullRequest(2059, headSha),
|
||||
openPullRequest(2060, headSha),
|
||||
],
|
||||
context,
|
||||
headSha,
|
||||
});
|
||||
|
||||
assert.deepEqual(selected, {
|
||||
prNumber: 2060,
|
||||
source: "artifact_and_commit_association",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses ambiguous associated PRs without a single matching artifact", () => {
|
||||
const selected = selectPullRequestNumber({
|
||||
runPullRequest: undefined,
|
||||
artifacts: [],
|
||||
associatedPullRequests: [
|
||||
openPullRequest(2059, headSha),
|
||||
openPullRequest(2060, headSha),
|
||||
],
|
||||
context,
|
||||
headSha,
|
||||
});
|
||||
|
||||
assert.match(selected.error, /multiple open pull requests/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePreviewRequest", () => {
|
||||
const headSha = "4f1159e99c7785bc370f53510284c251fabdb75b";
|
||||
const workflowRun = {
|
||||
id: 123,
|
||||
head_sha: headSha,
|
||||
pull_requests: [],
|
||||
};
|
||||
|
||||
it("resolves fork PRs from commit association and marks deployment creation as skippable", async () => {
|
||||
const pullRequest = openPullRequest(2017, headSha, "Rene0422/sure");
|
||||
const state = fakeCore();
|
||||
const github = fakeGithub({
|
||||
artifacts: [previewArtifact(2017, headSha)],
|
||||
associatedPullRequests: [pullRequest],
|
||||
pullRequest,
|
||||
});
|
||||
|
||||
await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
|
||||
|
||||
assert.equal(state.failure, null);
|
||||
assert.equal(state.outputs.should_deploy, "true");
|
||||
assert.equal(state.outputs.pr_number, "2017");
|
||||
assert.equal(state.outputs.head_sha, headSha);
|
||||
assert.equal(state.outputs.artifact_name, `preview-image-pr-2017-${headSha}`);
|
||||
assert.equal(state.outputs.is_fork, "true");
|
||||
});
|
||||
|
||||
it("resolves PRs from artifact names when workflow and commit association metadata are unavailable", async () => {
|
||||
const pullRequest = openPullRequest(2017, headSha, "Rene0422/sure");
|
||||
const state = fakeCore();
|
||||
const github = fakeGithub({
|
||||
artifacts: [previewArtifact(2017, headSha)],
|
||||
associatedPullRequests: [],
|
||||
pullRequest,
|
||||
});
|
||||
|
||||
await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
|
||||
|
||||
assert.equal(state.failure, null);
|
||||
assert.equal(state.outputs.should_deploy, "true");
|
||||
assert.equal(state.outputs.pr_number, "2017");
|
||||
assert.equal(state.outputs.head_sha, headSha);
|
||||
assert.equal(state.outputs.artifact_name, `preview-image-pr-2017-${headSha}`);
|
||||
assert.equal(state.outputs.is_fork, "true");
|
||||
assert.match(state.messages.join("\n"), /Resolved PR 2017 from artifact_name; fork=true/);
|
||||
});
|
||||
|
||||
it("treats stale workflow runs as successful no-ops", async () => {
|
||||
const currentHeadSha = "c79a325513160e651680170f817d802395c38d86";
|
||||
const pullRequest = openPullRequest(2060, currentHeadSha);
|
||||
const state = fakeCore();
|
||||
const github = fakeGithub({
|
||||
artifacts: [previewArtifact(2060, headSha)],
|
||||
associatedPullRequests: [openPullRequest(2060, headSha)],
|
||||
pullRequest,
|
||||
});
|
||||
|
||||
await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
|
||||
|
||||
assert.equal(state.failure, null);
|
||||
assert.equal(state.outputs.should_deploy, "false");
|
||||
assert.match(state.messages.join("\n"), /is stale for PR 2060/);
|
||||
});
|
||||
|
||||
it("fails closed when a labeled PR changed workflow files", async () => {
|
||||
const pullRequest = openPullRequest(2060, headSha);
|
||||
const state = fakeCore();
|
||||
const github = fakeGithub({
|
||||
artifacts: [previewArtifact(2060, headSha)],
|
||||
associatedPullRequests: [pullRequest],
|
||||
pullRequest,
|
||||
files: [{ filename: ".github/workflows/pr.yml" }],
|
||||
});
|
||||
|
||||
await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
|
||||
|
||||
assert.match(state.failure, /base-trusted workflow definitions/);
|
||||
assert.equal(state.outputs.should_deploy, "false");
|
||||
});
|
||||
|
||||
it("fails closed when the expected artifact is missing", async () => {
|
||||
const pullRequest = openPullRequest(2060, headSha);
|
||||
const state = fakeCore();
|
||||
const github = fakeGithub({
|
||||
artifacts: [],
|
||||
associatedPullRequests: [pullRequest],
|
||||
pullRequest,
|
||||
});
|
||||
|
||||
await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
|
||||
|
||||
assert.match(state.failure, /did not publish preview-image-pr-2060-/);
|
||||
assert.equal(state.outputs.should_deploy, "false");
|
||||
});
|
||||
|
||||
it("skips PRs without the preview label before requiring an artifact", async () => {
|
||||
const pullRequest = openPullRequest(2060, headSha, "we-promise/sure", { labels: [] });
|
||||
const state = fakeCore();
|
||||
const github = fakeGithub({
|
||||
artifacts: [],
|
||||
associatedPullRequests: [pullRequest],
|
||||
pullRequest,
|
||||
});
|
||||
|
||||
await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
|
||||
|
||||
assert.equal(state.failure, null);
|
||||
assert.equal(state.outputs.should_deploy, "false");
|
||||
assert.match(state.messages.join("\n"), /does not have the preview-cf label/);
|
||||
});
|
||||
});
|
||||
198
workers/preview/deploy/resolve_preview_request.cjs
Normal file
198
workers/preview/deploy/resolve_preview_request.cjs
Normal file
@@ -0,0 +1,198 @@
|
||||
const PREVIEW_ARTIFACT_PATTERN = /^preview-image-pr-([1-9][0-9]*)-([a-f0-9]{40})$/;
|
||||
|
||||
function parsePreviewArtifactName(name) {
|
||||
const match = PREVIEW_ARTIFACT_PATTERN.exec(name);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
prNumber: Number(match[1]),
|
||||
headSha: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function repoFullName(context) {
|
||||
return `${context.repo.owner}/${context.repo.repo}`;
|
||||
}
|
||||
|
||||
function labelsIncludePreview(pullRequest) {
|
||||
return pullRequest.labels.some((label) => label.name === "preview-cf");
|
||||
}
|
||||
|
||||
function artifactCandidates(artifacts, headSha) {
|
||||
return artifacts
|
||||
.filter((artifact) => !artifact.expired)
|
||||
.map((artifact) => ({
|
||||
artifact,
|
||||
parsed: parsePreviewArtifactName(artifact.name),
|
||||
}))
|
||||
.filter((candidate) => candidate.parsed?.headSha === headSha);
|
||||
}
|
||||
|
||||
function uniqueNumbers(candidates) {
|
||||
return [...new Set(candidates.map((candidate) => candidate.parsed.prNumber))];
|
||||
}
|
||||
|
||||
function associatedPullRequestsForHead(associatedPullRequests, context, headSha) {
|
||||
const baseRepo = repoFullName(context);
|
||||
|
||||
return associatedPullRequests.filter((pullRequest) => (
|
||||
pullRequest.state === "open" &&
|
||||
pullRequest.head?.sha === headSha &&
|
||||
pullRequest.base?.repo?.full_name === baseRepo
|
||||
));
|
||||
}
|
||||
|
||||
function selectPullRequestNumber({ runPullRequest, artifacts, associatedPullRequests, context, headSha }) {
|
||||
if (runPullRequest?.number) {
|
||||
return {
|
||||
prNumber: runPullRequest.number,
|
||||
source: "workflow_run",
|
||||
};
|
||||
}
|
||||
|
||||
const associatedHeadPullRequests = associatedPullRequestsForHead(associatedPullRequests, context, headSha);
|
||||
const artifactPullRequestNumbers = uniqueNumbers(artifactCandidates(artifacts, headSha));
|
||||
|
||||
if (associatedHeadPullRequests.length === 1) {
|
||||
return {
|
||||
prNumber: associatedHeadPullRequests[0].number,
|
||||
source: "commit_association",
|
||||
};
|
||||
}
|
||||
|
||||
if (associatedHeadPullRequests.length > 1) {
|
||||
const associatedNumbers = new Set(associatedHeadPullRequests.map((pullRequest) => pullRequest.number));
|
||||
const artifactMatches = artifactPullRequestNumbers.filter((number) => associatedNumbers.has(number));
|
||||
|
||||
if (artifactMatches.length === 1) {
|
||||
return {
|
||||
prNumber: artifactMatches[0],
|
||||
source: "artifact_and_commit_association",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Workflow run head SHA ${headSha} is associated with multiple open pull requests and no single preview artifact matched`,
|
||||
};
|
||||
}
|
||||
|
||||
if (artifactPullRequestNumbers.length === 1) {
|
||||
return {
|
||||
prNumber: artifactPullRequestNumbers[0],
|
||||
source: "artifact_name",
|
||||
};
|
||||
}
|
||||
|
||||
if (artifactPullRequestNumbers.length > 1) {
|
||||
return {
|
||||
error: `Workflow run ${headSha} published preview artifacts for multiple pull requests`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prNumber: null,
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePreviewRequest({ github, context, core }) {
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const runPullRequest = workflowRun.pull_requests?.[0];
|
||||
const headSha = workflowRun.head_sha;
|
||||
|
||||
core.setOutput("should_deploy", "false");
|
||||
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: workflowRun.id,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const { data: associatedPullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: headSha,
|
||||
});
|
||||
|
||||
const selected = selectPullRequestNumber({
|
||||
runPullRequest,
|
||||
artifacts,
|
||||
associatedPullRequests,
|
||||
context,
|
||||
headSha,
|
||||
});
|
||||
|
||||
if (selected.error) {
|
||||
core.setFailed(selected.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selected.prNumber) {
|
||||
core.info("Workflow run is not associated with an open pull request");
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = selected.prNumber;
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
if (pullRequest.state !== "open") {
|
||||
core.info(`PR ${prNumber} is ${pullRequest.state}; skipping preview deploy`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pullRequest.head.sha !== headSha) {
|
||||
core.info(`Workflow run head SHA ${headSha} is stale for PR ${prNumber}; current head is ${pullRequest.head.sha}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPreviewLabel = labelsIncludePreview(pullRequest);
|
||||
if (!hasPreviewLabel) {
|
||||
core.info(`PR ${prNumber} does not have the preview-cf label`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const workflowChanges = files
|
||||
.map((file) => file.filename)
|
||||
.filter((filename) => filename.startsWith(".github/workflows/"));
|
||||
|
||||
if (workflowChanges.length > 0) {
|
||||
core.setFailed(`Preview deployment requires base-trusted workflow definitions; changed workflow files: ${workflowChanges.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactName = `preview-image-pr-${prNumber}-${headSha}`;
|
||||
const artifact = artifacts.find((item) => item.name === artifactName && !item.expired);
|
||||
|
||||
if (!artifact) {
|
||||
core.setFailed(`Pull Request workflow run ${workflowRun.id} did not publish ${artifactName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isFork = pullRequest.head.repo?.full_name !== repoFullName(context);
|
||||
core.info(`Resolved PR ${prNumber} from ${selected.source}; fork=${isFork}`);
|
||||
|
||||
core.setOutput("artifact_name", artifactName);
|
||||
core.setOutput("head_sha", headSha);
|
||||
core.setOutput("is_fork", String(isFork));
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("should_deploy", "true");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
artifactCandidates,
|
||||
associatedPullRequestsForHead,
|
||||
parsePreviewArtifactName,
|
||||
resolvePreviewRequest,
|
||||
selectPullRequestNumber,
|
||||
};
|
||||
Reference in New Issue
Block a user