From 3863e929936970420e7b5845841c172c99b6a435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Wed, 21 Oct 2026 10:35:40 +0200 Subject: [PATCH 1/8] :bug: Fix loss of swap slot when copying to clipboard (#12434) * :bug: Fix loss of swap slot when copying to clipboard * :wrench: Add test and make test helpers run same code as real functions * :wrench: Changes after review --- common/src/app/common/types/file.cljc | 25 ++- .../app/main/data/workspace/clipboard.cljs | 81 +++++----- .../test/frontend_tests/helpers/pages.cljs | 35 +---- .../logic/comp_remove_swap_slots_test.cljs | 147 ++++++++++++++++++ 4 files changed, 208 insertions(+), 80 deletions(-) diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 3a52dd79ca..df4733c25a 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -1259,23 +1259,22 @@ (defn get-ref-chain-until-target-ref "Returns a vector with the shape ref chain until target-ref, including itself" [container libraries shape target-ref] - (loop [chain [shape] - current shape] - (if (= current target-ref) + (loop [chain [shape] + current shape + container container] + (if (= (:id current) target-ref) chain (if-let [ref (find-ref-shape nil container libraries current :with-context? true)] - (recur (conj chain ref) ref) + (recur (conj chain ref) ref (:container (meta ref))) chain)))) (defn get-touched-from-ref-chain-until-target-ref "Returns a set with the :touched of all the items on the shape ref chain until target-ref, including itself" - [container libraries shape target-ref] - (let [chain (get-ref-chain-until-target-ref container libraries shape target-ref) - more-touched (->> chain - (map :touched) - (remove nil?) - (apply set/union) - (remove ctk/swap-slot?) - set)] - (set/union (or (:touched shape) #{}) more-touched))) + [container libraries shape target-ref & {:keys [remove-slots?] :or {remove-slots? true}}] + (let [chain (get-ref-chain-until-target-ref container libraries shape target-ref) + more-touched (cond->> chain + :always (map :touched) + :always (reduce set/union #{}) + remove-slots? (remove ctk/swap-slot?))] + (set/union (or (:touched shape) #{}) (set more-touched)))) \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 7145bd3ad5..d763e48e2e 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -67,6 +67,43 @@ [potok.v2.core :as ptk] [promesa.core :as p])) +(defn- advance-shape + [file libraries page level-delta head-id objects shape] + (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) + container (ctn/make-container page :page) + ;; The head becomes a root copy, which cannot carry a swap slot, so + ;; strip inherited slots only for it. Nested sub-heads keep them so + ;; they still pair with the children of the new near main. + head? (= (:id shape) head-id) + new-touched (ctf/get-touched-from-ref-chain-until-target-ref + container libraries shape new-shape-ref :remove-slots? head?)] + (cond-> objects + (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) + (update (:id shape) assoc :shape-ref new-shape-ref :touched new-touched)))) + +(defn- advance-copy + [file libraries page objects shape] + (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) + (let [level-delta (if (nil? (ctk/get-swap-slot shape)) + (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) + 0)] + (if (pos? level-delta) + (reduce (partial advance-shape file libraries page level-delta (:id shape)) + objects + (cfh/get-children-with-self objects (:id shape))) + objects)) + objects)) + +(defn advance-copies + "When copying an instance that is nested inside another one, we need to + advance the shape refs to one or more levels of remote mains." + [file libraries page selected data] + (let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] + (update data :objects + #(reduce (partial advance-copy file libraries page) + % + heads)))) + (defn copy-selected [] (letfn [(sort-selected [state data] @@ -148,39 +185,13 @@ (let [frame (get objects parent-frame-id)] (gsh/translate-to-frame shape frame)))) - ;; When copying an instance that is nested inside another one, we need to - ;; advance the shape refs to one or more levels of remote mains. - (advance-copies [state selected data] - (let [file (dsh/lookup-file state) - libraries (:files state) - ;; FIXME - page (dsh/lookup-page state) - heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] - (update data :objects - #(reduce (partial advance-copy file libraries page) - % - heads)))) - - (advance-copy [file libraries page objects shape] - (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) - (let [level-delta (if (nil? (ctk/get-swap-slot shape)) - (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) - 0)] - (if (pos? level-delta) - (reduce (partial advance-shape file libraries page level-delta) - objects - (cfh/get-children-with-self objects (:id shape))) - objects)) - objects)) - - (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) - container (ctn/make-container page :page) - new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)] - (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (-> (assoc-in [(:id shape) :shape-ref] new-shape-ref) - (assoc-in [(:id shape) :touched] new-touched))))) + (advance-copies-from-state [state selected data] + (advance-copies (dsh/lookup-file state) + (:files state) + ;; FIXME + (dsh/lookup-page state) + selected + data)) (on-copy-error [error] (js/console.error "clipboard blocked:" error) @@ -232,7 +243,7 @@ (rx/merge-map (partial prepare-object objects frame-id)) (rx/reduce (partial collect-data state) initial) (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) + (rx/map (partial advance-copies-from-state state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map #(wapi/create-blob % "text/plain")) (rx/subs! resolve reject))))] @@ -247,7 +258,7 @@ (rx/merge-map (partial prepare-object objects frame-id)) (rx/reduce (partial collect-data state) initial) (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) + (rx/map (partial advance-copies-from-state state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map clipboard/to-clipboard) (rx/catch on-copy-error) diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index 6ac59e6259..4a4b7784a0 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -14,13 +14,11 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] - [app.common.types.component :as ctk] - [app.common.types.container :as ctn] - [app.common.types.file :as ctf] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] + [app.main.data.workspace.clipboard :as dwc] [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.layout :as layout])) @@ -225,34 +223,7 @@ (if (= parent-frame-id uuid/zero) shape (let [frame (get objects parent-frame-id)] - (gsh/translate-to-frame shape frame)))) - - ;; When copying an instance that is nested inside another one, we need to - ;; advance the shape refs to one or more levels of remote mains. - (advance-copies [data] - (let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] - (update data :objects - #(reduce (partial advance-copy file libraries page) - % - heads)))) - - (advance-copy [file libraries page objects shape] - (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) - (let [level-delta (if (nil? (ctk/get-swap-slot shape)) - (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) - 0)] - (if (pos? level-delta) - (reduce (partial advance-shape file libraries page level-delta) - objects - (cfh/get-children-with-self objects (:id shape))) - objects)) - objects)) - - (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})] - (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (assoc-in [(:id shape) :shape-ref] new-shape-ref))))] + (gsh/translate-to-frame shape frame))))] (let [file-id (:id file) frame-id (cfh/common-parent-frame objects selected) @@ -273,4 +244,4 @@ (map (partial prepare-object objects frame-id)) (reduce collect-data initial) sort-selected - advance-copies)))) + (dwc/advance-copies file libraries page selected))))) diff --git a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs index 476d549c5d..f93bd40d3b 100644 --- a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs +++ b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs @@ -5,6 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.comp-remove-swap-slots-test (:require + [app.common.files.validate :as cfv] [app.common.test-helpers.components :as cthc] [app.common.test-helpers.compositions :as ctho] [app.common.test-helpers.files :as cthf] @@ -870,3 +871,149 @@ (t/is (some? copied-blue1')) (t/is (nil? (ctk/get-swap-slot copied-blue1'))))))))) + +(t/deftest test-keep-inherited-swap-slot-copy-paste-nested-copy-to-root + (t/async + done + (let [;; ==== Setup + ;; {:frame-red} [:name frame-red] # [Component :red] + ;; {:frame-blue} [:name frame-blue] # [Component :blue] + ;; {:frame-icon} [:name frame-icon] # [Component :icon] + ;; :red-copy-icon [:name frame-red] @--> frame-red + ;; {:frame-title} [:name frame-title] # [Component :title] + ;; :icon-copy-title [:name frame-icon] @--> frame-icon + ;; :blue-copy-title [:name frame-blue, :swap-slot-label :red-copy-icon] @--> frame-blue + ;; :title-copy [:name frame-title] #--> frame-title + ;; :icon-copy-in-title-copy [:name frame-icon] @--> :icon-copy-title + ;; :blue-copy-in-title-copy [:name frame-blue] @--> :blue-copy-title + file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame-red :name "frame-red") + (cthc/make-component :red :frame-red) + (ctho/add-frame :frame-blue :name "frame-blue") + (cthc/make-component :blue :frame-blue) + (ctho/add-frame :frame-icon :name "frame-icon") + (cthc/make-component :icon :frame-icon) + (cthc/instantiate-component :red :red-copy-icon :parent-label :frame-icon) + (ctho/add-frame :frame-title :name "frame-title") + (cthc/make-component :title :frame-title) + (cthc/instantiate-component :icon :icon-copy-title :parent-label :frame-title + :children-labels [:red-copy-in-icon-copy-title]) + (cthc/component-swap :red-copy-in-icon-copy-title :blue :blue-copy-title) + (cthc/instantiate-component :title :title-copy + :children-labels [:icon-copy-in-title-copy + :blue-copy-in-title-copy])) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + icon (cths/get-shape file :icon-copy-in-title-copy) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id icon)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + icon' (cths/get-shape file' :icon-copy-in-title-copy) + blue' (cths/get-shape file' :blue-copy-in-title-copy) + copied-icon' (find-copied-shape icon' page' uuid/zero) + copied-blue' (find-copied-shape blue' page' (:id copied-icon')) + errors (try + (cfv/validate-file! file' {(:id file') file'}) + nil + (catch :default cause + (ex-data cause)))] + + ;; ==== Check + ;; the copied icon now refers to the icon main, where blue + ;; does not exist, so the copied blue must keep the swap slot + ;; inherited from :blue-copy-title + (t/is (some? copied-icon')) + (t/is (= (:shape-ref copied-icon') (cthi/id :frame-icon))) + (t/is (some? copied-blue')) + (t/is (= (ctk/get-swap-slot copied-blue') (cthi/id :red-copy-icon))) + + ;; the file must be valid after the paste + (t/is (nil? errors)))))))) + +(t/deftest test-keep-touched-copy-paste-nested-copy-to-root + (t/async + done + (let [;; ==== Setup + ;; Add a nested copy inside a copy of :green, and override its fill + ;; so that the copy has a touched attribute. + file (-> (setup-file) + (cthc/instantiate-component :green :green-copy-2 + :parent-label :frame-b2 + :children-labels [:red-copy-in-green-copy-2]) + (cths/update-shape :red-copy-in-green-copy-2 + :fills (cths/sample-fills-color :fill-color "#ff0000"))) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + red (cths/get-shape file :red-copy-in-green-copy-2) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id red)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + red' (cths/get-shape file' :red-copy-in-green-copy-2) + copied-red' (find-copied-shape red' page' uuid/zero)] + + ;; ==== Check + ;; the touched fill override must be preserved in the pasted copy + (t/is (some? copied-red')) + (t/is (contains? (:touched copied-red') :fill-group)) + (t/is (= (:touched red') (:touched copied-red'))))))))) + +(t/deftest test-remove-swap-slot-copy-paste-nested-swapped-copy-to-root + (t/async + done + (let [;; ==== Setup (sample file as is) + file (setup-file) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + blue (cths/get-shape file :blue-copy-in-green-copy) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id blue)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + blue' (cths/get-shape file' :blue-copy-in-green-copy) + copied-blue' (find-copied-shape blue' page' uuid/zero)] + + ;; ==== Check + ;; the copied blue has no swap-id + (t/is (some? copied-blue')) + (t/is (nil? (ctk/get-swap-slot copied-blue'))))))))) From d8862c759b023ee7b94b1247b3747188d3643068 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 12:31:03 +0200 Subject: [PATCH 2/8] :bug: Fix shared files 500 on path content without coordinates (#12426) * :bug: Fix legacy path migrations on content without coordinates PRE still returned 500 on get-team-shared-files after #12357, now with "expected rect instance" in legacy-6. When a path has no points, legacy-6 rebuilds its rect from the content, but content whose points are not numbers (NaN or infinite coordinates, segments without params, only a close path) gives no rect either, and rect->points rejects nil. Fall back to the empty rect in legacy-6. A path with segments missing coordinates then failed later, in 0003-convert-path-content-v2, since the binary path data can not store them; drop those segments there. Relates to #12343 AI-assisted-by: claude-opus-5-5 * :bug: Skip shared files that fail to load in get-team-shared-files get-team-shared-files loads and migrates every shared file of the team, so one file whose migration fails made the whole request return 500 and the "Add library" modal could not open. Catch the error per file, log it with the file and team ids, and leave that file out of the list. The other libraries still show, and the error report names the broken file. Closes #12343 AI-assisted-by: claude-opus-5-5 --- backend/src/app/rpc/commands/files.clj | 16 ++++- backend/test/backend_tests/rpc_file_test.clj | 20 +++++++ common/src/app/common/files/migrations.cljc | 22 +++++-- .../common_tests/files_migrations_test.cljc | 58 +++++++++++++++++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 058141829d..5c17effec4 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -607,11 +607,21 @@ (let [process-row (fn [{:keys [id library-file-ids]}] - (let [file (get-file-with-summary cfg id)] - (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{})))) + ;; One shared file that fails to load (for example, a migration + ;; error on old data) must not break the whole list; skip it + ;; and report it with its id. + (try + (let [file (get-file-with-summary cfg id)] + (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{}))) + (catch Exception cause + (l/error :hint "unable to load shared file" + :file-id (str id) + :team-id (str team-id) + :cause cause) + nil))) xform - (map process-row)] + (keep process-row)] (->> (db/plan conn [sql:team-shared-files team-id] {:fetch-size 1}) (transduce xform conj #{})))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 34adcae63e..c885219fc2 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2907,3 +2907,23 @@ (get-in (meta (:result out)) [:app.loggers.audit/props :id]))) (t/is (= (:id team) (get-in (meta (:result out)) [:app.loggers.audit/props :team-id]))))) + +(t/deftest get-team-shared-files-skips-files-that-fail-to-load + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + get-file (deref #'files/get-file-with-summary) + broken (fn [cfg id] + (if (= id (:id file1)) + (throw (ex-info "broken file" {:type :assertion})) + (get-file cfg id))) + out (with-redefs-fn {#'files/get-file-with-summary broken} + #(th/command! {::th/type :get-team-shared-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile)}))] + (t/is (th/success? out)) + (t/is (= #{(:id file2)} (into #{} (map :id) (:result out)))))) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index d0312f5a56..de82f16f9e 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -230,7 +230,10 @@ ;; its geometry from the content as for line-like paths. (let [{:keys [width height] :as rect} (grc/points->rect (:points shape))] (if (or (nil? rect) (mth/almost-zero? width) (mth/almost-zero? height)) - (let [selrect (path.segment/content->selrect (:content shape)) + ;; Content whose points are not numbers gives no rect + ;; either; fall back to the empty rect. + (let [selrect (or (path.segment/content->selrect (:content shape)) + (grc/make-rect)) points (grc/rect->points selrect) transform (gmt/matrix) transform-inv (gmt/matrix)] @@ -1339,6 +1342,16 @@ (let [decode-segments (sm/decoder path/schema:segments sm/json-transformer) + ;; Old files may have segments with missing coordinates, which + ;; the binary path data can not store; drop them. + valid-segment? + (fn [{:keys [command params]}] + (case command + :close-path true + (:move-to :line-to) (and (number? (:x params)) (number? (:y params))) + :curve-to (every? #(number? (get params %)) [:x :y :c1x :c1y :c2x :c2y]) + false)) + update-object (fn [object] (if (or (cfh/bool-shape? object) @@ -1352,9 +1365,10 @@ (path/content []) :else - (-> content - (decode-segments) - (path/content)))] + (->> content + (decode-segments) + (filterv valid-segment?) + (path/content)))] (assoc object :content content)) object)) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 61ba0e7486..cb27492b6a 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.files.migrations :as cfm] [app.common.files.tokens :as cfo] + [app.common.geom.rect :as grc] [app.common.test-helpers.components :as thk] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -16,6 +17,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] [app.common.types.file :as ctf] + [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.token :as ctt] [app.common.uuid :as uuid] @@ -637,6 +639,62 @@ (t/is (= (:selrect shape) (:selrect shape')) "selrect rebuilt from content"))))) +(def ^:private contents-without-rect + "Path contents from old files whose points give no rect" + {"segments without params" [{:command :move-to} {:command :line-to}] + "only a close path" [{:command :close-path :params {}}] + "not finite coordinates" [{:command :move-to :params {:x ##NaN :y ##Inf}} + {:command :line-to :params {:x 10 :y 10}}]}) + +(t/deftest migration-legacy-6-repairs-paths-whose-content-has-no-rect + (let [migration-id "legacy-6" + page-id (uuid/next) + shape-id (uuid/next)] + + (doseq [[label content] contents-without-rect] + (let [shape (-> (make-path-shape shape-id) + (assoc :points [] :content content)) + data {:pages-index {page-id {:objects {shape-id shape}}}} + data' (cfm/migrate-data data migration-id) + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (grc/rect? (:selrect shape')) (str "selrect is a rect: " label)) + (t/is (= 4 (count (:points shape'))) (str "points rebuilt: " label)))))) + +(t/deftest migration-0003-convert-path-content-drops-segments-without-coordinates + (let [migration-id "0003-convert-path-content-v2" + page-id (uuid/next) + shape-id (uuid/next) + content [{:command :move-to :params {:x 10 :y 20}} + {:command :line-to} + {:command :curve-to :params {:x 50 :y 50}} + {:command :line-to :params {:x 110 :y 70}} + {:command :close-path :params {}}] + shape (assoc (make-path-shape shape-id) :content content) + data {:pages-index {page-id {:objects {shape-id shape}}}} + data' (cfm/migrate-data data migration-id) + content' (get-in data' [:pages-index page-id :objects shape-id :content])] + + (t/is (path/content? content')) + (t/is (= [:move-to :line-to :close-path] (mapv :command (vec content')))))) + +(t/deftest migration-legacy-6-chain-with-content-without-rect + ;; The file must load: every migration from legacy-6 on and the final + ;; data validation must succeed. + (doseq [[label content] contents-without-rect] + (let [shape-id (uuid/next) + file (ctf/make-file {:name "Legacy path without rect"}) + page-id (first (get-in file [:data :pages])) + shape (-> (make-path-shape shape-id) + (assoc :points [] :content content)) + file (-> file + (dissoc :migrations) + (assoc :version 5) + (assoc-in [:data :pages-index page-id :objects shape-id] shape)) + file' (cfm/migrate-file file (delay {}))] + + (t/is (contains? (:migrations file') "legacy-6") (str "migrated: " label))))) + (t/deftest migration-legacy-6-runs-through-file-migration ;; A file saved before version 6 runs every migration from legacy-6 on, ;; and the whole chain must succeed for the file to load. From 338d009c1eaeec6bbdd6c39015527805f961924c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 21 Oct 2026 15:12:41 +0200 Subject: [PATCH 3/8] :bug: Fix sorting for accented teams and organizations (#12505) * :bug: Fix sorting for accented teams and organizations * :paperclip: Code review --- .../dashboard/organization_team_switch.cljs | 18 ++++++++++++++--- .../ui/organization_team_switch_test.cljs | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs index 9d99ecb3c0..62144bfbf7 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs @@ -69,6 +69,18 @@ (nil? (dm/get-in team [:organization :id])) (contains? #{"unlimited" "enterprise"} (get-subscription-type (:subscription team))))) +(def ^:private combining-marks-re (js/RegExp. "\\p{M}" "gu")) + +(defn- name-sort-key + "Key that sorts `label` alphabetically regardless of case and + accents: NFD splits an accented letter into its base letter plus a + combining mark, and dropping the marks makes \"Ábaco\" sort like + \"abaco\" instead of after \"z\"." + [label] + (-> (.normalize label "NFD") + (str/replace combining-marks-re "") + (str/lower))) + (defn sort-organization-teams "Orders the teams of a single organization for the dropdown's second column: alphabetical, with the organization's default team @@ -76,7 +88,7 @@ `:is-default` and an explicit `false` into one comparator value." [teams] (sort-by (juxt (fn [team] (boolean (:is-default team))) - (fn [team] (str/lower (:name team "")))) + (fn [team] (name-sort-key (:name team "")))) teams)) (defn sort-all-teams @@ -89,7 +101,7 @@ (sort-all-teams teams team-display-name)) ([teams display-name] (sort-by (juxt (fn [team] (boolean (:is-default team))) - (comp str/lower display-name)) + (comp name-sort-key display-name)) teams))) (defn teams-for-organization @@ -113,7 +125,7 @@ stable." [organizations] (sort-by (juxt (fn [organization] (if (nil? (:id organization)) 1 0)) - (fn [organization] (str/lower (:name organization ""))) + (fn [organization] (name-sort-key (:name organization ""))) :id) organizations)) diff --git a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs index 6cf00fa228..6d10a260f4 100644 --- a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs +++ b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs @@ -46,6 +46,26 @@ (t/is (= ["org-a" "org-b" "org-c" nil] (map :id (dts/sort-organizations organizations)))))) +(t/deftest names-starting-with-an-accented-letter-sort-as-the-plain-letter + ;; A code-point comparison would push "Ábaco", "Órbita" and "Ámbar" + ;; after "z"; accented letters must sort like their base letter. + (let [organizations [{:id "org-1" :name "OrgA"} + nil + {:id "org-2" :name "Órbita"} + {:id "org-3" :name "beta"} + {:id "org-4" :name "Ábaco"}] + teams [{:id "t1" :name "zeta"} + {:id "t2" :name "Personal projects" :is-default true} + {:id "t3" :name "apricot"} + {:id "t4" :name "Ámbar"}] + display-name (fn [team] (if (:is-default team) "Personal projects" (:name team)))] + (t/is (= ["Ábaco" "beta" "Órbita" "OrgA" nil] + (map :name (dts/sort-organizations organizations)))) + (t/is (= ["Ámbar" "apricot" "zeta" "Personal projects"] + (map :name (dts/sort-organization-teams teams)))) + (t/is (= ["Ámbar" "apricot" "zeta" "Personal projects"] + (map :name (dts/sort-all-teams teams display-name)))))) + (t/deftest closed-control-line-2-matrix (t/testing "profile with organizations, destination has an organization" (t/is (= "Org A" From 05e1981accc41a52326815c8a418fc1db15c43fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 21 Oct 2026 15:30:46 +0200 Subject: [PATCH 4/8] :sparkles: Support running several exporter replicas (#12487) * :sparkles: Support running several exporter replicas * :books: Document one exporter per machine instead of compose replicas * :books: Trim exporter replica docs to the essentials * :sparkles: Cancel an exporter's jobs when it shuts down * :books: Replace exporter replica docs with a short beta note --- backend/resources/app/templates/debug.tmpl | 4 +- docs/technical-guide/configuration.md | 9 +++++ exporter/README.md | 1 + exporter/src/app/config.cljs | 8 ++++ exporter/src/app/core.cljs | 16 +++++++- exporter/src/app/jobs.cljs | 43 ++++++++++++++++----- exporter/src/app/jobs/scheduler.cljs | 1 + exporter/test/exporter_tests/jobs_test.cljs | 21 ++++++++++ 8 files changed, 90 insertions(+), 13 deletions(-) diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl index 64ddd9d150..1498edaf87 100644 --- a/backend/resources/app/templates/debug.tmpl +++ b/backend/resources/app/templates/debug.tmpl @@ -326,6 +326,7 @@ Debug Main Page PROGRESS CMD BACKEND + REPLICA NAME CREATED ENDED @@ -339,12 +340,13 @@ Debug Main Page {{job.done}} / {{job.total}} {{job.cmd}} {{job.backend}} + {{job.replica}} {{job.name}} {{job.created-at}} {{job.ended-at}} {% empty %} - No export jobs. + No export jobs. {% endfor %} diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index bde2d9fd3d..e99e8d5cd5 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -883,6 +883,15 @@ PENPOT_INTERNAL_URI: http://penpot-frontend:8080 `http://penpot-frontend:8080` used in the docker-compose is a good default and it is recommended to keep it unchanged. +**Beta, new render engine (WebGL):** when you run more than one exporter, give +each one its own name and keep it across restarts and upgrades. An exporter +uses it to cancel only its own unfinished exports when it restarts: + +```bash +# Exporter +PENPOT_EXPORTER_REPLICA_ID: exporter-1 +``` + ### MCP The MCP server lets AI agents read and edit Penpot files. It runs as a separate diff --git a/exporter/README.md b/exporter/README.md index df4d4412db..5e3986b90c 100644 --- a/exporter/README.md +++ b/exporter/README.md @@ -88,6 +88,7 @@ section, with a `?job-id=` filter. | `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` | `4` | Admission limit | | `PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE`| `2` | Per-profile admission limit | | `PENPOT_EXPORTER_QUEUE_MAX` | `64` | Queue cap; over it, `429 :queue-full` | +| `PENPOT_EXPORTER_REPLICA_ID` | hostname | Names this replica; boot cleanup only cancels its own jobs | | `PENPOT_WASM_WORKER_POOL_MAX` | `2` | Headless render worker threads; min 1 | | `PENPOT_WASM_WORKER_POOL_MIN` | `1` | Workers kept warm; clamped to the max | | `PENPOT_WASM_WORKER_IDLE_TIMEOUT` | `300` | Silence before a worker is terminated, in seconds | diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 0fcc5a052d..4f94887ce0 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -9,6 +9,7 @@ (:require ["node:buffer" :as buffer] ["node:crypto" :as crypto] + ["node:os" :as os] ["node:process" :as process] [app.common.data :as d] [app.common.flags :as flags] @@ -55,6 +56,7 @@ [:exporter-max-jobs-per-profile {:optional true} ::sm/int] [:exporter-queue-max {:optional true} ::sm/int] [:exporter-job-ttl {:optional true} ::sm/int] + [:exporter-replica-id {:optional true} :string] [:wasm-worker-pool-max {:optional true} ::sm/int] [:wasm-worker-pool-min {:optional true} ::sm/int] [:wasm-worker-idle-timeout {:optional true} ::sm/int] @@ -124,6 +126,12 @@ (or (c/get config :internal-uri) (c/get config :public-uri))) +(def replica-id + "Names this exporter among its siblings. It must survive a restart, since + the boot sweep uses it to find the jobs the previous run left behind." + (or (c/get config :exporter-replica-id) + (os/hostname))) + (def management-key (let [key (or (c/get config :exporter-shared-key) (let [secret-key (c/get config :secret-key) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index a1ddab7efa..7dcf3a5b71 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -33,6 +33,7 @@ (l/info :msg "initializing" :public-uri (str (cf/get :public-uri)) :internal-uri (str (cf/get-internal-uri)) + :replica cf/replica-id :version (:full cf/version)) (l/info :msg "headless wasm export available" :wasm-dir wasm/artifact-dir @@ -86,8 +87,19 @@ (fn [cause] (js/console.error cause))) +(defonce ^:private exiting? (atom false)) + +(defn- exit! + "Cancels this process's jobs before exiting, so their records and clients + do not keep waiting on work that dies with it. A second signal exits at once." + [] + (if (compare-and-set! exiting? false true) + (->> (shutdown-step "cancel-jobs" jobs/cancel-all!) + (p/fnly (fn [_ _] (proc/exit 0)))) + (proc/exit 0))) + ;; Signals are only delivered to the main thread, and `exit` in a worker would ;; take down that worker rather than the process. (when ^boolean wt/isMainThread - (.on proc/default "SIGTERM" (fn [] (proc/exit 0))) - (.on proc/default "SIGINT" (fn [] (proc/exit 0)))) + (.on proc/default "SIGTERM" exit!) + (.on proc/default "SIGINT" exit!)) diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs index e3497b2861..d555269a52 100644 --- a/exporter/src/app/jobs.cljs +++ b/exporter/src/app/jobs.cljs @@ -20,6 +20,7 @@ [app.common.logging :as l] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.jobs.store :as store] [app.redis :as redis] [promesa.core :as p])) @@ -81,6 +82,7 @@ [{:keys [profile-id cmd backend total name resource-id]} run-fn] (let [job {:id (uuid/next) :profile-id profile-id + :replica cf/replica-id :cmd cmd :backend backend :state "queued" @@ -165,7 +167,10 @@ overwrite either." [job data] (if-let [job (live job)] - (persist-and-publish! (merge job data)) + (let [job (merge job data)] + (l/info :hint "export job" :job-id (str (:id job)) :state (:state job) + :replica (:replica job)) + (persist-and-publish! job)) (p/resolved job))) (defn start! @@ -208,7 +213,7 @@ :error (ex-message cause)})) (defn- cancel-local! - [job-id] + [job-id & {:as data}] (let [k (str job-id) rt (get @registry k) job (:job rt)] @@ -222,7 +227,7 @@ ;; (`scheduler/drop-queued!`) releases the job, and `transition!` on a ;; released job is a no-op, so a queued job would keep claiming to be ;; queued in the store and never publish its `cancelled` update. - (let [result (transition! job {:state "cancelled" :ended-at (now-ms)})] + (let [result (transition! job (merge {:state "cancelled" :ended-at (now-ms)} data))] (doseq [f (:cancel-fns rt)] (try (f) @@ -245,22 +250,40 @@ (store/request-cancel! (:id job)) job))))))) +(defn cancel-all! + "Cancels every job this process holds, on shutdown. Nothing in flight survives + the process, and clients learn it now rather than when this replica boots + again, if it ever does under the same id." + [] + (let [job-ids (keys @registry)] + (when (seq job-ids) + (l/info :hint "cancelling jobs on shutdown" :count (count job-ids))) + (->> job-ids + (map #(cancel-local! % :interrupted true)) + (p/all)))) + +(defn- abandoned? + "A job this replica's previous run left mid-flight. A sibling's jobs are its + own business: they may well be running." + [job] + (and (= cf/replica-id (:replica job)) + (not (terminal? job)))) + (defn- clean-abandoned! - "Marks every job left mid-flight by a previous process as cancelled. + "Marks every job left mid-flight by a previous run of this replica as + cancelled. A queue and its running jobs live in the memory of the process that owns them, so nothing in flight when it died can be resumed; without this the - record would keep claiming to be running until its TTL expires. - - NOTE: the store cannot tell whose jobs are whose, so with more than one - exporter behind a load balancer this would also cancel a sibling's running - jobs. Single-instance deployments only." + record would keep claiming to be running until its TTL expires. A replica + that never comes back under the same id leaves its records to the TTL." [] (->> (store/fetch-all) (p/mcat (fn [jobs] - (let [abandoned (remove terminal? jobs)] + (let [abandoned (filter abandoned? jobs)] (when (seq abandoned) (l/warn :hint "cancelling jobs abandoned by a previous process" + :replica cf/replica-id :count (count abandoned))) (->> abandoned (map (fn [job] diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs index 320689d90d..a986019a57 100644 --- a/exporter/src/app/jobs/scheduler.cljs +++ b/exporter/src/app/jobs/scheduler.cljs @@ -8,6 +8,7 @@ "Admission control for export jobs. Limits concurrent jobs and rejects work rather than allowing an unbounded backlog. + Every limit is per replica: the queue lives in this process only. Queue order is FIFO, except jobs whose profile is already at its cap are skipped, as are headless jobs once every render worker is busy." (:require diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs index c8baa9b9fd..a92c1c1790 100644 --- a/exporter/test/exporter_tests/jobs_test.cljs +++ b/exporter/test/exporter_tests/jobs_test.cljs @@ -9,6 +9,7 @@ reported and swallowed, so only the in-process record is exercised." (:require [app.common.uuid :as uuid] + [app.config :as cf] [app.jobs :as jobs] [cljs.test :as t :include-macros true] [promesa.core :as p])) @@ -84,3 +85,23 @@ (t/is (= "ended" (:state ended))) (t/is (nil? (jobs/lookup (:id job)))) (done))))) + +(t/deftest boot-sweep-leaves-sibling-jobs-alone + (let [abandoned? #'jobs/abandoned?] + (t/is (abandoned? {:replica cf/replica-id :state "running"})) + (t/is (abandoned? {:replica cf/replica-id :state "queued"})) + (t/is (not (abandoned? {:replica cf/replica-id :state "ended"}))) + (t/is (not (abandoned? {:replica (str cf/replica-id "-sibling") :state "running"}))))) + +(t/deftest shutdown-cancels-all-its-jobs + (t/async done + (p/let [running (create!) + queued (create!) + _ (jobs/start! running) + _ (jobs/cancel-all!)] + (doseq [job [running queued]] + (let [current (jobs/lookup (:id job))] + (t/is (= "cancelled" (:state current))) + (t/is (true? (:interrupted current)))) + (jobs/release! (:id job))) + (done)))) From 452d751dff1d84d8e023472ac4ea29233e0dd6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 21 Oct 2026 16:04:12 +0200 Subject: [PATCH 5/8] :wrench: Fix dashboard thumbnails crashing on deployed environments (#12465) * :bug: Pass version tag to worker so it loads the current render wasm * :bug: Keep wasm thumbnail queue alive when one render fails * :bug: Fail wasm thumbnail request when canvas context cannot init --- frontend/src/app/main/worker.cljs | 3 ++- frontend/src/app/worker/impl.cljs | 3 +++ frontend/src/app/worker/thumbnails.cljs | 15 ++++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/worker.cljs b/frontend/src/app/main/worker.cljs index bbfec651c6..4daaeebc36 100644 --- a/frontend/src/app/main/worker.cljs +++ b/frontend/src/app/main/worker.cljs @@ -22,7 +22,8 @@ (uw/ask! worker {:cmd :configure :config {:public-uri cf/public-uri :build-data cf/build-date - :version cf/version}}) + :version cf/version + :version-tag cf/version-tag}}) (set! instance worker))) (defn ask! diff --git a/frontend/src/app/worker/impl.cljs b/frontend/src/app/worker/impl.cljs index 5461756ff5..c7f65dc1b7 100644 --- a/frontend/src/app/worker/impl.cljs +++ b/frontend/src/app/worker/impl.cljs @@ -36,6 +36,9 @@ (when-let [version (get config :version)] (set! cf/version version)) + (when-let [version-tag (get config :version-tag)] + (set! cf/version-tag version-tag)) + (when-let [build-date (get config :build-data)] (set! cf/build-date build-date)) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index e42a9687b4..48d35cb0c7 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -192,7 +192,7 @@ :file-id file-id :revn revn})) (rx/end! subs))))))) - (rx/end! subs)) + (rx/error! subs (ex-info "unable to init wasm canvas" {:file-id file-id}))) nil))))))) (defonce ^:private @@ -202,7 +202,10 @@ (defonce ^:private thumbnails-stream (->> thumbnails-queue - (rx/mapcat render-thumbnail-with-wasm) + (rx/mapcat (fn [{:keys [id] :as message}] + (->> (render-thumbnail-with-wasm message) + (rx/catch (fn [cause] + (rx/of {:id id :error cause})))))) (rx/share))) (defmethod impl/handler :thumbnails/generate-for-file-wasm @@ -213,8 +216,10 @@ sid (->> thumbnails-stream (rx/filter #(= id (:id %))) (rx/subs! - (fn [result] - (rx/push! subs result) - (rx/end! subs))))] + (fn [{:keys [error] :as result}] + (if error + (rx/error! subs error) + (do (rx/push! subs result) + (rx/end! subs))))))] (rx/push! thumbnails-queue (assoc message :id id)) #(rx/dispose! sid))))) From ce5276065ecf8e861d83527a3b7561d04b5c33ff Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 22 Oct 2026 12:24:12 +0200 Subject: [PATCH 6/8] :bug: Skip unknown entries and reserve keys in shortcut settings (#12473) * :bug: Skip unknown entries when importing shortcuts The shortcuts import rejected the whole file when it held a single entry it did not know, so a profile exported from an older Penpot version, or edited by hand, could not be imported at all. The spec in #9924 asks to skip unknown keys, tell the user which ones, and apply the rest. Importing now keeps the entries that map to a known shortcut with a valid key combination and skips the rest: unknown contexts, unknown shortcut keys, and commands that are not a key combination. The import modal lists the skipped entries. The file is only rejected when it is not a map or holds nothing that can be applied. A context whose entries are all skipped is left out: an empty context clears the user's shortcuts on import, so keeping it would wipe them. valid-command? reads a command the way mousetrap does, so it accepts every default shortcut and rejects strings mousetrap could never match. AI-assisted-by: claude-opus-5-5 * :paperclip: Sort requires in shortcuts settings test cljfmt expects the namespace requires in alphabetical order. AI-assisted-by: claude-opus-5-5 * :sparkles: Reserve cancel, copy and paste shortcuts Cancel, copy and paste are no longer customizable, in the workspace and in the path editor, which shares their shortcut keys. Their key combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the shortcut recorder warns and blocks saving one of them for another action, and the import skips entries that use them. Reserved combinations come from the non-customizable workspace shortcuts, so marking another one as non-customizable reserves its key too. The path editor's cancel also binds V, the move tool key, so path shortcuts are left out. Combinations compare the way mousetrap matches them: aliases and modifier order do not matter. apply-custom-overrides now ignores overrides of non-customizable shortcuts. An override saved before this change would otherwise still apply, with no way to reset it from the settings page. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/data/shortcuts.cljs | 5 +- .../main/data/workspace/path/shortcuts.cljs | 3 + .../app/main/data/workspace/shortcuts.cljs | 2 + .../settings/import_shortcuts_diff_modal.cljs | 14 +- .../settings/import_shortcuts_diff_modal.scss | 19 ++ .../src/app/main/ui/settings/shortcuts.cljs | 59 ++++- frontend/src/app/main/ui/shortcuts.cljs | 134 ++++++++++- .../ui/settings_shortcuts_test.cljs | 217 +++++++++++++++++- frontend/translations/en.po | 8 + frontend/translations/es.po | 8 + 10 files changed, 447 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 7181f5ebb3..9a045d4995 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -187,8 +187,11 @@ shortcuts (reduce-kv (fn [acc sc-key new-command] + ;; A non-customizable shortcut ignores overrides stored before it + ;; became so: the settings page no longer offers to reset them. (if (and (contains? acc sc-key) - (not (:disabled (get acc sc-key)))) + (not (:disabled (get acc sc-key))) + (not (false? (:customizable (get acc sc-key))))) (-> acc (assoc-in [sc-key :original-command] (get-in acc [sc-key :command])) (assoc-in [sc-key :command] new-command) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index bd924df5fe..21f1c87960 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -127,6 +127,7 @@ :label (fn [] (tr "shortcuts.copy")) :command (ds/c-mod "c") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/copy-selected-nodes))} :cut {:tooltip (ds/meta "X") @@ -139,6 +140,7 @@ :label (fn [] (tr "shortcuts.paste")) :command (ds/c-mod "v") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/paste-nodes))} :duplicate {:tooltip (ds/meta "D") @@ -176,6 +178,7 @@ :label (fn [] (tr "shortcuts.escape")) :command ["escape" "v"] :section [:workspace] + :customizable false :fn #(st/emit! (esc-pressed))} ;; Reuses the `:start-editing` key (instead of adding "enter" to diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index bdbd3cedaf..17246e5889 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -94,6 +94,7 @@ :command (ds/c-mod "c") :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! (dw/copy-selected))} :copy-link {:tooltip (ds/shift (ds/alt "C")) @@ -178,6 +179,7 @@ :command "escape" :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))} :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs index a7462d9380..43478a33f1 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs @@ -65,7 +65,7 @@ (mf/defc import-shortcuts-diff-modal {::mf/register modal/components ::mf/register-as :import-shortcuts-diff-modal} - [{:keys [imported-shortcuts custom-shortcuts all-shortcuts-raw]}] + [{:keys [imported-shortcuts skipped-entries custom-shortcuts all-shortcuts-raw]}] (let [diff-entries (mf/with-memo [imported-shortcuts custom-shortcuts] (->> (compute-diff imported-shortcuts custom-shortcuts) @@ -145,7 +145,17 @@ :is-customized true :has-conflict? false}])]]])))]] [:div {:class (stl/css :no-changes)} - (tr "import-shortcuts.no-changes")])] + (tr "import-shortcuts.no-changes")]) + + (when (seq skipped-entries) + [:div {:class (stl/css :skipped-entries)} + [:div {:class (stl/css :modal-content-text)} + (tr "import-shortcuts.skipped-entries")] + [:ul {:class (stl/css :skipped-entries-list)} + (for [entry skipped-entries] + [:li {:key entry + :class (stl/css :skipped-entry)} + entry])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss index e810578008..c28d5de804 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss @@ -165,3 +165,22 @@ text-align: center; padding-block: var(--sp-xl); } + +.skipped-entries { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.skipped-entries-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.skipped-entry { + @include t.use-typography("code-font"); + + color: var(--color-foreground-secondary); + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 2b246339b2..841841ac5b 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -121,6 +121,53 @@ [:dashboard {:optional true} (context-schema :dashboard)] [:viewer {:optional true} (context-schema :viewer)]])) +(defn- entry-path + [& segments] + (str/join "/" (map d/name segments))) + +(defn- sanitize-context + "Keeps the entries of one imported context that map to a known shortcut key + with a valid command; every other entry goes to :skipped." + [result ctx entries known-keys] + (let [viable (reduce-kv + (fn [viable k command] + (if (and (contains? known-keys k) + (ss/valid-command? command) + (not (ss/reserved-command? command))) + (assoc viable k command) + viable)) + {} + entries) + skipped (->> (keys entries) + (remove #(contains? viable %)) + (map #(entry-path ctx %)))] + (cond-> (update result :skipped into skipped) + ;; A context left empty by skipped entries is dropped: an empty context + ;; clears the user's shortcuts on import, an explicit {} still does. + (or (seq viable) (empty? entries)) + (assoc-in [:shortcuts ctx] viable)))) + +(defn sanitize-imported-shortcuts + "Splits an imported custom-shortcuts payload into the entries Penpot can + apply and the ones it must skip: unknown contexts, unknown or + non-customizable shortcut keys, commands that are not a key combination + (see `app.main.ui.shortcuts/valid-command?`) and reserved combinations + (see `app.main.ui.shortcuts/reserved-command?`). + + Returns {:shortcuts :skipped [\"context/key\" ...]}, or + nil when the payload is not a map." + [data] + (when (map? data) + (-> (reduce-kv + (fn [result ctx entries] + (let [known-keys (get known-shortcut-keys ctx)] + (if (and (some? known-keys) (map? entries)) + (sanitize-context result ctx entries known-keys) + (update result :skipped conj (entry-path ctx))))) + {:shortcuts {} :skipped []} + data) + (update :skipped (comp vec sort))))) + (defn- flatten-humanize "Turns Malli's humanized explain output into a flat list of {:path :message} maps." ([human] (flatten-humanize [] human)) @@ -450,12 +497,16 @@ (rx/subs! (fn [content] (try - (let [shortcuts (js->clj (.parse js/JSON content) - :keywordize-keys true) - validation (validate-imported-shortcuts shortcuts)] - (if (:valid? validation) + (let [data (js->clj (.parse js/JSON content) + :keywordize-keys true) + {:keys [shortcuts skipped]} (sanitize-imported-shortcuts data)] + ;; Rejects the file only when nothing in it can be applied. + (if (and (some? shortcuts) + (or (seq shortcuts) (empty? skipped)) + (:valid? (validate-imported-shortcuts shortcuts))) (st/emit! (modal/show {:type :import-shortcuts-diff-modal :imported-shortcuts shortcuts + :skipped-entries skipped :custom-shortcuts custom-shortcuts :all-shortcuts-raw all-shortcuts-raw})) (st/emit! (ntf/error (tr "import-shortcuts.invalid-file"))))) diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 1520f3258b..30aed17a2c 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -68,6 +68,122 @@ "Meta" "meta" " " "space"}) +;; Mirrors how mousetrap reads a bind string (`_keysFromString` and +;; `_getKeyInfo` in @penpot/mousetrap), so a command is valid exactly when +;; mousetrap can match it. + +(def ^:private command-aliases + {"ctrl" "ctrl" + "control" "ctrl" + "alt" "alt" + "option" "alt" + "shift" "shift" + "meta" "meta" + "command" "meta" + "return" "enter" + "enter" "enter" + "esc" "escape" + "escape" "escape" + "del" "del" + "delete" "del" + "ins" "ins" + "insert" "ins" + "space" "space" + "plus" "+"}) + +(def ^:private command-modifiers + #{"ctrl" "alt" "shift" "meta"}) + +(def ^:private command-named-keys + (into #{} (remove command-modifiers) (vals key-name-map))) + +(defn- canonical-command-key + [token] + (cond + ;; `mod` follows the platform, like `ds/c-mod`. + (= "mod" token) + (if (cf/check-platform? :macos) "meta" "ctrl") + + (contains? command-aliases token) + (get command-aliases token) + + (= 1 (count token)) + (str/lower token) + + :else + (str/lower (get key-name-map token token)))) + +(defn- command-step-keys + "Splits one step of a command (\"ctrl+shift+z\") into canonical keys. + A lone \"+\" is the plus key, and \"++\" ends a step with it." + [step] + (if (= "+" step) + ["+"] + (->> (.split (str/replace step #"\+{2}" "+plus") "+") + (mapv canonical-command-key)))) + +(defn- final-command-key? + [k] + (or (= 1 (count k)) + (contains? command-named-keys k) + ;; Two-digit alternatives first: cljs `re-matches` does not + ;; backtrack into a longer alternative. + (some? (re-matches #"f(?:1[0-9]|2[0-4]|[1-9])" k)) + (some? (re-matches #"num[0-9]" k)))) + +(defn- valid-command-step? + "A lone modifier is a valid step: `:show-measure` binds a bare `alt`." + [step-keys] + (let [final-key (peek step-keys)] + (if (contains? command-modifiers final-key) + (= 1 (count step-keys)) + (and (every? command-modifiers (pop step-keys)) + (final-command-key? final-key))))) + +(defn valid-command? + "True when `command` is a key combination mousetrap can match: steps + separated by single spaces, each a lone modifier or modifiers joined + with \"+\" ending with a single character, a named key (`escape`, `up`, + ...), a function key (`f1`..`f24`) or a numpad digit (`num0`..`num9`). + The empty string is valid: it disables the shortcut." + [command] + (and (string? command) + (or (= "" command) + (every? (comp valid-command-step? command-step-keys) + (.split command " "))))) + +(defn- canonical-command-step + "One step in the form mousetrap matches on: canonical keys with the + modifiers sorted, so `Shift+Ctrl+Z` and `ctrl+shift+z` compare equal." + [step] + (let [step-keys (command-step-keys step) + final-key (peek step-keys)] + (str/join "+" (conj (vec (sort (pop step-keys))) final-key)))) + +(defn- reserved-command-steps + "Key combinations of the non-customizable workspace shortcuts (cancel, + copy, paste): assigning them to another action would bind one key to two + actions. The path editor's cancel also binds `v`, the move tool key, + so path shortcuts are left out." + [] + (into #{} + (comp (filter #(false? (:customizable (val %)))) + (mapcat #(let [command (:command (val %))] + (if (vector? command) command [command]))) + (mapcat #(.split % " ")) + (map canonical-command-step)) + wsc/shortcuts)) + +(defn reserved-command? + "True when any step of `command` is a combination Penpot keeps for a + non-customizable shortcut." + [command] + (and (string? command) + (not= "" command) + (let [reserved (reserved-command-steps)] + (some? (some #(contains? reserved (canonical-command-step %)) + (.split command " ")))))) + (defn- event->native "Unwraps a goog.events.BrowserEvent to the native event, since the wrapper copies `key` but not `code`." @@ -601,12 +717,16 @@ effective-section-key)) (clean-editing-state))) + reserved? (and (some? recorded-command) + (not reset-pending) + (reserved-command? recorded-command)) + save-fn (mf/use-fn - (mf/deps recorded-command conflict command effective-section-key) + (mf/deps recorded-command reserved? conflict command effective-section-key) (fn [event] (dom/prevent-default event) - (when recorded-command + (when (and recorded-command (not reserved?)) (if (deref reset-pending*) (st/emit! (customize/reset-custom-shortcut command recorded-command effective-section-key)) (st/emit! (customize/set-custom-shortcut @@ -710,9 +830,16 @@ :has-conflict? false}]]) (when (and recorded-command (not reset-pending)) - (if conflict + (cond + reserved? + [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} + (tr "shortcuts.edit-modal.reserved")] + + conflict [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} (tr "shortcuts.edit-modal.conflict" (:name conflict))] + + :else [:> context-notification* {:level :success :class (stl/css :modal-success-msg)} (tr "shortcuts.edit-modal.success")])) [:div {:class (stl/css :edit-buttons)} @@ -725,6 +852,7 @@ [:> icon-button* {:variant "primary" :aria-label (tr "labels.save") :on-click save-fn + :disabled reserved? :icon i/tick :icon-size "m"}]] [:div {:class (stl/css :confirmation-buttons)} diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index fd82bfe17d..3ed7e035c9 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,8 +1,12 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require [app.config :as cf] + [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] + [app.main.data.viewer.shortcuts :as vsc] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.shortcuts :as wsc] [app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal] [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] @@ -14,21 +18,21 @@ (t/deftest validate-imported-shortcuts-accepts-valid-workspace (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape" + {:workspace {:draw-rect "shift+r" :increase-zoom "+" :zoom-lense-decrease "alt+z"}})))) (t/deftest validate-imported-shortcuts-accepts-optional-contexts (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape"} + {:workspace {:draw-rect "shift+r"} :dashboard {:toggle-theme "alt+m"} :viewer {:next-frame "right"}})))) (t/deftest validate-imported-shortcuts-accepts-empty-string-command (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape ""}})))) + {:workspace {:draw-rect ""}})))) (t/deftest validate-imported-shortcuts-rejects-nil (let [result (sut/validate-imported-shortcuts nil)] @@ -52,19 +56,19 @@ (t/is (some #(= "workspace" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-non-string-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape 123}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect 123}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-vector-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape ["ctrl+e"]}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect ["ctrl+e"]}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-unknown-top-level-key - (let [result (sut/validate-imported-shortcuts {:workspace {:escape "escape"} + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect "shift+r"} :unknown-context {}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) @@ -87,6 +91,195 @@ (t/is (= false (:valid? (sut/validate-imported-shortcuts #{:workspace})))) (t/is (= false (:valid? (sut/validate-imported-shortcuts (fn [])))))) +(t/deftest sanitize-imported-shortcuts-skips-unknown-action-keys + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["workspace/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-unknown-contexts + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["unknown-context"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r"} + :unknown-context {:draw-rect "shift+r"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-string-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect 123 + :increase-zoom ["+"] + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-invalid-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "foo+bar" + :increase-zoom "ctrl+" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest cancel-copy-and-paste-are-not-customizable + (t/testing "in the workspace and in the path editor, which share the + shortcut keys of the workspace context" + (doseq [shortcuts [wsc/shortcuts psc/shortcuts] + k [:escape :copy :paste]] + (t/is (false? (get-in shortcuts [k :customizable])) + (str k))))) + +(t/deftest sanitize-imported-shortcuts-skips-non-customizable-keys + (t/testing "cancel, copy and paste cannot be imported, even with a free + key combination" + (t/is (= {:shortcuts {} + :skipped ["workspace/copy" "workspace/escape" "workspace/paste"]} + (sut/sanitize-imported-shortcuts + {:workspace {:escape "shift+escape" + :copy "ctrl+shift+1" + :paste "ctrl+shift+2"}}))))) + +(t/deftest apply-custom-overrides-skips-non-customizable-shortcuts + (t/testing "an override stored before a shortcut became non-customizable + is ignored, since the settings page can no longer reset it" + (let [shortcuts {:escape {:command "escape" :customizable false} + :undo {:command "ctrl+z"}} + result (ds/apply-custom-overrides shortcuts + {:workspace {:escape "shift+escape" + :undo "ctrl+u"}} + :workspace)] + (t/is (= "escape" (get-in result [:escape :command]))) + (t/is (= "ctrl+u" (get-in result [:undo :command])))))) + +(t/deftest sanitize-imported-shortcuts-skips-reserved-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "escape" + :increase-zoom "ctrl+v" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-copy-combination + (t/is (= {:shortcuts {:workspace {:draw-rect "ctrl+alt+c"}} + :skipped ["workspace/draw-ellipse"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-ellipse "ctrl+c" + :draw-rect "ctrl+alt+c"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-map-context + (t/is (= {:shortcuts {:viewer {:next-frame "right"}} + :skipped ["workspace"]} + (sut/sanitize-imported-shortcuts + {:workspace "not-a-map" + :viewer {:next-frame "right"}})))) + +(t/deftest sanitize-imported-shortcuts-drops-context-without-viable-entries + (t/testing "a context whose entries are all skipped is left out, so the + import does not clear the user's shortcuts in that context" + (t/is (= {:shortcuts {} + :skipped ["viewer/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:viewer {:not-a-real-shortcut "x"}}))))) + +(t/deftest sanitize-imported-shortcuts-keeps-empty-context + (t/is (= {:shortcuts {:workspace {}} + :skipped []} + (sut/sanitize-imported-shortcuts {:workspace {}})))) + +(t/deftest sanitize-imported-shortcuts-returns-nil-for-non-maps + (t/is (nil? (sut/sanitize-imported-shortcuts nil))) + (t/is (nil? (sut/sanitize-imported-shortcuts "shortcuts"))) + (t/is (nil? (sut/sanitize-imported-shortcuts 42))) + (t/is (nil? (sut/sanitize-imported-shortcuts [])))) + +(t/deftest sanitize-imported-shortcuts-output-is-valid + (let [{:keys [shortcuts]} (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x" + :increase-zoom 1} + :dashboard "not-a-map" + :viewer {:next-frame "right"} + :unknown-context {}})] + (t/is (= {:valid? true} + (sut/validate-imported-shortcuts shortcuts))))) + +(t/deftest valid-command-accepts-key-combinations + (t/are [command] (true? (ui-shortcuts/valid-command? command)) + "" + "a" + "ctrl+shift+k" + "Ctrl+s" + "command+alt+ñ" + "+" + "shift++" + "ctrl+plus" + "shift+\"" + "g v" + "escape" + "Escape" + "f1" + "f12" + "f24" + "num5" + "alt")) + +(t/deftest valid-command-rejects-other-strings + (t/are [command] (false? (ui-shortcuts/valid-command? command)) + "foo" + "foo+bar" + "a+b" + "ctrl+foo" + "ctrl+" + "ctrl+shift" + "++" + "f25" + " " + " a" + "a " + "a b" + "🙂" + nil + 123)) + +(t/deftest reserved-command-matches-non-customizable-combinations + (t/testing "cancel, copy and paste keep their keys; spelling and modifier + order do not matter, and a reserved step taints a sequence" + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (true? (ui-shortcuts/reserved-command? command)) + "escape" + "Escape" + "esc" + "ctrl+c" + "Control+C" + "mod+c" + "ctrl+v" + "Control+V" + "mod+v" + "g escape" + "ctrl+c g")))) + +(t/deftest reserved-command-ignores-other-combinations + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (false? (ui-shortcuts/reserved-command? command)) + "" + "v" + "c" + "shift+escape" + "ctrl+shift+v" + "ctrl+shift+c" + "ctrl+alt+c" + "shift+alt+c" + nil))) + +(t/deftest valid-command-accepts-every-default-command + (t/testing "a profile exported by Penpot always holds valid commands" + (doseq [shortcuts [psc/shortcuts wsc/shortcuts dsc/shortcuts vsc/shortcuts] + [shortcut-key {:keys [command]}] shortcuts + command (if (vector? command) command [command]) + :when (some? command)] + (t/is (ui-shortcuts/valid-command? command) + (str shortcut-key " -> " (pr-str command)))))) + (t/deftest import-custom-shortcuts-updates-multiple-contexts (let [imported {:workspace {:add-comment "alt+c"} :viewer {:select-all "ctrl+alt+a"}} @@ -173,17 +366,17 @@ (t/is (= "ctrl+shift+a" (:imported select-all-entry)))))) (t/deftest compute-diff-shows-empty-string-as-disabled-shortcut - (let [imported {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} current {} diff (diff-modal/compute-diff imported current)] (t/is (pos? (count diff))) - (let [entry (first (filter #(= :escape (:key %)) diff))] + (let [entry (first (filter #(= :draw-rect (:key %)) diff))] (t/is (= :workspace (:context entry))) (t/is (= "" (:imported entry)))))) (t/deftest compute-diff-excludes-unchanged-shortcuts - (let [imported {:workspace {:escape ""}} - current {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} + current {:workspace {:draw-rect ""}} diff (diff-modal/compute-diff imported current)] (t/is (empty? diff)))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 014f5b622b..c1699b9564 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2181,6 +2181,10 @@ msgstr "This file is not a valid shortcuts export." msgid "import-shortcuts.no-changes" msgstr "No differences found between the current and imported shortcuts." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "These entries can't be imported: they don't match any Penpot shortcut or use a reserved key combination." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -4937,6 +4941,10 @@ msgstr "Edit %s" msgid "shortcuts.edit-modal.conflict" msgstr "Already assigned to \"%s\". Saving will move it to this action." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "This combination is reserved by Penpot and can't be assigned." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Saving will reset this shortcut to default:" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6eaf06da2a..b579adfd77 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2207,6 +2207,10 @@ msgstr "Este archivo no es una exportación de atajos válida." msgid "import-shortcuts.no-changes" msgstr "No se encontraron diferencias entre los atajos actuales y los importados." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "Estas entradas no se pueden importar: no corresponden a ningún atajo de Penpot o usan una combinación reservada." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -4991,6 +4995,10 @@ msgstr "Editar %s" msgid "shortcuts.edit-modal.conflict" msgstr "Ya asignado a \"%s\". Al guardar, pasará a esta acción." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "Penpot reserva esta combinación y no se puede asignar." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Al guardar, este atajo volverá a su valor por defecto:" From 6508449fa0e0731ba3f395afd524d25a663556b3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Oct 2026 09:55:23 +0200 Subject: [PATCH 7/8] :bug: Rebuild text layout when image fills finish loading (#12564) Text image shaders are baked into Skia paragraphs at layout time. touch_shapes_with_image only scanned shape.fills/strokes, so text spans were never invalidated after store_image. Detect text content image fills and force a layout rebuild before touching tiles. Closes #12563 --- render-wasm/src/wasm/fills/image.rs | 63 +++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index e6b2e634c5..72befd5e55 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,30 +1,67 @@ use crate::error::{Error, Result}; use crate::get_resources; use crate::mem; -use crate::shapes::Fill; +use crate::shapes::{Fill, Shape, TextContent, Type}; use crate::state::State; use crate::uuid::Uuid; use crate::with_state; use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet}; use macros::wasm_error; +fn text_content_uses_image(content: &TextContent, image_id: Uuid) -> bool { + content.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + span.fills + .iter() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + }) + }) +} + +fn shape_uses_image(shape: &Shape, image_id: Uuid) -> bool { + if shape + .fills() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + || shape + .strokes + .iter() + .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) + { + return true; + } + match &shape.shape_type { + Type::Text(content) => text_content_uses_image(content, image_id), + _ => false, + } +} + +fn rebuild_text_layout_for_image(shape: &mut Shape) { + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + // Image shaders are baked into Skia paragraphs at layout time. When the + // media arrives after the first layout, force a rebuild so text fills + // pick up `get_image` (rect fills look up the image every paint). + text_content.force_next_layout_update(); + text_content.update_layout(shape.selrect); + shape.apply_deferred_batch_paint(); + shape.invalidate_extrect(); +} + fn touch_shapes_with_image(state: &mut State, image_id: Uuid) { - let ids: Vec = state + let ids: Vec<(Uuid, bool)> = state .shapes .iter() - .filter(|shape| { - shape - .fills() - .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) - || shape - .strokes - .iter() - .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) - }) - .map(|shape| shape.id) + .filter(|shape| shape_uses_image(shape, image_id)) + .map(|shape| (shape.id, matches!(shape.shape_type, Type::Text(_)))) .collect(); - for id in ids { + for (id, is_text) in ids { + if is_text { + if let Some(shape) = state.shapes.get_mut(&id) { + rebuild_text_layout_for_image(shape); + } + } state.touch_shape(id); } } From 2df5f417a01519044ce26ffe2dbaab1f9d365c33 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 23 Oct 2026 12:40:24 +0200 Subject: [PATCH 8/8] :rewind: Backport .github and docker/ changes from develop --- .github/CODEOWNERS | 9 + .github/actionlint.yaml | 14 + .github/workflows/auto-label.yml | 25 +- .github/workflows/build-adhoc.yml | 29 +- .github/workflows/build-bundle.yml | 52 +++- .github/workflows/build-develop.yml | 29 +- .../workflows/build-docker-admin-console.yml | 22 +- .github/workflows/build-docker-devenv.yml | 42 ++- .github/workflows/build-docker.yml | 134 ++++++--- .github/workflows/build-renovate.yml | 256 ++++++++++++++++++ .github/workflows/build-staging.yml | 29 +- .github/workflows/build-tag.yml | 41 ++- .github/workflows/build-tmp-tokens.yml | 29 +- .github/workflows/commit-checker.yml | 15 +- .github/workflows/lint-workflows.yml | 78 ++++++ .github/workflows/plugins-deploy-api-doc.yml | 38 +-- .github/workflows/plugins-deploy-package.yml | 81 +++--- .github/workflows/plugins-deploy-packages.yml | 44 ++- .../workflows/plugins-deploy-styles-doc.yml | 38 +-- .github/workflows/release.yml | 41 ++- .github/workflows/tests-backend.yml | 11 +- .github/workflows/tests-common.yml | 7 +- .github/workflows/tests-e2e.yml | 46 ++-- .github/workflows/tests-exporter.yml | 14 +- .github/workflows/tests-frontend.yml | 7 +- .github/workflows/tests-library.yml | 7 +- .github/workflows/tests-mcp.yml | 7 +- .github/workflows/tests-plugins.yml | 7 +- .github/workflows/tests-wasm.yml | 17 +- .github/zizmor.yml | 39 +++ docker/devenv/Dockerfile | 98 ++----- docker/devenv/docker-compose.infra.yml | 6 +- docker/devenv/docker-compose.main.yml | 7 +- docker/devenv/files/bashrc | 4 +- docker/devenv/files/entrypoint.sh | 14 + docker/devenv/files/nginx.conf | 20 +- docker/imagemagick/Dockerfile | 11 +- docker/images/Dockerfile.backend | 18 +- docker/images/Dockerfile.exporter | 17 +- docker/images/Dockerfile.frontend | 14 +- docker/images/Dockerfile.mcp | 10 +- docker/images/Dockerfile.media-processor | 92 +++---- docker/images/Dockerfile.storybook | 2 +- docker/images/docker-compose.yaml | 47 +++- ...ginx-admin-console-locations.conf.template | 6 +- docker/images/files/nginx-entrypoint.sh | 139 +++++++++- .../files/nginx-external-locations.conf | 24 ++ .../files/nginx-mcp-locations.conf.template | 6 - .../nginx-security-headers.conf.template | 6 + docker/images/files/nginx.conf.template | 21 +- 50 files changed, 1353 insertions(+), 417 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/build-renovate.yml create mode 100644 .github/workflows/lint-workflows.yml create mode 100644 .github/zizmor.yml create mode 100644 docker/images/files/nginx-security-headers.conf.template 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;