⏪ Backport .github and docker/ changes from develop

This commit is contained in:
Andrey Antukh 2026-10-23 12:40:24 +02:00
parent 021492a577
commit 2df5f417a0
50 changed files with 1353 additions and 417 deletions

9
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,9 @@
# Changes to workflows, CI configuration and repository automation need
# review from the ci-maintainers team. Issue and PR templates do not.
/.github/workflows/ @penpot/ci-maintainers
/.github/actions/ @penpot/ci-maintainers
/.github/scripts/ @penpot/ci-maintainers
/.github/dependabot.yml @penpot/ci-maintainers
/.github/zizmor.yml @penpot/ci-maintainers
/.github/actionlint.yaml @penpot/ci-maintainers
/.github/CODEOWNERS @penpot/ci-maintainers

14
.github/actionlint.yaml vendored Normal file
View File

@ -0,0 +1,14 @@
# actionlint configuration — https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
- penpot-standar-runner
- penpot-extended-runner
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# shellcheck style/info notices that are pervasive in the existing
# scripts (unquoted `>> $GITHUB_OUTPUT`, repeated redirects). Real
# shellcheck warnings and errors are still reported.
- 'SC2086:info'
- 'SC2129:style'

View File

@ -1,25 +1,42 @@
name: Auto Label and Add to Project
# pull_request_target is required so the GitHub App secrets are available
# for PRs from forks. It is safe here: the job never checks out or runs PR
# code, it only reads ids from the event payload.
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
permissions: {}
jobs:
triage:
# The Renovate Dependency Dashboard and Renovate's GitHub Actions PRs do
# not need triage. Other Renovate PRs (base images, Node.js, pnpm) do.
if: >-
!(github.event_name == 'issues'
&& github.event.issue.user.login == 'penpot-renovate[bot]')
&& !(github.event_name == 'pull_request_target'
&& github.event.pull_request.user.login == 'penpot-renovate[bot]'
&& github.event.pull_request.head.ref == 'renovate-github-actions')
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: triage-app-token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ secrets.TRIAGE_APP_ID }}
client-id: ${{ secrets.TRIAGE_APP_CLIENT_ID }}
private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }}
owner: penpot
# No owner/repositories: the token is scoped to this repository.
# Organization permissions (the project board) still apply.
permission-issues: write
permission-pull-requests: write
permission-organization-projects: write
- name: Process Issue or PR
uses: actions/github-script@v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.triage-app-token.outputs.token }}
script: |

View File

@ -20,18 +20,39 @@ on:
required: false
default: false
permissions: {}
jobs:
build-bundle:
permissions:
contents: read
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: ${{ inputs.gh_ref }}
force: ${{ inputs.force }}
build-docker:
needs: build-bundle
permissions:
contents: read
uses: ./.github/workflows/build-docker.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: ${{ inputs.gh_ref }}
# Pin build-docker to the commit build-bundle actually bundled.
@ -39,8 +60,10 @@ jobs:
force: ${{ inputs.force }}
build-docker-admin-console:
permissions: {}
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
secrets:
ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }}
with:
gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
force: ${{ inputs.force }}

View File

@ -20,7 +20,6 @@ on:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this version already exists in S3'
type: boolean
@ -30,6 +29,17 @@ on:
sha:
description: 'Bundled commit, full 40-char SHA.'
value: ${{ jobs.check.outputs.sha }}
secrets:
AWS_ACCESS_KEY_ID:
required: false
AWS_REGION:
required: false
AWS_SECRET_ACCESS_KEY:
required: false
MATTERMOST_WEBHOOK:
required: false
S3_BUCKET:
required: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
@ -39,6 +49,9 @@ concurrency:
group: build-bundle-${{ inputs.gh_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── 1. Decide whether there is anything to build ───────────────────────
check:
@ -55,15 +68,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.gh_ref }}
- name: Extract some useful variables
id: vars
env:
GH_REF: ${{ inputs.gh_ref || github.ref_name }}
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
@ -79,8 +95,12 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
FORCE: ${{ inputs.force }}
SHORT_SHA: ${{ steps.vars.outputs.short_sha }}
BUNDLE_VERSION: ${{ steps.vars.outputs.bundle_version }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
if [ "$FORCE" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
{
echo "### 🔁 Bundle build forced"
@ -91,8 +111,8 @@ jobs:
fi
BUNDLE_EXISTS=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" \
--bucket "$S3_BUCKET" \
--key "penpot-sha-${SHORT_SHA}.zip" \
> /dev/null 2>&1 && echo "true" || echo "false")
if [ "$BUNDLE_EXISTS" = "true" ]; then
@ -100,7 +120,7 @@ jobs:
{
echo "### ⏭️ Bundle build skipped"
echo ""
echo "The bundle in S3 was already built from \`sha-${{ steps.vars.outputs.short_sha }}\` (\`${{ steps.vars.outputs.bundle_version }}\`)."
echo "The bundle in S3 was already built from \`sha-${SHORT_SHA}\` (\`${BUNDLE_VERSION}\`)."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "exists=false" >> $GITHUB_OUTPUT
@ -116,8 +136,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.gh_ref }}
@ -142,18 +163,25 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
SHORT_SHA: ${{ needs.check.outputs.short_sha }}
BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }}
run: |
aws s3 cp zips/penpot.zip \
s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ needs.check.outputs.short_sha }}.zip \
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
"s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" \
--metadata "bundle-version=${BUNDLE_VERSION}"
- name: Write step summary
env:
SHORT_SHA: ${{ needs.check.outputs.short_sha }}
BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }}
COMMIT_TITLE: ${{ needs.check.outputs.commit_title }}
run: |
{
echo "### ✅ Bundle built"
echo ""
echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.check.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.short_sha }}) — ${{ needs.check.outputs.commit_title }}"
echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -18,18 +18,39 @@ concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
build-bundle:
permissions:
contents: read
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
permissions:
contents: read
uses: ./.github/workflows/build-docker.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: "develop"
# Pin build-docker to the commit build-bundle actually bundled.
@ -37,8 +58,10 @@ jobs:
force: ${{ inputs.force || false }}
build-docker-admin-console:
permissions: {}
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
secrets:
ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }}
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}

View File

