diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..2f3c88cb33 --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..166202b6e3 --- /dev/null +++ b/.github/actionlint.yaml @@ -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' diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 491f422d1f..65dcc51875 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -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: | diff --git a/.github/workflows/build-adhoc.yml b/.github/workflows/build-adhoc.yml index abfe01d5a1..1dcb83bbb5 100644 --- a/.github/workflows/build-adhoc.yml +++ b/.github/workflows/build-adhoc.yml @@ -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 }} diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e7f1f3a815..88c04a2634 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -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" diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 8f12163965..e808f38384 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -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 }} diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml index dbc28387b6..3d1ef04008 100644 --- a/.github/workflows/build-docker-admin-console.yml +++ b/.github/workflows/build-docker-admin-console.yml @@ -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" diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 2eab6be51e..a1c99e2bdd 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -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) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index d24b09436e..a8d6a1976d 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -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 ───────────── diff --git a/.github/workflows/build-renovate.yml b/.github/workflows/build-renovate.yml new file mode 100644 index 0000000000..6156aed086 --- /dev/null +++ b/.github/workflows/build-renovate.yml @@ -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 diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 249ba39e40..2b8d7a8e4c 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -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 }} diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index ed79d398f9..c2fd48671d 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -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 }} diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml index 838a97a617..28802b1ccc 100644 --- a/.github/workflows/build-tmp-tokens.yml +++ b/.github/workflows/build-tmp-tokens.yml @@ -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" diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 14c48a5850..15790e72a9 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -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 }} diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 0000000000..a9ad7d055c --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -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 diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 4657740a58..f513ba6af9 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -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 }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index cb6cbc704c..c9ff731682 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -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)' 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 }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 943e4b790d..40b71ede21 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -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 diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 53a379b854..65ed5c80f3 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d54b33ec03..201150b826 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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() diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index 02f839c062..40b99ff3c5 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -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 diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml index e513efc54f..f8168dbade 100644 --- a/.github/workflows/tests-common.yml +++ b/.github/workflows/tests-common.yml @@ -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 diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index a70008b4e4..167f00ddf8 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -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 diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index 0c8317499c..ed0da17b28 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -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 diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index f08f34054d..9a6ff9ae4b 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -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 diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml index 84ab5c9dc9..76dbc0d079 100644 --- a/.github/workflows/tests-library.yml +++ b/.github/workflows/tests-library.yml @@ -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 diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index f51c56b8a3..8ccf4a083a 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -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 diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml index 9f6c461da2..fff9c8002e 100644 --- a/.github/workflows/tests-plugins.yml +++ b/.github/workflows/tests-plugins.yml @@ -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 diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index 9f26175554..eac2d7ebf3 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -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 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000..8f6e7f112d --- /dev/null +++ b/.github/zizmor.yml @@ -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 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index f28a277b07..81fae7a791 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -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 diff --git a/docker/devenv/docker-compose.infra.yml b/docker/devenv/docker-compose.infra.yml index 6a876b36ad..a5c9a7d5b8 100644 --- a/docker/devenv/docker-compose.infra.yml +++ b/docker/devenv/docker-compose.infra.yml @@ -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" diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index e593ad1ba7..219e348a56 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -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: diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 5097ab010e..5fb66c9613 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -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 diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index d7a00a670a..193005d3fe 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -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" diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 46246e5153..5a0a81f091 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -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; diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index 92ff766acb..f875c09a95 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -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 " 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 " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index c5b16e9756..32e8c2c1fa 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -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 " 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 " 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 diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index a186917a97..97daca2dfe 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -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 " 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}"; \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index a134f15f27..26f84d4640 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -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 " 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; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 4d62675bec..f018302d33 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -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 " 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 " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index a147b9d8b3..ee0694bc34 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -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 " 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"] diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 3904604a25..2b259ffb61 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -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 " ARG BUNDLE_PATH="./bundle-storybook/" diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index df846c43b4..4c69760e24 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -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(``)" + # ## When Penpot is served from a subpath, match it too: + # - "traefik.http.routers.penpot-https.rule=Host(``) && PathPrefix(`/`)" # - "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 diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index fead3783c9..a2a90913b0 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -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; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 47a8a5544b..d60a5d691f 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -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 "$@"; diff --git a/docker/images/files/nginx-external-locations.conf b/docker/images/files/nginx-external-locations.conf index c9c106864b..64f4dbe503 100644 --- a/docker/images/files/nginx-external-locations.conf +++ b/docker/images/files/nginx-external-locations.conf @@ -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/(?.+) { 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; diff --git a/docker/images/files/nginx-mcp-locations.conf.template b/docker/images/files/nginx-mcp-locations.conf.template index 6ff6fda592..6538585927 100644 --- a/docker/images/files/nginx-mcp-locations.conf.template +++ b/docker/images/files/nginx-mcp-locations.conf.template @@ -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; -} diff --git a/docker/images/files/nginx-security-headers.conf.template b/docker/images/files/nginx-security-headers.conf.template new file mode 100644 index 0000000000..be41beda3e --- /dev/null +++ b/docker/images/files/nginx-security-headers.conf.template @@ -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} diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 8e3b21d7e1..c8800b0503 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -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;