@ -39,6 +39,8 @@ on:
description: 'Token with Actions write access on penpot-nitrate'
required: true
permissions: {}
jobs:
build-nitrate-docker:
runs-on: ubuntu-latest
@ -48,22 +50,23 @@ jobs:
WORKFLOW: build-docker-admin-console.yml
GH_REF: ${{ inputs.gh_ref }}
DISPATCH_REF: ${{ inputs.dispatch_ref }}
FORCE: ${{ inputs.force }}
steps:
- name: Trigger nitrate docker build
id: dispatch
run: |
DISTINCT_ID="${{ github.run_id }}-${{ github.run_attempt }}"
CALLER_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
DISTINCT_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
CALLER_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
-f gh_ref="$GH_REF" \
-f force="${{ inputs.force }}" \
-f force="$FORCE" \
-f caller_run_id="$DISTINCT_ID" \
-f caller_run_url="$CALLER_URL"
# Locate the dispatched run using the correlation id embedded in its run-name
RUN_ID=""
for i in $(seq 1 24); do
for _ in $(seq 1 24); do
sleep 5
RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" \
--limit 10 --json databaseId,displayTitle \
@ -83,20 +86,25 @@ jobs:
echo "::notice title=Nitrate docker build::$RUN_URL"
- name: Wait for nitrate docker build
env:
RUN_ID: ${{ steps.dispatch.outputs.run_id }}
run: |
gh run watch "${{ steps.dispatch.outputs.run_id }}" \
gh run watch "$RUN_ID" \
--repo "$REPO" \
--interval 30 \
--exit-status
- name: Report result
if: always() && steps.dispatch.outputs.run_id != ''
env:
RUN_ID: ${{ steps.dispatch.outputs.run_id }}
RUN_URL: ${{ steps.dispatch.outputs.run_url }}
run: |
CONCLUSION=$(gh run view "${{ steps.dispatch.outputs.run_id }}" \
CONCLUSION=$(gh run view "$RUN_ID" \
--repo "$REPO" --json conclusion --jq '.conclusion')
{
echo "### 🐳 Nitrate docker build"
echo ""
echo "- Result: \`${CONCLUSION:-in_progress}\`"
echo "- Run: ${{ steps.dispatch.outputs.run_url }}"
echo "- Run: ${RUN_URL}"
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -3,40 +3,55 @@ name: DevEnv Docker Image Builder
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-docker-devenv
cancel-in-progress: false
jobs:
build-and-push:
name: Build and push DevEnv Docker image
runs-on: penpot-standar-runner
timeout-minutes: 90
env:
DOCKER_IMAGE: 'penpotapp/devenv'
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV"
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Registry (push destination)
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Login to Docker Hardened Images registry (base image pull)
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Build and push DevEnv Docker image
uses: docker/build-push-action@v7
env:
DOCKER_IMAGE: 'penpotapp/devenv'
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ./docker/devenv/
file: ./docker/devenv/Dockerfile
@ -56,4 +71,13 @@ jobs:
TEXT: |
🚀 *[PENPOT] New devenv available*
📄 You may want to update your devenv.
@alvaro
- name: Notify Mattermost (developers)
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: penpotdev
TEXT: |
🚀 *[PENPOT] New devenv available*
📄 You may want to update your devenv.
(\cc @alvaro)

View File

@ -19,7 +19,6 @@ on:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
sha:
description: >-
Exact commit to check out (full 40-char SHA, e.g. from
@ -33,6 +32,27 @@ on:
type: boolean
required: false
default: false
secrets:
AWS_ACCESS_KEY_ID:
required: false
AWS_REGION:
required: false
AWS_SECRET_ACCESS_KEY:
required: false
DOCKER_PASSWORD:
required: false
DOCKER_REGISTRY:
required: false
DOCKER_USERNAME:
required: false
MATTERMOST_WEBHOOK:
required: false
PUB_DOCKER_PASSWORD:
required: false
PUB_DOCKER_USERNAME:
required: false
S3_BUCKET:
required: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
@ -50,6 +70,9 @@ env:
# missing (e.g. if runners ever move to separate machines).
BUNDLE_CACHE: /var/tmp/penpot-bundle-cache
permissions:
contents: read
jobs:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
@ -58,6 +81,7 @@ jobs:
timeout-minutes: 15
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
ref_slug: ${{ steps.vars.outputs.ref_slug }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
short_sha: ${{ steps.vars.outputs.short_sha }}
@ -66,16 +90,21 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }}
- name: Extract some useful variables
id: vars
env:
GH_REF: ${{ inputs.gh_ref || github.ref_name }}
run: |
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
# A git ref accepts characters that a Docker tag does not.
REF_SLUG=$(printf '%s' "$GH_REF" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-100)
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
echo "ref_slug=$REF_SLUG" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
@ -92,13 +121,16 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
FORCE: ${{ inputs.force }}
SHORT_SHA: ${{ steps.vars.outputs.short_sha }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
if [ "$FORCE" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip"
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" "$ZIP.$$.tmp"
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
{
echo "### 🔁 Image set build forced"
@ -109,14 +141,14 @@ jobs:
fi
if aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "markers/images-sha-${{ steps.vars.outputs.short_sha }}" \
--bucket "$S3_BUCKET" \
--key "markers/images-sha-${SHORT_SHA}" \
> /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
{
echo "### ⏭️ Image set build skipped"
echo ""
echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.short_sha }}\`."
echo "The whole set was already built and promoted for \`sha-${SHORT_SHA}\`."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "exists=false" >> $GITHUB_OUTPUT
@ -126,9 +158,9 @@ jobs:
# prune stale bundles while at it.
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip"
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
if [ ! -f "$ZIP" ]; then
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" "$ZIP.$$.tmp"
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
fi
fi
@ -156,12 +188,13 @@ jobs:
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }}
- name: Login to Docker Registry
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
@ -171,7 +204,7 @@ jobs:
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
@ -183,7 +216,7 @@ jobs:
# reuses the same PUB_DOCKER_* credentials as the DockerHub
# login above.
- name: Login to Docker Hardened Images registry (base image pull)
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
@ -197,38 +230,41 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
IMAGE: ${{ matrix.image }}
run: |
ZIP="$BUNDLE_CACHE/penpot-sha-${{ needs.prepare.outputs.short_sha }}.zip"
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
if [ ! -f "$ZIP" ]; then
echo "Bundle not found in host cache; falling back to S3."
mkdir -p "$BUNDLE_CACHE"
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ needs.prepare.outputs.short_sha }}.zip" "$ZIP.$$.tmp"
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
fi
# Extract only the bundle this job needs.
pushd docker/images
unzip -q "$ZIP" "penpot/${{ matrix.image }}/*"
mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.image }}"
unzip -q "$ZIP" "penpot/${IMAGE}/*"
mv "penpot/${IMAGE}" "bundle-${IMAGE}"
popd
- name: Set up QEMU (stable)
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ matrix.image }}
labels: |
bundle_version=${{ needs.prepare.outputs.bundle_version }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.${{ matrix.image }}
@ -256,22 +292,26 @@ jobs:
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Registry
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Point branch tags to the new build key
env:
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
REF_SLUG: ${{ needs.prepare.outputs.ref_slug }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
run: |
set -e
for image in $ALL_IMAGES; do
docker buildx imagetools create \
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
"${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.short_sha }}"
-t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \
"${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}"
done
# The marker is written LAST: its presence certifies that all five
@ -281,20 +321,27 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
run: |
echo "${{ github.run_id }}" | aws s3 cp - \
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.short_sha }}"
echo "$GITHUB_RUN_ID" | aws s3 cp - \
"s3://${S3_BUCKET}/markers/images-sha-${SHORT_SHA}"
- name: Write step summary
env:
REF_SLUG: ${{ needs.prepare.outputs.ref_slug }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }}
COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }}
run: |
{
echo "### ✅ Image set promoted"
echo ""
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.prepare.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.short_sha }}) — ${{ needs.prepare.outputs.commit_title }}"
echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.short_sha }}\`."
echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 3b. Skip path: make sure THIS ref's tags point to the existing
@ -314,34 +361,43 @@ jobs:
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Registry
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Point branch tags to the existing build key
env:
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
REF_SLUG: ${{ needs.prepare.outputs.ref_slug }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
run: |
set -e
for image in $ALL_IMAGES; do
docker buildx imagetools create \
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
"${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.short_sha }}"
-t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \
"${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}"
done
- name: Write step summary
env:
REF_SLUG: ${{ needs.prepare.outputs.ref_slug }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }}
COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }}
run: |
{
echo "### ✅ Image set already built (branch tags ensured)"
echo ""
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.prepare.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.short_sha }}) — ${{ needs.prepare.outputs.commit_title }}"
echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}"
echo "- Checked at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.short_sha }}\`."
echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 4. Single failure notification for the whole workflow ─────────────

256
.github/workflows/build-renovate.yml vendored Normal file
View File

@ -0,0 +1,256 @@
name: Renovate Docker Image Check
# Triggered by pushes to Renovate branches, not by pull_request: this is a
# public repository and these jobs run on the self-hosted runners, so a PR
# from a fork must never be able to start them. Only users with write
# access can push here.
on:
push:
branches:
- 'renovate-*'
permissions: {}
concurrency:
group: renovate-image-check-${{ github.ref_name }}
cancel-in-progress: true
env:
BUNDLE_CACHE: /var/tmp/penpot-bundle-cache
jobs:
# ── 1. Pick an existing develop bundle and detect what changed ────────
prepare:
name: Prepare
runs-on: penpot-standar-runner
timeout-minutes: 10
permissions:
contents: read
outputs:
short_sha: ${{ steps.bundle.outputs.short_sha }}
images: ${{ steps.changes.outputs.images }}
devenv: ${{ steps.changes.outputs.devenv }}
imagemagick: ${{ steps.changes.outputs.imagemagick }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Find the develop commit this branch is based on
id: base
run: |
echo "sha=$(git merge-base HEAD origin/develop)" >> "$GITHUB_OUTPUT"
- name: Detect changed images
id: changes
env:
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
changed() {
git diff --quiet "$BASE_SHA" HEAD -- "$@" && echo false || echo true
}
{
# Anything outside .github can affect the production images.
echo "images=$(changed . ':!.github')"
echo "devenv=$(changed docker/devenv)"
echo "imagemagick=$(changed docker/imagemagick)"
} >> "$GITHUB_OUTPUT"
# Renovate only changes Dockerfiles and package manager versions, so
# the most recent develop bundle is enough to build the images.
- name: Find the latest develop bundle
id: bundle
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
for SHA in $(git rev-list --first-parent --max-count=50 "$BASE_SHA"); do
SHORT_SHA=$(git rev-parse --short=12 "$SHA")
if aws s3api head-object --bucket "$S3_BUCKET" \
--key "penpot-sha-${SHORT_SHA}.zip" > /dev/null 2>&1; then
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Using bundle \`penpot-sha-${SHORT_SHA}.zip\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
done
echo "::error::No bundle found for the last 50 develop commits"
exit 1
# ── 2. Production images, built from the PR Dockerfiles ───────────────
images:
name: Build ${{ matrix.image }}
runs-on: penpot-standar-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.images == 'true'
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 3
matrix:
image: [backend, frontend, exporter, storybook, mcp]
steps:
- name: Set common environment variables
env:
MATRIX_IMAGE: ${{ matrix.image }}
run: |
echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}-${MATRIX_IMAGE}" >> "$GITHUB_ENV"
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to DockerHub Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Login to Docker Hardened Images registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Prepare Penpot bundle
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
IMAGE: ${{ matrix.image }}
run: |
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
if [ ! -f "$ZIP" ]; then
mkdir -p "$BUNDLE_CACHE"
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
fi
pushd docker/images
unzip -q "$ZIP" "penpot/${IMAGE}/*"
mv "penpot/${IMAGE}" "bundle-${IMAGE}"
popd
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build Docker image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.${{ matrix.image }}
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
# ── 3. Devenv, only when the PR touches it ────────────────────────────
devenv:
name: Build devenv
runs-on: penpot-standar-runner
timeout-minutes: 90
needs: prepare
if: needs.prepare.outputs.devenv == 'true'
permissions:
contents: read
steps:
- name: Set common environment variables
run: |
echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV"
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to DockerHub Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Login to Docker Hardened Images registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build devenv image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ./docker/devenv/
file: ./docker/devenv/Dockerfile
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=registry,ref=penpotapp/devenv:buildcache
# ── 4. ImageMagick, only when the PR touches it ───────────────────────
# amd64 only: compiling ImageMagick under QEMU takes too long for a check.
imagemagick:
name: Build imagemagick
runs-on: penpot-standar-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.imagemagick == 'true'
permissions:
contents: read
steps:
- name: Set common environment variables
run: |
echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV"
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker Hardened Images registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build imagemagick image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ./docker/imagemagick/
file: ./docker/imagemagick/Dockerfile
platforms: linux/amd64
push: false

View File

@ -18,18 +18,39 @@ concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
build-bundle:
permissions:
contents: read
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
permissions:
contents: read
uses: ./.github/workflows/build-docker.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: "staging"
# Pin build-docker to the commit build-bundle actually bundled.
@ -37,8 +58,10 @@ jobs:
force: ${{ inputs.force || false }}
build-docker-admin-console:
permissions: {}
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
secrets:
ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }}
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}

View File

@ -21,31 +21,55 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
permissions: {}
jobs:
build-bundle:
permissions:
contents: read
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
permissions:
contents: read
uses: ./.github/workflows/build-docker.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
build-docker-admin-console:
permissions: {}
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
secrets:
ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }}
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
notify:
name: Notifications
permissions: {}
runs-on: ubuntu-latest
needs:
- build-docker
@ -66,7 +90,16 @@ jobs:
needs:
- build-docker
- build-docker-admin-console
permissions:
contents: write
uses: ./.github/workflows/release.yml
secrets: inherit
secrets:
AWS_REGION: ${{ secrets.AWS_REGION }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
with:
gh_ref: ${{ github.ref_name }}

View File

@ -2,23 +2,44 @@ name: _TMP TOKENS
on:
workflow_dispatch:
schedule:
- cron: '46 5-20 * * 1-5'
# schedule:
# - cron: '46 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
build-bundle:
permissions:
contents: read
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: "hiru-tokens-in-libs"
build-docker:
needs: build-bundle
permissions:
contents: read
uses: ./.github/workflows/build-docker.yml
secrets: inherit
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
with:
gh_ref: "hiru-tokens-in-libs"

View File

@ -7,19 +7,16 @@ on:
- reopened
- synchronize
- ready_for_review
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
push:
branches:
- main
- develop
- staging
permissions:
contents: read
pull-requests: read
jobs:
check-commit-message:
if: ${{ !github.event.pull_request.draft }}
@ -27,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check Commit Type
uses: gsactions/commit-message-checker@v2
uses: gsactions/commit-message-checker@16fa2d5de096ae0d35626443bcd24f1e756cafee # v2.0.0
with:
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
flags: 'gm'
@ -59,7 +56,7 @@ jobs:
# always the trusted version: PR commits are only read, never
# executed. On push events this resolves to the pushed sha.
- name: Checkout trusted code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.base.sha || github.sha }}

78
.github/workflows/lint-workflows.yml vendored Normal file
View File

@ -0,0 +1,78 @@
name: "CI: Workflows"
# Static analysis of the GitHub Actions workflows themselves:
# - actionlint: syntax, expressions, runner labels, job/step references,
# and shellcheck on every `run:` block.
# - zizmor: security audits (template injection, dangerous triggers,
# credential persistence, ...). Configured in .github/zizmor.yml.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.github/actionlint.yaml'
- '.github/zizmor.yml'
push:
branches:
- develop
- staging
- main
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.github/actionlint.yaml'
- '.github/zizmor.yml'
workflow_dispatch:
concurrency:
group: lint-workflows-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
ACTIONLINT_VERSION: '1.7.12'
ZIZMOR_VERSION: '1.31.1'
jobs:
lint:
name: Lint workflows
if: ${{ !github.event.pull_request.draft }}
# PR code is untrusted: never run this on the self-hosted runners.
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install actionlint
run: |
curl -fsSL --retry 3 \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
# Each finding is emitted as a `::error` annotation on the PR diff.
- name: Run actionlint
run: |
# shellcheck disable=SC2016 # Go template, not shell expansion.
actionlint -format '{{range $err := .}}::error file={{$err.Filepath}},line={{$err.Line}},col={{$err.Column}},title=actionlint ({{$err.Kind}})::{{$err.Message}}\n{{end}}'
- name: Run zizmor
if: ${{ !cancelled() }}
env:
# Enables the online audits (known-vulnerable-actions,
# impostor-commit, ref-confusion, ...).
GH_TOKEN: ${{ github.token }}
run: |
pipx run "zizmor==${ZIZMOR_VERSION}" \
--config .github/zizmor.yml \
--format github \
.github/workflows

View File

@ -33,43 +33,32 @@ jobs:
steps:
- name: Extract some useful variables
id: vars
env:
GH_REF: ${{ inputs.gh_ref || github.ref_name }}
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
# No dependency cache here: a deploy must not restore a cache that
# other workflows can write to.
- name: Setup pnpm + Node.js
uses: pnpm/setup@v2
uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1
with:
working-directory: plugins
runtime: node@24.21.0
runtime: node@24.22.1
install: false
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm install --frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: Build docs
@ -78,8 +67,9 @@ jobs:
run: pnpm run build:doc
- name: Select Worker name
env:
REF: ${{ steps.vars.outputs.gh_ref }}
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV
@ -97,7 +87,7 @@ jobs:
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml
sed -i "s/WORKER_URI/${WORKER_URI}/g" wrangler-penpot-plugins-api-doc.toml
- name: Add noindex header and robots.txt files for non-production environments
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
@ -117,7 +107,7 @@ jobs:
EOF
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@ -23,11 +23,17 @@ on:
description: 'Name of the branch'
type: string
required: true
default: 'develop'
plugin_name:
description: 'Publig name (from plugins/apps/<plugin_name>-plugin)'
type: string
required: true
secrets:
CLOUDFLARE_ACCOUNT_ID:
required: false
CLOUDFLARE_API_TOKEN:
required: false
MATTERMOST_WEBHOOK:
required: false
permissions:
contents: read
@ -35,72 +41,71 @@ permissions:
jobs:
deploy:
runs-on: penpot-standar-runner
container:
image: penpotapp/devenv:latest
volumes:
# Persistent pnpm store shared by all runner instances on the host.
# pnpm's content-addressable store is safe for concurrent access.
- /var/cache/github-runner/pnpm-store:/root/.local/share/pnpm/store
defaults:
run:
shell: bash
env:
GH_REF: ${{ inputs.gh_ref }}
PLUGIN_NAME: ${{ inputs.plugin_name }}
steps:
# plugin_name is a free-form string that ends up in paths, worker names
# and GITHUB_ENV; reject anything that is not a plain slug before it is
# used anywhere.
- name: Validate inputs
run: |
if ! [[ "$PLUGIN_NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "::error::Invalid plugin_name: must match ^[a-z0-9][a-z0-9-]*$"
exit 1
fi
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
- name: Setup pnpm + Node.js
uses: pnpm/setup@v2
with:
working-directory: plugins
runtime: node@24.21.0
install: false
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm install --frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: "Build package for ${{ inputs.plugin_name }}-plugin"
working-directory: plugins
shell: bash
run: pnpm --filter ${{ inputs.plugin_name }}-plugin build
run: pnpm --filter "${PLUGIN_NAME}-plugin" build
- name: Select Worker name
run: |
REF="${{ inputs.gh_ref }}"
case "$REF" in
case "$GH_REF" in
main)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;;
echo "WORKER_NAME=${PLUGIN_NAME}-plugin-pro" >> $GITHUB_ENV
echo "WORKER_URI=${PLUGIN_NAME}.plugins.penpot.app" >> $GITHUB_ENV ;;
staging)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;;
echo "WORKER_NAME=${PLUGIN_NAME}-plugin-pre" >> $GITHUB_ENV
echo "WORKER_URI=${PLUGIN_NAME}.plugins.penpot.dev" >> $GITHUB_ENV ;;
develop)
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${REF}" && exit 1 ;;
echo "WORKER_NAME=${PLUGIN_NAME}-plugin-hourly" >> $GITHUB_ENV
echo "WORKER_URI=${PLUGIN_NAME}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
*) echo "Unsupported branch ${GH_REF}" && exit 1 ;;
esac
- name: Set the custom url
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml
sed -i "s/WORKER_URI/${WORKER_URI}/g" "apps/${PLUGIN_NAME}-plugin/wrangler.toml"
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@ -21,6 +21,9 @@ on:
- staging
- main
permissions:
contents: read
jobs:
detect-changes:
runs-on: ubuntu-latest
@ -36,9 +39,11 @@ jobs:
# [For new plugins]
# Add more outputs here
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: filter
uses: dorny/paths-filter@v4
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
filters: |
colors_to_tokens:
@ -72,7 +77,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.colors_to_tokens == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: colors-to-tokens
@ -81,7 +89,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.contrast == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: contrast
@ -90,7 +101,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.create_palette == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: create-palette
@ -99,7 +113,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.icons == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: icons
@ -108,7 +125,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.lorem_ipsum == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: lorem-ipsum
@ -117,7 +137,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rename_layers == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: rename-layers
@ -126,7 +149,10 @@ jobs:
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.table == 'true'
uses: ./.github/workflows/plugins-deploy-package.yml
secrets: inherit
secrets:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }}
with:
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
plugin_name: table

View File

@ -31,43 +31,32 @@ jobs:
steps:
- name: Extract some useful variables
id: vars
env:
GH_REF: ${{ inputs.gh_ref || github.ref_name }}
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
# START: Setup Node and PNPM enabling cache
# No dependency cache here: a deploy must not restore a cache that
# other workflows can write to.
- name: Setup pnpm + Node.js
uses: pnpm/setup@v2
uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1
with:
working-directory: plugins
runtime: node@24.21.0
runtime: node@24.22.1
install: false
- name: Get pnpm store path
id: pnpm-store
working-directory: ./plugins
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# END: Setup Node and PNPM enabling cache
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
pnpm install --no-frozen-lockfile;
pnpm install --frozen-lockfile;
pnpm add -D -w wrangler@latest;
- name: Build styles
@ -76,8 +65,9 @@ jobs:
run: pnpm run build:styles-example
- name: Select Worker name
env:
REF: ${{ steps.vars.outputs.gh_ref }}
run: |
REF="${{ steps.vars.outputs.gh_ref }}"
case "$REF" in
main)
echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV
@ -95,7 +85,7 @@ jobs:
working-directory: plugins
shell: bash
run: |
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml
sed -i "s/WORKER_URI/${WORKER_URI}/g" wrangler-penpot-plugins-styles-doc.toml
- name: Add noindex header and robots.txt files for non-production environments
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
@ -115,7 +105,7 @@ jobs:
EOF
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
workingDirectory: plugins
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@ -13,6 +13,21 @@ on:
description: 'Tag to release'
type: string
required: true
secrets:
AWS_REGION:
required: false
DOCKER_PASSWORD:
required: false
DOCKER_REGISTRY:
required: false
DOCKER_USERNAME:
required: false
MATTERMOST_WEBHOOK:
required: false
PUB_DOCKER_PASSWORD:
required: false
PUB_DOCKER_USERNAME:
required: false
permissions:
contents: write
@ -26,18 +41,21 @@ jobs:
steps:
- name: Extract some useful variables
id: vars
env:
GH_REF: ${{ inputs.gh_ref || github.ref_name }}
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ steps.vars.outputs.gh_ref }}
# --- Publicly release the docker images ---
- name: Configure ECR credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
aws-access-key-id: ${{ secrets.DOCKER_USERNAME }}
aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }}
@ -92,14 +110,19 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
# --- Create GitHub release ---
# Updates the release if it already exists, so re-running is safe.
- name: Create GitHub release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.vars.outputs.gh_ref }}
name: ${{ steps.vars.outputs.gh_ref }}
body: ${{ steps.extract_release_notes.outputs.release_notes }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ steps.vars.outputs.gh_ref }}
NOTES: ${{ steps.extract_release_notes.outputs.release_notes }}
run: |
if gh release view "$TAG" > /dev/null 2>&1; then
gh release edit "$TAG" --title "$TAG" --notes "$NOTES"
else
gh release create "$TAG" --verify-tag --title "$TAG" --notes "$NOTES"
fi
- name: Notify Mattermost
if: failure()

View File

@ -28,6 +28,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
@ -60,7 +63,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fmt
working-directory: ./backend
@ -75,6 +80,10 @@ jobs:
- name: Tests
working-directory: ./backend
env:
# `clojure.core/assert` is elided at compile time, so without this
# flag the precondition asserts do not exist in the compiled code
# and the suite silently stops checking them.
PENPOT_FLAGS: "enable-backend-asserts"
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test

View File

@ -26,6 +26,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
@ -39,7 +42,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Lint
working-directory: ./common

View File

@ -86,6 +86,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── 1. Build the frontend bundle once ──────────────────────────────────
build-bundle:
@ -107,8 +110,9 @@ jobs:
# An empty `ref` makes checkout fall back to its default (the PR merge
# ref on pull_request, the pushed ref on push).
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.gh_ref }}
# The cache key must come from the SHA actually checked out: on a manual
@ -120,7 +124,7 @@ jobs:
- name: Restore Bundle Cache
id: restore
uses: actions/cache/restore@v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
@ -133,7 +137,7 @@ jobs:
- name: Store Bundle Cache
if: steps.restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
@ -163,12 +167,13 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.gh_ref }}
- name: Restore Cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ needs.build-bundle.outputs.bundle_key }}
path: frontend/resources/public
@ -190,16 +195,18 @@ jobs:
working-directory: ./frontend
env:
WORKERS: ${{ inputs.workers }}
SHARD: ${{ matrix.shard }}
SHARD_TOTAL: ${{ strategy.job-total }}
run: |
WORKERS=${WORKERS:-2}
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
echo "Running shard ${SHARD}/${SHARD_TOTAL} with $WORKERS workers"
pnpm exec playwright test --project default \
--workers="$WORKERS" \
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
--shard="${SHARD}/${SHARD_TOTAL}" \
--reporter=blob
- name: Upload blob report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: integration-blob-report-${{ matrix.shard }}
@ -208,7 +215,7 @@ jobs:
retention-days: 3
- name: Upload test result
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: integration-tests-result-${{ matrix.shard }}
@ -232,11 +239,13 @@ jobs:
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The driver serves the prebuilt bundle from frontend/resources/public.
- name: Restore Cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ needs.build-bundle.outputs.bundle_key }}
path: frontend/resources/public
@ -270,11 +279,13 @@ jobs:
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Mocked mode serves the prebuilt bundle from frontend/resources/public.
- name: Restore Cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: ${{ needs.build-bundle.outputs.bundle_key }}
path: frontend/resources/public
@ -316,8 +327,9 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.gh_ref }}
- name: Install deps
@ -326,7 +338,7 @@ jobs:
pnpm install --frozen-lockfile;
- name: Download blob reports
uses: actions/download-artifact@v7
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: frontend/all-blob-reports
pattern: integration-blob-report-*
@ -355,7 +367,7 @@ jobs:
>> "$GITHUB_STEP_SUMMARY"
- name: Upload JSON report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: integration-json-report
@ -365,7 +377,7 @@ jobs:
retention-days: 7
- name: Upload HTML report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: integration-html-report

View File

@ -9,6 +9,7 @@ on:
paths:
- 'exporter/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
@ -23,11 +24,15 @@ on:
paths:
- 'exporter/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
@ -41,7 +46,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Lint
working-directory: ./exporter
@ -54,3 +61,8 @@ jobs:
working-directory: ./exporter
run: |
./scripts/test
- name: Check no invoke_* in exporter JS glue
working-directory: ./render-wasm
run: |
./check-no-invoke-v export

View File

@ -30,6 +30,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
@ -43,7 +46,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Lint
working-directory: ./frontend

View File

@ -28,6 +28,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
@ -41,7 +44,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Lint
working-directory: ./library

View File

@ -24,6 +24,9 @@ on:
paths:
- 'mcp/**'
permissions:
contents: read
jobs:
test-mcp:
if: ${{ !github.event.pull_request.draft }}
@ -33,7 +36,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup
working-directory: ./mcp

View File

@ -26,6 +26,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
@ -38,7 +41,9 @@ jobs:
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install deps
working-directory: ./plugins

View File

@ -26,6 +26,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
@ -39,7 +42,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Format
working-directory: ./render-wasm
@ -55,3 +60,13 @@ jobs:
working-directory: ./render-wasm
run: |
./test
- name: Build
working-directory: ./render-wasm
run: |
NODE_ENV=production ./build frontend
- name: Check no invoke_*V in JS glue
working-directory: ./render-wasm
run: |
./check-no-invoke-v

39
.github/zizmor.yml vendored Normal file
View File

@ -0,0 +1,39 @@
# zizmor configuration — https://docs.zizmor.sh/configuration/
#
# Every audit is enforced for every workflow. New workflows are checked with
# no exceptions. The entries below are the only remaining ones, and each is
# marked as either a pending clean-up or an accepted risk with its reason.
# Remove a file from a list in the same PR that fixes it, and the whole rule
# entry once its list is empty.
rules:
# Accepted: wrangler is added with `pnpm add wrangler@latest` before each
# deploy instead of being a dependency of the plugins workspace. Only used
# to publish to Cloudflare; not worth adding to the workspace.
adhoc-packages:
ignore:
- plugins-deploy-api-doc.yml
- plugins-deploy-package.yml
- plugins-deploy-styles-doc.yml
# Accepted: auto-label.yml needs pull_request_target so the GitHub App
# secrets are available for PRs from forks. It never checks out or runs
# PR code, it only reads ids from the event payload.
dangerous-triggers:
ignore:
- auto-label.yml
# Style nudge towards the `$/...` syntax; not worth enforcing.
self-repository:
disable: true
# Accepted: penpotapp/devenv is our own image, built and pushed by
# build-docker-devenv.yml, so the floating tag is intentional.
unpinned-images:
ignore:
- plugins-deploy-package.yml
- tests-backend.yml
- tests-common.yml
- tests-e2e.yml
- tests-exporter.yml
- tests-frontend.yml
- tests-library.yml
- tests-mcp.yml
- tests-plugins.yml
- tests-wasm.yml

View File

@ -1,4 +1,4 @@
FROM dhi.io/debian-base:trixie-debian13-dev AS base
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base
ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
@ -66,8 +66,8 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v24.21.0 \
PNPM_VERSION=12.5.1 \
ENV NODE_VERSION=v24.22.1 \
PNPM_VERSION=12.15.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@ -76,14 +76,16 @@ RUN set -eux; \
aarch64|arm64) \
OPENSSL_ARCH='linux-aarch64'; \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \
PNPM_ARCH='arm64'; \
PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \
PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \
;; \
amd64|x86_64) \
OPENSSL_ARCH='linux-x86_64'; \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \
PNPM_ARCH='x64'; \
PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \
PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@ -91,6 +93,7 @@ RUN set -eux; \
;; \
esac; \
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
echo "${NODE_SHA256} */tmp/nodejs.tar.gz" | sha256sum -c -; \
mkdir -p /opt/node; \
cd /opt/node; \
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
@ -103,72 +106,13 @@ RUN set -eux; \
find /opt/node/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; ; \
rm -f /tmp/nodejs.tar.gz /tmp/pnpm.tar.gz;
################################################################################
## OPENCODE SETUP
################################################################################
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.18.31
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; exit 1; \
;; \
esac; \
curl -fsSL ${BINARY_URL} -o /tmp/opencode.tar.gz; \
mkdir -p /tmp/opencode; \
tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode; \
chmod +x /tmp/opencode/opencode; \
rm -f /tmp/opencode.tar.gz;
################################################################################
## OPENCODE2 SETUP (V2, side-by-side with V1 `opencode`)
################################################################################
FROM base AS setup-opencode2
ENV OPENCODE2_VERSION=2.0.12
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE2_VERSION}.tgz"; \
ESUM='33f0dde9f0f055b6a3665d290371bcfc8c63daceade7a535c4b94d386f2bd917'; \
;; \
amd64|x86_64) \
BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE2_VERSION}.tgz"; \
ESUM='2a79beb2e24382cb2bdbb709237dbc22f498cf52a106d029a6a3a3243760c78b'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; exit 1; \
;; \
esac; \
curl -LfsSo /tmp/opencode2.tgz "${BINARY_URL}"; \
echo "${ESUM} */tmp/opencode2.tgz" | sha256sum -c -; \
mkdir -p /tmp/opencode2; \
tar -xzf /tmp/opencode2.tgz -C /tmp/opencode2 --strip-components=2 package/bin; \
mv /tmp/opencode2/opencode /tmp/opencode2/opencode2; \
chmod +x /tmp/opencode2/opencode2; \
rm -f /tmp/opencode2.tgz;
################################################################################
## CADDYSERVER SETUP
################################################################################
FROM base AS setup-caddy
ENV CADDY_VERSION=2.11.4
ENV CADDY_VERSION=2.11.7
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
@ -282,13 +226,13 @@ RUN set -eux; \
FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.08.04 \
BABASHKA_VERSION=1.13.223 \
CLJFMT_VERSION=0.16.5 \
PIXI_VERSION=0.81.0 \
GITHUB_CLI_VERSION=2.101.0 \
AWS_CLI_VERSION=2.36.49 \
UV_VERSION=0.12.17 \
ENV CLJKONDO_VERSION=2026.10.20 \
BABASHKA_VERSION=1.13.226 \
CLJFMT_VERSION=0.16.6 \
PIXI_VERSION=0.83.0 \
GITHUB_CLI_VERSION=2.103.0 \
AWS_CLI_VERSION=2.38.5 \
UV_VERSION=0.13.5 \
UV_TOOL_DIR=/opt/uv/tools \
UV_TOOL_BIN_DIR=/opt/utils/bin \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
@ -403,11 +347,11 @@ RUN set -ex; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_ARCH="aarch64"; \
BINARY_SHA256="2dec26fdaff2f5e34961ea98f456297104f73d8409fc3599c8f2909c6e27a07e"; \
BINARY_SHA256="07711a936e9d271045ab937d2c2c5390c25f419e4ba9e25adb9e996031e3f6be"; \
;; \
amd64|x86_64) \
BINARY_ARCH="x86_64"; \
BINARY_SHA256="6b51b62d232217350e72d8f2493f47d3461239d011d1d5eef960dfcd8cb07069"; \
BINARY_SHA256="5def7d1b7e55d892f9a381bb2cb2081a685712464be0d36d5d706af0ac48d0d3"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@ -476,6 +420,7 @@ RUN set -ex; \
nginx \
fd-find \
bat \
gettext \
\
fontconfig \
woff-tools \
@ -525,7 +470,6 @@ RUN set -ex; \
libjpeg62-turbo \
libtiff6 \
libwebp7 \
libopenexr-3-1-30 \
libfreetype6 \
libfontconfig1 \
libglib2.0-0 \
@ -575,7 +519,7 @@ ENV LANG='C.UTF-8' \
SERENA_CONTEXT="claude-code" \
PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick
COPY --from=setup-jvm /opt/jdk /opt/jdk
COPY --from=setup-jvm /opt/clojure /opt/clojure
COPY --from=setup-node /opt/node /opt/node
@ -586,8 +530,6 @@ COPY --from=setup-rust /opt/cargo /opt/cargo
COPY --from=setup-rust /opt/rustup /opt/rustup
COPY --from=setup-rust /opt/emsdk /opt/emsdk
COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy
COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode
COPY --from=setup-opencode2 /tmp/opencode2/opencode2 /opt/utils/bin/opencode2
COPY files/nginx.conf /etc/nginx/nginx.conf
COPY files/nginx-mime.types /etc/nginx/mime.types

View File

@ -15,7 +15,7 @@ volumes:
services:
rustfs:
image: "rustfs/rustfs:1.0.0"
image: "rustfs/rustfs:1.0.3"
command: /data
volumes:
@ -68,7 +68,7 @@ services:
- postgres
valkey:
image: valkey/valkey:8.1
image: valkey/valkey:8.1.10
restart: always
command: valkey-server --save 120 1 --loglevel warning
volumes:
@ -79,7 +79,7 @@ services:
- valkey
mailer:
image: axllent/mailpit:v1.31.1
image: axllent/mailpit:v1.32.0
restart: always
volumes:
- "mailpit_data:/data"

View File

@ -81,6 +81,11 @@ services:
- PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD}
- PENPOT_DATABASE_MAX_POOL_SIZE=${PENPOT_DATABASE_MAX_POOL_SIZE}
- PENPOT_REDIS_URI=${PENPOT_REDIS_URI}
# Per-instance backend-test config. Values come from
# instance-env-overrides in manage.sh; the main database above stays
# shared by all instances on purpose.
- PENPOT_TEST_DATABASE_URI=${PENPOT_TEST_DATABASE_URI}
- PENPOT_TEST_REDIS_URI=${PENPOT_TEST_REDIS_URI}
- PENPOT_OBJECTS_STORAGE_BACKEND=${PENPOT_OBJECTS_STORAGE_BACKEND}
- PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}
- PENPOT_OBJECTS_STORAGE_S3_BUCKET=${PENPOT_OBJECTS_STORAGE_S3_BUCKET}
@ -93,7 +98,7 @@ services:
# Agentic devenv: set to a PyPI release version/tag to update Serena on startup,
# leave empty to skip update and use the version baked into the image.
- SERENA_UPDATE_VERSION=1.6.1
- SERENA_UPDATE_VERSION=
- SHADOW_SERVER_URL=${SHADOW_SERVER_URL}
networks:

View File

@ -27,7 +27,7 @@ fi
# pnpm
export PNPM_HOME="/home/penpot/.local/share/pnpm"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
*":$PNPM_HOME/bin:"*) ;;
*) export PATH="$PNPM_HOME/bin:$PATH" ;;
esac
# pnpm end

View File

@ -10,6 +10,10 @@ cp /root/.bashrc /home/penpot/.bashrc
cp /root/.vimrc /home/penpot/.vimrc
cp /root/.tmux.conf /home/penpot/.tmux.conf
chown -R penpot:penpot /home/penpot/.bashrc
chown -R penpot:penpot /home/penpot/.vimrc
chown -R penpot:penpot /home/penpot/.tmux.conf
# Seed SERENA_HOME with default config on first run
mkdir -p ${SERENA_HOME}
if [ ! -f "${SERENA_HOME}/serena_config.yml" ]; then
@ -21,6 +25,16 @@ chown penpot:users /home/penpot
# we need to be able to install rust-analyzer and possibly other dependencies with rustup
chown -R penpot:penpot /opt/rustup
# pnpm keeps its global dir (bin, global, store, package-manager-store)
# under ~/.local and its metadata cache under ~/.cache; opencode and other
# user-space tools write there too. Hand both trees back to penpot so
# packages, and pnpm itself, can be updated without root. Needed because the
# UID remap above can leave everything the image created owned by the old
# UID. ~/.config is deliberately excluded: the opencode overlay bind-mounts
# a host directory inside it.
mkdir -p /home/penpot/.local /home/penpot/.cache
chown -R penpot:penpot /home/penpot/.local /home/penpot/.cache
rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/
export JAVA_OPTS="-Djava.net.preferIPv4Stack=true"

View File

@ -58,6 +58,13 @@ http {
'' close;
}
# Link preview crawlers; their requests for the application root
# are served with dynamic Open Graph metadata from the backend.
map $http_user_agent $penpot_link_preview_agent {
default 0;
"~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky|applebot|googlebot|bingbot|bingpreview|duckduckbot|mattermost-bot|opengraph[.]xyz|twitter card validator)" 1;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
@ -125,6 +132,10 @@ http {
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
}
location = /link-preview {
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args;
}
# On production, this is controlled by ELB
location /api/export {
proxy_pass http://127.0.0.1:6061;
@ -170,11 +181,6 @@ http {
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass http://127.0.0.1:4401/sse;
proxy_http_version 1.1;
}
location /admin {
proxy_pass http://127.0.0.1:6063/admin;
}
@ -301,6 +307,10 @@ http {
return 301 " /404";
}
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf;
add_header Cache-Control "no-store" always;
try_files $uri /index.html$is_args$args /index.html =404;

View File

@ -1,4 +1,4 @@
FROM dhi.io/debian-base:trixie-debian13-dev AS build
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \
@ -6,11 +6,10 @@ ENV LANG='C.UTF-8' \
DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC
ARG IMAGEMAGICK_VERSION=7.1.2-27
ARG IMAGEMAGICK_VERSION=7.1.2-32
RUN set -e; \
apt-get -qq update; \
apt-get -qq upgrade; \
apt-get -qqy --no-install-recommends install init-system-helpers; \
apt-get -qqy --no-install-recommends install \
autoconf \
@ -24,7 +23,6 @@ RUN set -e; \
liblcms2-dev \
libltdl-dev \
liblzma-dev \
libopenexr-dev \
libxml2-dev \
libpng-dev \
librsvg2-dev \
@ -39,7 +37,7 @@ RUN set -e; \
mkdir -p /tmp/magick; \
cd /tmp/magick; \
tar -xf /tmp/magick.tar.gz --strip-components=1; \
./configure --prefix=/opt/imagick; \
./configure --prefix=/opt/imagick --without-openexr; \
make -j 2; \
make install; \
rm -rf /opt/imagick/lib/libMagick++*; \
@ -58,7 +56,6 @@ RUN set -ex; \
libheif1 \
libjpeg62-turbo \
liblcms2-2 \
libopenexr-3-1-30 \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
@ -77,7 +74,7 @@ RUN set -ex; \
rm -rf /var/lib/apt/lists/*
FROM dhi.io/debian-base:trixie-debian13 AS image
FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \

View File

@ -1,4 +1,4 @@
FROM dhi.io/debian-base:trixie-debian13-dev AS build
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \
@ -9,11 +9,11 @@ ENV LANG='C.UTF-8' \
RUN set -ex; \
apt-get -qq update; \
apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \
apt-get -qqy --no-install-recommends install \
binutils \
ca-certificates \
curl \
gzip \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
@ -27,12 +27,12 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='5b222fce0b7076a10ac7ae3b1009a6c2caf4f35bc4e81de72010af6750c5e146'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_aarch64.tar.gz'; \
ESUM='8ff07320636ef5b30f0d7e16687932df52255c3ef3ffd920a6aae3723511ce7f'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='4b7c114917aebd0fc6284fc7111245d7747a4d9603bd12d86b384b1abc9d575d'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_x64.tar.gz'; \
ESUM='c6b810ca659e3293205444f47ac4af163396fd6d025c51fd5f767e628b2ceede'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@ -53,7 +53,7 @@ RUN set -eux; \
--output /opt/jre;
FROM dhi.io/debian-base:trixie-debian13-dev AS image
FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \
@ -68,7 +68,6 @@ RUN set -ex; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install passwd; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \
apt-get -qqy --no-install-recommends install \
ca-certificates \
curl \
@ -81,7 +80,6 @@ RUN set -ex; \
libheif1 \
libjpeg62-turbo \
liblcms2-2 \
libopenexr-3-1-30 \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
@ -108,7 +106,7 @@ RUN set -ex; \
chown -R penpot:penpot /opt/data;
COPY --from=build /opt/jre /opt/jre
COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick
COPY files/imagemagick-policy.xml /opt/imagick/etc/ImageMagick-7/policy.xml

View File

@ -1,4 +1,4 @@
FROM dhi.io/node:24.20.0-debian13-dev
FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
@ -11,12 +11,12 @@ RUN set -ex; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install passwd; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
gzip \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
@ -26,7 +26,7 @@ RUN set -ex; \
RUN set -ex; \
apt-get -qq update; \
apt-get -qqy install \
apt-get -qqy --no-install-recommends install \
\
xvfb \
fonts-noto-color-emoji \
@ -37,10 +37,12 @@ RUN set -ex; \
xfonts-scalable \
fonts-liberation \
fonts-ipafont-gothic \
fonts-ipafont-mincho \
fonts-wqy-zenhei \
fonts-tlwg-loma-otf \
fonts-freefont-ttf \
poppler-utils \
poppler-data \
\
libasound2 \
libatk-bridge2.0-0 \
@ -68,7 +70,6 @@ RUN set -ex; \
libheif1 \
libjpeg62-turbo \
liblcms2-2 \
libopenexr-3-1-30 \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
@ -87,24 +88,24 @@ RUN set -ex; \
ARG BUNDLE_PATH="./bundle-exporter/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/
COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick
WORKDIR /opt/penpot/exporter
# pnpm ships as a system binary (same tarball + SHA pin as
# docker/devenv/Dockerfile); the generated ./setup expects it on PATH.
# Corepack is gone from Node 25+, so nothing here may use it.
ARG PNPM_VERSION=12.5.1
ARG PNPM_VERSION=12.15.0
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
PNPM_ARCH='arm64'; \
PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \
PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \
;; \
amd64|x86_64) \
PNPM_ARCH='x64'; \
PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \
PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \

View File

@ -1,4 +1,4 @@
FROM dhi.io/nginx:1.31.1-debian13-dev
FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964
LABEL maintainer="Penpot <docker@penpot.app>"
ENV DEBIAN_FRONTEND=noninteractive
@ -7,7 +7,6 @@ USER root
RUN set -ex; \
apt-get -qq update; \
apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \
apt-get -qqy -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" --no-install-recommends install bash gettext-base passwd; \
rm -rf /var/lib/apt/lists/*; \
groupadd -f -g 1001 penpot; \
@ -27,11 +26,20 @@ COPY ./files/nginx.conf.template /tmp/nginx.conf.template
COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template
COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template
COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template
COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf
COPY ./files/nginx-security-headers.conf.template /tmp/nginx-security-headers.conf.template
COPY ./files/nginx-mime.types /etc/nginx/mime.types
COPY ./files/nginx-external-locations.conf /etc/nginx/overrides/location.d/external-locations.conf
COPY ./files/nginx-entrypoint.sh /entrypoint.sh
# The CSP hashes of the inline scripts of index.html are emitted by the
# frontend build. Move them out of the document root: nginx must read them,
# the browser has no reason to.
RUN if [ -f /var/www/app/csp-script-hashes.txt ]; then \
mv /var/www/app/csp-script-hashes.txt /etc/nginx/csp-script-hashes.txt; \
else \
echo "WARNING: the frontend bundle does not provide csp-script-hashes.txt" >&2; \
fi
RUN chown -R 1001:0 /var/cache/nginx; \
chmod -R g+w /var/cache/nginx; \
chown -R 1001:0 /etc/nginx; \

View File

@ -1,4 +1,4 @@
FROM dhi.io/node:24.20.0-debian13-dev AS build
FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build
LABEL maintainer="Penpot <docker@penpot.app>"
ENV DEBIAN_FRONTEND=noninteractive
@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp
# pnpm ships as a system binary (same tarball + SHA pin as
# docker/devenv/Dockerfile); ./setup expects it on PATH.
# Corepack is gone from Node 25+, so nothing here may use it.
ARG PNPM_VERSION=12.5.1
ARG PNPM_VERSION=12.15.0
RUN set -eux; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \
@ -20,11 +20,11 @@ RUN set -eux; \
case "${ARCH}" in \
aarch64|arm64) \
PNPM_ARCH='arm64'; \
PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \
PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \
;; \
amd64|x86_64) \
PNPM_ARCH='x64'; \
PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \
PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@ -42,7 +42,7 @@ RUN set -eux; \
RUN ./setup
FROM dhi.io/node:24.20.0-debian13 AS image
FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 AS image
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \

View File

@ -1,19 +1,16 @@
FROM ubuntu:26.04
# syntax=docker/dockerfile:1
FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v24.20.0 \
PNPM_VERSION=12.5.1 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
DEBIAN_FRONTEND=noninteractive
# passwd provides useradd, not preinstalled on the DHI base image.
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \
apt-get -qqy --no-install-recommends install passwd; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
@ -35,9 +32,8 @@ RUN set -ex; \
\
libgomp1 \
libheif1 \
libjpeg-turbo8 \
libjpeg62-turbo \
liblcms2-2 \
libopenexr-3-1-30 \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
@ -45,44 +41,12 @@ RUN set -ex; \
libwebp7 \
libwebpdemux2 \
libwebpmux3 \
libxml2-16 \
libxml2 \
libzip5 \
libzstd1 \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*;
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
PNPM_ARCH='arm64'; \
PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \
;; \
amd64|x86_64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
PNPM_ARCH='x64'; \
PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
mkdir -p /opt/node; \
cd /opt/node; \
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
chown -R root /opt/node; \
rm -rf /tmp/nodejs.tar.gz; \
PNPM_URL="https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/pnpm-linux-${PNPM_ARCH}.tar.gz"; \
curl -LfsSo /tmp/pnpm.tar.gz "${PNPM_URL}"; \
echo "${PNPM_SHA256} */tmp/pnpm.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/pnpm.tar.gz -C /opt/node/bin pnpm; \
chmod 755 /opt/node/bin/pnpm; \
rm -f /tmp/pnpm.tar.gz; \
pnpm --version; \
rm -rf /var/lib/apt/lists/*; \
mkdir -p /opt/penpot; \
chown -R penpot:penpot /opt/penpot;
@ -90,8 +54,42 @@ ARG BUNDLE_PATH="./bundle-media-processor/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/
WORKDIR /opt/penpot/media-processor
# pnpm ships as a system binary (same tarball + SHA pin as
# docker/devenv/Dockerfile); ./setup expects it on PATH.
# Corepack is gone from Node 25+, so nothing here may use it.
ARG PNPM_VERSION=12.15.0
RUN set -eux; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install gzip; \
rm -rf /var/lib/apt/lists/*; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
PNPM_ARCH='arm64'; \
PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \
;; \
amd64|x86_64) \
PNPM_ARCH='x64'; \
PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/pnpm.tar.gz "https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/pnpm-linux-${PNPM_ARCH}.tar.gz"; \
echo "${PNPM_SHA256} */tmp/pnpm.tar.gz" | sha256sum -c -; \
mkdir -p /usr/local/bin; \
tar -xzf /tmp/pnpm.tar.gz -C /usr/local/bin pnpm; \
chmod 755 /usr/local/bin/pnpm; \
rm -f /tmp/pnpm.tar.gz; \
pnpm --version;
# Runs as root: this base image installs Node system-wide (symlinked into
# /usr/bin), so ./setup needs write access there.
RUN ./setup && chown -R penpot:penpot /opt/penpot/media-processor
USER penpot:penpot
RUN ./setup
CMD ["node", "dist/index.js"]

View File

@ -1,4 +1,4 @@
FROM dhi.io/nginx:1.31.1-alpine3.22
FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9
LABEL maintainer="Penpot <docker@penpot.app>"
ARG BUNDLE_PATH="./bundle-storybook/"

View File

@ -26,6 +26,9 @@
x-flags: &penpot-flags
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
## Public URI where Penpot is reached. To serve Penpot from a subpath, include
## it with a trailing slash, e.g. https://example.com/penpot/. A reverse proxy in
## front must forward the subpath unchanged, without rewriting it.
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001
@ -38,7 +41,7 @@ x-body-size: &penpot-http-body-size
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
## (eg http sessions, or invitations) are derived.
##
## We recommend to use a trully randomly generated
## We recommend to use a truly randomly generated
## 512 bits base64 encoded string here. You can generate one with:
##
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
@ -58,6 +61,7 @@ networks:
volumes:
penpot_postgres_v15:
penpot_assets:
# penpot_mailpit_data:
# penpot_traefik:
services:
@ -109,6 +113,8 @@ services:
# ## internet with HTTPS using traefik.
# - "traefik.http.routers.penpot-https.rule=Host(`<DOMAIN_NAME>`)"
# ## When Penpot is served from a subpath, match it too:
# - "traefik.http.routers.penpot-https.rule=Host(`<DOMAIN_NAME>`) && PathPrefix(`/<SUBPATH>`)"
# - "traefik.http.routers.penpot-https.entrypoints=websecure"
# - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt"
# - "traefik.http.routers.penpot-https.tls=true"
@ -139,6 +145,9 @@ services:
networks:
- penpot
## Configuration environment variables for the backend container.
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database]
@ -169,13 +178,13 @@ services:
PENPOT_TELEMETRY_ENABLED: "true"
PENPOT_TELEMETRY_REFERER: compose
## Example SMTP/Email configuration. By default, emails are sent to the mailcatch
## Example SMTP/Email configuration. By default, emails are sent to the Mailpit
## service, but for production usage it is recommended to setup a real SMTP
## provider. Emails are used to confirm user registrations & invitations. Look below
## how the mailcatch service is configured.
## how the Mailpit service is configured.
PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com
PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com
PENPOT_SMTP_HOST: penpot-mailcatch
PENPOT_SMTP_HOST: penpot-mailpit
PENPOT_SMTP_PORT: 1025
PENPOT_SMTP_USERNAME:
PENPOT_SMTP_PASSWORD:
@ -224,9 +233,19 @@ services:
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
## Valkey (or previously Redis) is used for the websockets notifications.
## Valkey (or previously Redis) is used for the websockets notifications
## and for storing the state export jobs
PENPOT_REDIS_URI: redis://penpot-valkey/0
# PENPOT_EXPORTER_MAX_CONCURRENT_JOBS: 4
# PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE: 2
# PENPOT_EXPORTER_QUEUE_MAX: 64
# PENPOT_EXPORTER_JOB_TTL: 3600
# PENPOT_WASM_WORKER_POOL_MAX: 2
# PENPOT_WASM_WORKER_POOL_MIN: 1
# PENPOT_WASM_WORKER_IDLE_TIMEOUT: 300
# PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE: 134217728
penpot-postgres:
image: "postgres:15"
restart: always
@ -270,16 +289,22 @@ services:
# although this should not be necessary.
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the
## port 1080 for read all emails the penpot platform has sent. Should be only used as a
## temporal solution while no real SMTP provider is configured.
## A Mailpit service, used as temporal SMTP server. You can access via HTTP to the
## port 8025 to read all emails the penpot platform has sent. Should be only used as a
## temporal solution while no real SMTP provider is configured. Messages are kept in
## memory only and are lost when the container is recreated; uncomment the volume
## below (and the one in the top-level volumes: section) to persist them instead.
penpot-mailcatch:
image: sj26/mailcatcher:latest
penpot-mailpit:
image: axllent/mailpit:v1
restart: always
# volumes:
# - penpot_mailpit_data:/data
# environment:
# - MP_DATABASE=/data/mailpit.db
expose:
- '1025'
ports:
- "1080:1080"
- "8025:8025"
networks:
- penpot

View File

@ -1,7 +1,11 @@
# The Admin Console is served at the root of its own server. Keep proxy_pass
# without a URI: building it from $request_uri escapes the path twice.
location /admin-console {
rewrite "^/admin-console(?:/(.*))?$" /$1 break;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri;
proxy_pass $PENPOT_ADMIN_CONSOLE_URI;
}

View File

@ -51,6 +51,23 @@ update_oidc_name /var/www/app/js/config.js
## Nginx Config
#########################################
# Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/).
# Requests below it are rewritten to the root, so the proxy in front of this
# server forwards them unchanged.
PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")"
PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}"
export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/"
if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then
# shellcheck disable=SC2016 # literal regex metacharacters
_public_path_regex="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")"
export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; }
rewrite ^${_public_path_regex}(.*)\$ /\$1 last;"
unset _public_path_regex
else
export PENPOT_PUBLIC_PATH_DIRECTIVES=""
fi
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
@ -58,7 +75,7 @@ export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]
if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then
export PENPOT_IPV6_LISTEN_DIRECTIVE=""
fi
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE,\$PENPOT_PUBLIC_PATH,\$PENPOT_PUBLIC_PATH_DIRECTIVES" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then
@ -84,4 +101,124 @@ export PENPOT_INTERNAL_RESOLVER=${PENPOT_INTERNAL_RESOLVER:-$PENPOT_DEFAULT_INTE
envsubst "\$PENPOT_INTERNAL_RESOLVER" \
< /tmp/resolvers.conf.template > /etc/nginx/overrides/http.d/resolvers.conf
#########################################
## Security Headers Config
#########################################
# The default policy describes what a stock Penpot deployment actually
# needs: 'wasm-unsafe-eval' for the render engine, 'unsafe-inline' styles
# for the inline style attributes emitted by the UI, and blob:/data: for
# thumbnails, exports and font handling. Everything else is same-origin,
# because the Google Fonts and GitHub templates endpoints are reverse
# proxied by this very server.
#
# The hashes of the inline scripts of index.html are emitted by the frontend
# build and moved to /etc/nginx at image build time. A bundle predating that
# change simply yields no hashes, in which case those scripts would be
# reported (or blocked under enforce) as before.
#
# It ships in report-only mode because deployments with plugins enabled still
# report eval and remote fetch violations from the SES sandbox. Enforcing mode
# stays opt-in until that is resolved.
export PENPOT_CSP_MODE=${PENPOT_CSP_MODE:-report-only}
if [ -r /etc/nginx/csp-script-hashes.txt ]; then
PENPOT_CSP_SCRIPT_HASHES=" $(tr -d '\n' < /etc/nginx/csp-script-hashes.txt)"
else
PENPOT_CSP_SCRIPT_HASHES=""
fi
# Remember whether the policy comes from the deployment before the default
# is applied, so the warning below only fires for the default one.
if [ -n "${PENPOT_CSP_POLICY:-}" ]; then
PENPOT_CSP_POLICY_IS_CUSTOM="true"
else
PENPOT_CSP_POLICY_IS_CUSTOM="false"
fi
# Directives a deployment may extend. The default policy carries hashes that
# change on every build, so a deployment that needs an extra origin cannot
# hardcode the whole policy without recomputing them at each release. These
# variables let it declare only what it adds.
#
# base-uri, form-action, object-src and frame-ancestors are deliberately not
# extensible: there is no legitimate reason to relax them, and doing so
# silently removes the protection they provide. A deployment that really
# needs it can still set PENPOT_CSP_POLICY and own the whole policy.
if [ "${PENPOT_CSP_POLICY_IS_CUSTOM}" = "false" ]; then
export PENPOT_CSP_POLICY="default-src 'self'\
; base-uri 'self'\
; object-src 'none'\
; frame-ancestors 'self'\
; form-action 'self'\
; manifest-src 'self'\
; script-src 'self' 'wasm-unsafe-eval'${PENPOT_CSP_SCRIPT_HASHES}${PENPOT_CSP_SCRIPT_SRC_EXTRA:+ ${PENPOT_CSP_SCRIPT_SRC_EXTRA}}\
; style-src 'self' 'unsafe-inline'${PENPOT_CSP_STYLE_SRC_EXTRA:+ ${PENPOT_CSP_STYLE_SRC_EXTRA}}\
; img-src 'self' data: blob:${PENPOT_CSP_IMG_SRC_EXTRA:+ ${PENPOT_CSP_IMG_SRC_EXTRA}}\
; font-src 'self'${PENPOT_CSP_FONT_SRC_EXTRA:+ ${PENPOT_CSP_FONT_SRC_EXTRA}}\
; connect-src 'self' blob: data:${PENPOT_CSP_CONNECT_SRC_EXTRA:+ ${PENPOT_CSP_CONNECT_SRC_EXTRA}}\
; frame-src 'self'${PENPOT_CSP_FRAME_SRC_EXTRA:+ ${PENPOT_CSP_FRAME_SRC_EXTRA}}\
; worker-src 'self' blob:\
; media-src 'self' blob:${PENPOT_CSP_REPORT_URI:+; report-uri ${PENPOT_CSP_REPORT_URI}}"
else
for _var in SCRIPT_SRC_EXTRA STYLE_SRC_EXTRA IMG_SRC_EXTRA FONT_SRC_EXTRA \
CONNECT_SRC_EXTRA FRAME_SRC_EXTRA REPORT_URI; do
eval "_value=\${PENPOT_CSP_${_var}:-}"
if [ -n "${_value}" ]; then
echo "penpot: WARNING: PENPOT_CSP_${_var} is ignored because PENPOT_CSP_POLICY defines the whole policy." >&2
fi
done
unset _var _value
export PENPOT_CSP_POLICY
fi
case "${PENPOT_CSP_MODE}" in
enforce)
export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy \"${PENPOT_CSP_POLICY}\" always;"
# The plugin runtime initialises on every page load, whether or not a
# plugin is opened, and its sandbox needs 'unsafe-eval'. Look at the
# policy that will actually be served rather than at where it came
# from, since it can be granted through the default policy, through
# PENPOT_CSP_SCRIPT_SRC_EXTRA or through a policy of your own.
case "${PENPOT_CSP_POLICY}" in
*"'unsafe-eval'"*) ;;
*)
echo "penpot: WARNING: PENPOT_CSP_MODE=enforce degrades the plugin runtime." >&2
echo "penpot: it initialises on every page load and needs 'unsafe-eval', which this policy does not grant." >&2
echo "penpot: add it through PENPOT_CSP_SCRIPT_SRC_EXTRA if you need plugins, or keep report-only." >&2
;;
esac
;;
report-only)
export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy-Report-Only \"${PENPOT_CSP_POLICY}\" always;"
;;
disabled)
export PENPOT_CSP_DIRECTIVE=""
;;
*)
echo "penpot: invalid PENPOT_CSP_MODE '${PENPOT_CSP_MODE}'; expected one of: enforce, report-only, disabled" >&2
exit 1
;;
esac
# HSTS is only meaningful when the deployment is served over HTTPS, so it
# defaults to enabled when PENPOT_PUBLIC_URI declares an https scheme and
# to disabled otherwise. Set PENPOT_HSTS_VALUE explicitly to override it,
# for example to add includeSubDomains or preload, or to an empty value
# to disable it on an https deployment.
if [[ "${PENPOT_PUBLIC_URI:-}" == https://* ]]; then
export PENPOT_HSTS_VALUE=${PENPOT_HSTS_VALUE-"max-age=31536000"}
else
export PENPOT_HSTS_VALUE=${PENPOT_HSTS_VALUE-""}
fi
if [ -n "${PENPOT_HSTS_VALUE}" ]; then
export PENPOT_HSTS_DIRECTIVE="add_header Strict-Transport-Security \"${PENPOT_HSTS_VALUE}\" always;"
else
export PENPOT_HSTS_DIRECTIVE=""
fi
envsubst "\$PENPOT_CSP_DIRECTIVE,\$PENPOT_HSTS_DIRECTIVE" \
< /tmp/nginx-security-headers.conf.template > /etc/nginx/nginx-security-headers.conf
exec "$@";

View File

@ -7,6 +7,14 @@ location ~ ^/github/penpot-files/(.+)$ {
proxy_set_header User-Agent "curl/8.5.0";
proxy_set_header Host "raw.githubusercontent.com";
proxy_set_header Accept "*/*";
# A third-party upstream must not dictate the security policy of our own
# origin: browsers enforce the intersection of every Content-Security-Policy
# they receive, and any Strict-Transport-Security lands on this host.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
include /etc/nginx/nginx-security-headers.conf;
add_header Access-Control-Allow-Origin $http_origin;
proxy_buffering off;
}
@ -31,6 +39,14 @@ location ~ ^/internal/gfonts/font/(?<font_file>.+) {
proxy_cache penpot;
# A third-party upstream must not dictate the security policy of our own
# origin: browsers enforce the intersection of every Content-Security-Policy
# they receive, and any Strict-Transport-Security lands on this host.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
include /etc/nginx/nginx-security-headers.conf;
add_header Access-Control-Allow-Origin $http_origin;
add_header Cache-Control max-age=86400;
add_header X-Cache-Status $upstream_cache_status;
@ -53,6 +69,14 @@ location ~ ^/internal/gfonts/css {
proxy_cache penpot;
# A third-party upstream must not dictate the security policy of our own
# origin: browsers enforce the intersection of every Content-Security-Policy
# they receive, and any Strict-Transport-Security lands on this host.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
include /etc/nginx/nginx-security-headers.conf;
add_header Access-Control-Allow-Origin $http_origin;
add_header Cache-Control max-age=86400;
add_header X-Cache-Status $upstream_cache_status;

View File

@ -11,9 +11,3 @@ location /mcp/stream {
proxy_pass $mcp_stream_backend;
proxy_http_version 1.1;
}
location /mcp/sse {
set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args;
proxy_pass $mcp_sse_backend;
proxy_http_version 1.1;
}

View File

@ -0,0 +1,6 @@
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options SAMEORIGIN always;
${PENPOT_CSP_DIRECTIVE}
${PENPOT_HSTS_DIRECTIVE}

View File

@ -67,6 +67,13 @@ http {
'' close;
}
# Link preview crawlers; their requests for the application root
# are served with dynamic Open Graph metadata from the backend.
map $http_user_agent $penpot_link_preview_agent {
default 0;
"~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky|applebot|googlebot|bingbot|bingpreview|duckduckbot|mattermost-bot|opengraph[.]xyz|twitter card validator)" 1;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
@ -95,6 +102,8 @@ http {
root /var/www/app/;
${PENPOT_PUBLIC_PATH_DIRECTIVES}
location @handle_redirect {
set $redirect_uri "$upstream_http_location";
set $redirect_host "$upstream_http_x_host";
@ -137,6 +146,10 @@ http {
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
}
location = /link-preview {
proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args;
}
location /api/export {
proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_EXPORTER_URI;
@ -156,7 +169,7 @@ http {
location /readyz {
access_log off;
proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_BACKEND_URI$request_uri;
proxy_pass $PENPOT_BACKEND_URI$uri$is_args$args;
}
location /ws/notifications {
@ -187,7 +200,11 @@ http {
}
location ~ ^/[^/]+/(.*)$ {
return 301 " /404";
return 301 " ${PENPOT_PUBLIC_PATH}404";
}
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
include /etc/nginx/nginx-security-headers.conf;