").html(e).find(t.opts.filter))),t.$slide.one("onReset",function(){n(this).find("video,audio").trigger("pause"),t.$placeholder&&(t.$placeholder.after(e.removeClass("fancybox-content").hide()).remove(),t.$placeholder=null),t.$smallBtn&&(t.$smallBtn.remove(),t.$smallBtn=null),t.hasError||(n(this).empty(),t.isLoaded=!1,t.isRevealed=!1)}),n(e).appendTo(t.$slide),n(e).is("video,audio")&&(n(e).addClass("fancybox-video"),n(e).wrap("
"),t.contentType="video",t.opts.width=t.opts.width||n(e).attr("width"),t.opts.height=t.opts.height||n(e).attr("height")),t.$content=t.$slide.children().filter("div,form,main,video,audio,article,.fancybox-content").first(),t.$content.siblings().hide(),t.$content.length||(t.$content=t.$slide.wrapInner("
").children().first()),t.$content.addClass("fancybox-content"),t.$slide.addClass("fancybox-slide--"+t.contentType),o.afterLoad(t))},setError:function(t){t.hasError=!0,t.$slide.trigger("onReset").removeClass("fancybox-slide--"+t.contentType).addClass("fancybox-slide--error"),t.contentType="html",this.setContent(t,this.translate(t,t.opts.errorTpl)),t.pos===this.currPos&&(this.isAnimating=!1)},showLoading:function(t){var e=this;(t=t||e.current)&&!t.$spinner&&(t.$spinner=n(e.translate(e,e.opts.spinnerTpl)).appendTo(t.$slide).hide().fadeIn("fast"))},hideLoading:function(t){var e=this;(t=t||e.current)&&t.$spinner&&(t.$spinner.stop().remove(),delete t.$spinner)},afterLoad:function(t){var e=this;e.isClosing||(t.isLoading=!1,t.isLoaded=!0,e.trigger("afterLoad",t),e.hideLoading(t),!t.opts.smallBtn||t.$smallBtn&&t.$smallBtn.length||(t.$smallBtn=n(e.translate(t,t.opts.btnTpl.smallBtn)).appendTo(t.$content)),t.opts.protect&&t.$content&&!t.hasError&&(t.$content.on("contextmenu.fb",function(t){return 2==t.button&&t.preventDefault(),!0}),"image"===t.type&&n('
').appendTo(t.$content)),e.adjustCaption(t),e.adjustLayout(t),t.pos===e.currPos&&e.updateCursor(),e.revealContent(t))},adjustCaption:function(t){var e,n=this,o=t||n.current,i=o.opts.caption,a=o.opts.preventCaptionOverlap,s=n.$refs.caption,r=!1;s.toggleClass("fancybox-caption--separate",a),a&&i&&i.length&&(o.pos!==n.currPos?(e=s.clone().appendTo(s.parent()),e.children().eq(0).empty().html(i),r=e.outerHeight(!0),e.empty().remove()):n.$caption&&(r=n.$caption.outerHeight(!0)),o.$slide.css("padding-bottom",r||""))},adjustLayout:function(t){var e,n,o,i,a=this,s=t||a.current;s.isLoaded&&!0!==s.opts.disableLayoutFix&&(s.$content.css("margin-bottom",""),s.$content.outerHeight()>s.$slide.height()+.5&&(o=s.$slide[0].style["padding-bottom"],i=s.$slide.css("padding-bottom"),parseFloat(i)>0&&(e=s.$slide[0].scrollHeight,s.$slide.css("padding-bottom",0),Math.abs(e-s.$slide[0].scrollHeight)<1&&(n=i),s.$slide.css("padding-bottom",o))),s.$content.css("margin-bottom",n))},revealContent:function(t){var e,o,i,a,s=this,r=t.$slide,c=!1,l=!1,d=s.isMoved(t),u=t.isRevealed;return t.isRevealed=!0,e=t.opts[s.firstRun?"animationEffect":"transitionEffect"],i=t.opts[s.firstRun?"animationDuration":"transitionDuration"],i=parseInt(void 0===t.forcedDuration?i:t.forcedDuration,10),!d&&t.pos===s.currPos&&i||(e=!1),"zoom"===e&&(t.pos===s.currPos&&i&&"image"===t.type&&!t.hasError&&(l=s.getThumbPos(t))?c=s.getFitPos(t):e="fade"),"zoom"===e?(s.isAnimating=!0,c.scaleX=c.width/l.width,c.scaleY=c.height/l.height,a=t.opts.zoomOpacity,"auto"==a&&(a=Math.abs(t.width/t.height-l.width/l.height)>.1),a&&(l.opacity=.1,c.opacity=1),n.fancybox.setTranslate(t.$content.removeClass("fancybox-is-hidden"),l),p(t.$content),void n.fancybox.animate(t.$content,c,i,function(){s.isAnimating=!1,s.complete()})):(s.updateSlide(t),e?(n.fancybox.stop(r),o="fancybox-slide--"+(t.pos>=s.prevPos?"next":"previous")+" fancybox-animated fancybox-fx-"+e,r.addClass(o).removeClass("fancybox-slide--current"),t.$content.removeClass("fancybox-is-hidden"),p(r),"image"!==t.type&&t.$content.hide().show(0),void n.fancybox.animate(r,"fancybox-slide--current",i,function(){r.removeClass(o).css({transform:"",opacity:""}),t.pos===s.currPos&&s.complete()},!0)):(t.$content.removeClass("fancybox-is-hidden"),u||!d||"image"!==t.type||t.hasError||t.$content.hide().fadeIn("fast"),void(t.pos===s.currPos&&s.complete())))},getThumbPos:function(t){var e,o,i,a,s,r=!1,c=t.$thumb;return!(!c||!g(c[0]))&&(e=n.fancybox.getTranslate(c),o=parseFloat(c.css("border-top-width")||0),i=parseFloat(c.css("border-right-width")||0),a=parseFloat(c.css("border-bottom-width")||0),s=parseFloat(c.css("border-left-width")||0),r={top:e.top+o,left:e.left+s,width:e.width-i-s,height:e.height-o-a,scaleX:1,scaleY:1},e.width>0&&e.height>0&&r)},complete:function(){var t,e=this,o=e.current,i={};!e.isMoved()&&o.isLoaded&&(o.isComplete||(o.isComplete=!0,o.$slide.siblings().trigger("onReset"),e.preload("inline"),p(o.$slide),o.$slide.addClass("fancybox-slide--complete"),n.each(e.slides,function(t,o){o.pos>=e.currPos-1&&o.pos<=e.currPos+1?i[o.pos]=o:o&&(n.fancybox.stop(o.$slide),o.$slide.off().remove())}),e.slides=i),e.isAnimating=!1,e.updateCursor(),e.trigger("afterShow"),o.opts.video.autoStart&&o.$slide.find("video,audio").filter(":visible:first").trigger("play").one("ended",function(){Document.exitFullscreen?Document.exitFullscreen():this.webkitExitFullscreen&&this.webkitExitFullscreen(),e.next()}),o.opts.autoFocus&&"html"===o.contentType&&(t=o.$content.find("input[autofocus]:enabled:visible:first"),t.length?t.trigger("focus"):e.focus(null,!0)),o.$slide.scrollTop(0).scrollLeft(0))},preload:function(t){var e,n,o=this;o.group.length<2||(n=o.slides[o.currPos+1],e=o.slides[o.currPos-1],e&&e.type===t&&o.loadSlide(e),n&&n.type===t&&o.loadSlide(n))},focus:function(t,o){var i,a,s=this,r=["a[href]","area[href]",'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',"select:not([disabled]):not([aria-hidden])","textarea:not([disabled]):not([aria-hidden])","button:not([disabled]):not([aria-hidden])","iframe","object","embed","video","audio","[contenteditable]",'[tabindex]:not([tabindex^="-"])'].join(",");s.isClosing||(i=!t&&s.current&&s.current.isComplete?s.current.$slide.find("*:visible"+(o?":not(.fancybox-close-small)":"")):s.$refs.container.find("*:visible"),i=i.filter(r).filter(function(){return"hidden"!==n(this).css("visibility")&&!n(this).hasClass("disabled")}),i.length?(a=i.index(e.activeElement),t&&t.shiftKey?(a<0||0==a)&&(t.preventDefault(),i.eq(i.length-1).trigger("focus")):(a<0||a==i.length-1)&&(t&&t.preventDefault(),i.eq(0).trigger("focus"))):s.$refs.container.trigger("focus"))},activate:function(){var t=this;n(".fancybox-container").each(function(){var e=n(this).data("FancyBox");e&&e.id!==t.id&&!e.isClosing&&(e.trigger("onDeactivate"),e.removeEvents(),e.isVisible=!1)}),t.isVisible=!0,(t.current||t.isIdle)&&(t.update(),t.updateControls()),t.trigger("onActivate"),t.addEvents()},close:function(t,e){var o,i,a,s,r,c,l,u=this,f=u.current,h=function(){u.cleanUp(t)};return!u.isClosing&&(u.isClosing=!0,!1===u.trigger("beforeClose",t)?(u.isClosing=!1,d(function(){u.update()}),!1):(u.removeEvents(),a=f.$content,o=f.opts.animationEffect,i=n.isNumeric(e)?e:o?f.opts.animationDuration:0,f.$slide.removeClass("fancybox-slide--complete fancybox-slide--next fancybox-slide--previous fancybox-animated"),!0!==t?n.fancybox.stop(f.$slide):o=!1,f.$slide.siblings().trigger("onReset").remove(),i&&u.$refs.container.removeClass("fancybox-is-open").addClass("fancybox-is-closing").css("transition-duration",i+"ms"),u.hideLoading(f),u.hideControls(!0),u.updateCursor(),"zoom"!==o||a&&i&&"image"===f.type&&!u.isMoved()&&!f.hasError&&(l=u.getThumbPos(f))||(o="fade"),"zoom"===o?(n.fancybox.stop(a),s=n.fancybox.getTranslate(a),c={top:s.top,left:s.left,scaleX:s.width/l.width,scaleY:s.height/l.height,width:l.width,height:l.height},r=f.opts.zoomOpacity,
+"auto"==r&&(r=Math.abs(f.width/f.height-l.width/l.height)>.1),r&&(l.opacity=0),n.fancybox.setTranslate(a,c),p(a),n.fancybox.animate(a,l,i,h),!0):(o&&i?n.fancybox.animate(f.$slide.addClass("fancybox-slide--previous").removeClass("fancybox-slide--current"),"fancybox-animated fancybox-fx-"+o,i,h):!0===t?setTimeout(h,i):h(),!0)))},cleanUp:function(e){var o,i,a,s=this,r=s.current.opts.$orig;s.current.$slide.trigger("onReset"),s.$refs.container.empty().remove(),s.trigger("afterClose",e),s.current.opts.backFocus&&(r&&r.length&&r.is(":visible")||(r=s.$trigger),r&&r.length&&(i=t.scrollX,a=t.scrollY,r.trigger("focus"),n("html, body").scrollTop(a).scrollLeft(i))),s.current=null,o=n.fancybox.getInstance(),o?o.activate():(n("body").removeClass("fancybox-active compensate-for-scrollbar"),n("#fancybox-style-noscroll").remove())},trigger:function(t,e){var o,i=Array.prototype.slice.call(arguments,1),a=this,s=e&&e.opts?e:a.current;if(s?i.unshift(s):s=a,i.unshift(a),n.isFunction(s.opts[t])&&(o=s.opts[t].apply(s,i)),!1===o)return o;"afterClose"!==t&&a.$refs?a.$refs.container.trigger(t+".fb",i):r.trigger(t+".fb",i)},updateControls:function(){var t=this,o=t.current,i=o.index,a=t.$refs.container,s=t.$refs.caption,r=o.opts.caption;o.$slide.trigger("refresh"),r&&r.length?(t.$caption=s,s.children().eq(0).html(r)):t.$caption=null,t.hasHiddenControls||t.isIdle||t.showControls(),a.find("[data-fancybox-count]").html(t.group.length),a.find("[data-fancybox-index]").html(i+1),a.find("[data-fancybox-prev]").prop("disabled",!o.opts.loop&&i<=0),a.find("[data-fancybox-next]").prop("disabled",!o.opts.loop&&i>=t.group.length-1),"image"===o.type?a.find("[data-fancybox-zoom]").show().end().find("[data-fancybox-download]").attr("href",o.opts.image.src||o.src).show():o.opts.toolbar&&a.find("[data-fancybox-download],[data-fancybox-zoom]").hide(),n(e.activeElement).is(":hidden,[disabled]")&&t.$refs.container.trigger("focus")},hideControls:function(t){var e=this,n=["infobar","toolbar","nav"];!t&&e.current.opts.preventCaptionOverlap||n.push("caption"),this.$refs.container.removeClass(n.map(function(t){return"fancybox-show-"+t}).join(" ")),this.hasHiddenControls=!0},showControls:function(){var t=this,e=t.current?t.current.opts:t.opts,n=t.$refs.container;t.hasHiddenControls=!1,t.idleSecondsCounter=0,n.toggleClass("fancybox-show-toolbar",!(!e.toolbar||!e.buttons)).toggleClass("fancybox-show-infobar",!!(e.infobar&&t.group.length>1)).toggleClass("fancybox-show-caption",!!t.$caption).toggleClass("fancybox-show-nav",!!(e.arrows&&t.group.length>1)).toggleClass("fancybox-is-modal",!!e.modal)},toggleControls:function(){this.hasHiddenControls?this.showControls():this.hideControls()}}),n.fancybox={version:"3.5.7",defaults:a,getInstance:function(t){var e=n('.fancybox-container:not(".fancybox-is-closing"):last').data("FancyBox"),o=Array.prototype.slice.call(arguments,1);return e instanceof b&&("string"===n.type(t)?e[t].apply(e,o):"function"===n.type(t)&&t.apply(e,o),e)},open:function(t,e,n){return new b(t,e,n)},close:function(t){var e=this.getInstance();e&&(e.close(),!0===t&&this.close(t))},destroy:function(){this.close(!0),r.add("body").off("click.fb-start","**")},isMobile:/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),use3d:function(){var n=e.createElement("div");return t.getComputedStyle&&t.getComputedStyle(n)&&t.getComputedStyle(n).getPropertyValue("transform")&&!(e.documentMode&&e.documentMode<11)}(),getTranslate:function(t){var e;return!(!t||!t.length)&&(e=t[0].getBoundingClientRect(),{top:e.top||0,left:e.left||0,width:e.width,height:e.height,opacity:parseFloat(t.css("opacity"))})},setTranslate:function(t,e){var n="",o={};if(t&&e)return void 0===e.left&&void 0===e.top||(n=(void 0===e.left?t.position().left:e.left)+"px, "+(void 0===e.top?t.position().top:e.top)+"px",n=this.use3d?"translate3d("+n+", 0px)":"translate("+n+")"),void 0!==e.scaleX&&void 0!==e.scaleY?n+=" scale("+e.scaleX+", "+e.scaleY+")":void 0!==e.scaleX&&(n+=" scaleX("+e.scaleX+")"),n.length&&(o.transform=n),void 0!==e.opacity&&(o.opacity=e.opacity),void 0!==e.width&&(o.width=e.width),void 0!==e.height&&(o.height=e.height),t.css(o)},animate:function(t,e,o,i,a){var s,r=this;n.isFunction(o)&&(i=o,o=null),r.stop(t),s=r.getTranslate(t),t.on(f,function(c){(!c||!c.originalEvent||t.is(c.originalEvent.target)&&"z-index"!=c.originalEvent.propertyName)&&(r.stop(t),n.isNumeric(o)&&t.css("transition-duration",""),n.isPlainObject(e)?void 0!==e.scaleX&&void 0!==e.scaleY&&r.setTranslate(t,{top:e.top,left:e.left,width:s.width*e.scaleX,height:s.height*e.scaleY,scaleX:1,scaleY:1}):!0!==a&&t.removeClass(e),n.isFunction(i)&&i(c))}),n.isNumeric(o)&&t.css("transition-duration",o+"ms"),n.isPlainObject(e)?(void 0!==e.scaleX&&void 0!==e.scaleY&&(delete e.width,delete e.height,t.parent().hasClass("fancybox-slide--image")&&t.parent().addClass("fancybox-is-scaling")),n.fancybox.setTranslate(t,e)):t.addClass(e),t.data("timer",setTimeout(function(){t.trigger(f)},o+33))},stop:function(t,e){t&&t.length&&(clearTimeout(t.data("timer")),e&&t.trigger(f),t.off(f).css("transition-duration",""),t.parent().removeClass("fancybox-is-scaling"))}},n.fn.fancybox=function(t){var e;return t=t||{},e=t.selector||!1,e?n("body").off("click.fb-start",e).on("click.fb-start",e,{options:t},i):this.off("click.fb-start").on("click.fb-start",{items:this,options:t},i),this},r.on("click.fb-start","[data-fancybox]",i),r.on("click.fb-start","[data-fancybox-trigger]",function(t){n('[data-fancybox="'+n(this).attr("data-fancybox-trigger")+'"]').eq(n(this).attr("data-fancybox-index")||0).trigger("click.fb-start",{$trigger:n(this)})}),function(){var t=null;r.on("mousedown mouseup focus blur",".fancybox-button",function(e){switch(e.type){case"mousedown":t=n(this);break;case"mouseup":t=null;break;case"focusin":n(".fancybox-button").removeClass("fancybox-focus"),n(this).is(t)||n(this).is("[disabled]")||n(this).addClass("fancybox-focus");break;case"focusout":n(".fancybox-button").removeClass("fancybox-focus")}})}()}}(window,document,jQuery),function(t){"use strict";var e={youtube:{matcher:/(youtube\.com|youtu\.be|youtube\-nocookie\.com)\/(watch\?(.*&)?v=|v\/|u\/|embed\/?)?(videoseries\?list=(.*)|[\w-]{11}|\?listType=(.*)&list=(.*))(.*)/i,params:{autoplay:1,autohide:1,fs:1,rel:0,hd:1,wmode:"transparent",enablejsapi:1,html5:1},paramPlace:8,type:"iframe",url:"https://www.youtube-nocookie.com/embed/$4",thumb:"https://img.youtube.com/vi/$4/hqdefault.jpg"},vimeo:{matcher:/^.+vimeo.com\/(.*\/)?([\d]+)(.*)?/,params:{autoplay:1,hd:1,show_title:1,show_byline:1,show_portrait:0,fullscreen:1},paramPlace:3,type:"iframe",url:"//player.vimeo.com/video/$2"},instagram:{matcher:/(instagr\.am|instagram\.com)\/p\/([a-zA-Z0-9_\-]+)\/?/i,type:"image",url:"//$1/p/$2/media/?size=l"},gmap_place:{matcher:/(maps\.)?google\.([a-z]{2,3}(\.[a-z]{2})?)\/(((maps\/(place\/(.*)\/)?\@(.*),(\d+.?\d+?)z))|(\?ll=))(.*)?/i,type:"iframe",url:function(t){return"//maps.google."+t[2]+"/?ll="+(t[9]?t[9]+"&z="+Math.floor(t[10])+(t[12]?t[12].replace(/^\//,"&"):""):t[12]+"").replace(/\?/,"&")+"&output="+(t[12]&&t[12].indexOf("layer=c")>0?"svembed":"embed")}},gmap_search:{matcher:/(maps\.)?google\.([a-z]{2,3}(\.[a-z]{2})?)\/(maps\/search\/)(.*)/i,type:"iframe",url:function(t){return"//maps.google."+t[2]+"/maps?q="+t[5].replace("query=","q=").replace("api=1","")+"&output=embed"}}},n=function(e,n,o){if(e)return o=o||"","object"===t.type(o)&&(o=t.param(o,!0)),t.each(n,function(t,n){e=e.replace("$"+t,n||"")}),o.length&&(e+=(e.indexOf("?")>0?"&":"?")+o),e};t(document).on("objectNeedsType.fb",function(o,i,a){var s,r,c,l,d,u,f,p=a.src||"",h=!1;s=t.extend(!0,{},e,a.opts.media),t.each(s,function(e,o){if(c=p.match(o.matcher)){if(h=o.type,f=e,u={},o.paramPlace&&c[o.paramPlace]){d=c[o.paramPlace],"?"==d[0]&&(d=d.substring(1)),d=d.split("&");for(var i=0;i
1&&("youtube"===n.contentSource||"vimeo"===n.contentSource)&&o.load(n.contentSource)}})}(jQuery),function(t,e,n){"use strict";var o=function(){return t.requestAnimationFrame||t.webkitRequestAnimationFrame||t.mozRequestAnimationFrame||t.oRequestAnimationFrame||function(e){return t.setTimeout(e,1e3/60)}}(),i=function(){return t.cancelAnimationFrame||t.webkitCancelAnimationFrame||t.mozCancelAnimationFrame||t.oCancelAnimationFrame||function(e){t.clearTimeout(e)}}(),a=function(e){var n=[];e=e.originalEvent||e||t.e,e=e.touches&&e.touches.length?e.touches:e.changedTouches&&e.changedTouches.length?e.changedTouches:[e];for(var o in e)e[o].pageX?n.push({x:e[o].pageX,y:e[o].pageY}):e[o].clientX&&n.push({x:e[o].clientX,y:e[o].clientY});return n},s=function(t,e,n){return e&&t?"x"===n?t.x-e.x:"y"===n?t.y-e.y:Math.sqrt(Math.pow(t.x-e.x,2)+Math.pow(t.y-e.y,2)):0},r=function(t){if(t.is('a,area,button,[role="button"],input,label,select,summary,textarea,video,audio,iframe')||n.isFunction(t.get(0).onclick)||t.data("selectable"))return!0;for(var e=0,o=t[0].attributes,i=o.length;ee.clientHeight,a=("scroll"===o||"auto"===o)&&e.scrollWidth>e.clientWidth;return i||a},l=function(t){for(var e=!1;;){if(e=c(t.get(0)))break;if(t=t.parent(),!t.length||t.hasClass("fancybox-stage")||t.is("body"))break}return e},d=function(t){var e=this;e.instance=t,e.$bg=t.$refs.bg,e.$stage=t.$refs.stage,e.$container=t.$refs.container,e.destroy(),e.$container.on("touchstart.fb.touch mousedown.fb.touch",n.proxy(e,"ontouchstart"))};d.prototype.destroy=function(){var t=this;t.$container.off(".fb.touch"),n(e).off(".fb.touch"),t.requestId&&(i(t.requestId),t.requestId=null),t.tapped&&(clearTimeout(t.tapped),t.tapped=null)},d.prototype.ontouchstart=function(o){var i=this,c=n(o.target),d=i.instance,u=d.current,f=u.$slide,p=u.$content,h="touchstart"==o.type;if(h&&i.$container.off("mousedown.fb.touch"),(!o.originalEvent||2!=o.originalEvent.button)&&f.length&&c.length&&!r(c)&&!r(c.parent())&&(c.is("img")||!(o.originalEvent.clientX>c[0].clientWidth+c.offset().left))){if(!u||d.isAnimating||u.$slide.hasClass("fancybox-animated"))return o.stopPropagation(),void o.preventDefault();i.realPoints=i.startPoints=a(o),i.startPoints.length&&(u.touch&&o.stopPropagation(),i.startEvent=o,i.canTap=!0,i.$target=c,i.$content=p,i.opts=u.opts.touch,i.isPanning=!1,i.isSwiping=!1,i.isZooming=!1,i.isScrolling=!1,i.canPan=d.canPan(),i.startTime=(new Date).getTime(),i.distanceX=i.distanceY=i.distance=0,i.canvasWidth=Math.round(f[0].clientWidth),i.canvasHeight=Math.round(f[0].clientHeight),i.contentLastPos=null,i.contentStartPos=n.fancybox.getTranslate(i.$content)||{top:0,left:0},i.sliderStartPos=n.fancybox.getTranslate(f),i.stagePos=n.fancybox.getTranslate(d.$refs.stage),i.sliderStartPos.top-=i.stagePos.top,i.sliderStartPos.left-=i.stagePos.left,i.contentStartPos.top-=i.stagePos.top,i.contentStartPos.left-=i.stagePos.left,n(e).off(".fb.touch").on(h?"touchend.fb.touch touchcancel.fb.touch":"mouseup.fb.touch mouseleave.fb.touch",n.proxy(i,"ontouchend")).on(h?"touchmove.fb.touch":"mousemove.fb.touch",n.proxy(i,"ontouchmove")),n.fancybox.isMobile&&e.addEventListener("scroll",i.onscroll,!0),((i.opts||i.canPan)&&(c.is(i.$stage)||i.$stage.find(c).length)||(c.is(".fancybox-image")&&o.preventDefault(),n.fancybox.isMobile&&c.parents(".fancybox-caption").length))&&(i.isScrollable=l(c)||l(c.parent()),n.fancybox.isMobile&&i.isScrollable||o.preventDefault(),(1===i.startPoints.length||u.hasError)&&(i.canPan?(n.fancybox.stop(i.$content),i.isPanning=!0):i.isSwiping=!0,i.$container.addClass("fancybox-is-grabbing")),2===i.startPoints.length&&"image"===u.type&&(u.isLoaded||u.$ghost)&&(i.canTap=!1,i.isSwiping=!1,i.isPanning=!1,i.isZooming=!0,n.fancybox.stop(i.$content),i.centerPointStartX=.5*(i.startPoints[0].x+i.startPoints[1].x)-n(t).scrollLeft(),i.centerPointStartY=.5*(i.startPoints[0].y+i.startPoints[1].y)-n(t).scrollTop(),i.percentageOfImageAtPinchPointX=(i.centerPointStartX-i.contentStartPos.left)/i.contentStartPos.width,i.percentageOfImageAtPinchPointY=(i.centerPointStartY-i.contentStartPos.top)/i.contentStartPos.height,i.startDistanceBetweenFingers=s(i.startPoints[0],i.startPoints[1]))))}},d.prototype.onscroll=function(t){var n=this;n.isScrolling=!0,e.removeEventListener("scroll",n.onscroll,!0)},d.prototype.ontouchmove=function(t){var e=this;return void 0!==t.originalEvent.buttons&&0===t.originalEvent.buttons?void e.ontouchend(t):e.isScrolling?void(e.canTap=!1):(e.newPoints=a(t),void((e.opts||e.canPan)&&e.newPoints.length&&e.newPoints.length&&(e.isSwiping&&!0===e.isSwiping||t.preventDefault(),e.distanceX=s(e.newPoints[0],e.startPoints[0],"x"),e.distanceY=s(e.newPoints[0],e.startPoints[0],"y"),e.distance=s(e.newPoints[0],e.startPoints[0]),e.distance>0&&(e.isSwiping?e.onSwipe(t):e.isPanning?e.onPan():e.isZooming&&e.onZoom()))))},d.prototype.onSwipe=function(e){var a,s=this,r=s.instance,c=s.isSwiping,l=s.sliderStartPos.left||0;if(!0!==c)"x"==c&&(s.distanceX>0&&(s.instance.group.length<2||0===s.instance.current.index&&!s.instance.current.opts.loop)?l+=Math.pow(s.distanceX,.8):s.distanceX<0&&(s.instance.group.length<2||s.instance.current.index===s.instance.group.length-1&&!s.instance.current.opts.loop)?l-=Math.pow(-s.distanceX,.8):l+=s.distanceX),s.sliderLastPos={top:"x"==c?0:s.sliderStartPos.top+s.distanceY,left:l},s.requestId&&(i(s.requestId),s.requestId=null),s.requestId=o(function(){s.sliderLastPos&&(n.each(s.instance.slides,function(t,e){var o=e.pos-s.instance.currPos;n.fancybox.setTranslate(e.$slide,{top:s.sliderLastPos.top,left:s.sliderLastPos.left+o*s.canvasWidth+o*e.opts.gutter})}),s.$container.addClass("fancybox-is-sliding"))});else if(Math.abs(s.distance)>10){if(s.canTap=!1,r.group.length<2&&s.opts.vertical?s.isSwiping="y":r.isDragging||!1===s.opts.vertical||"auto"===s.opts.vertical&&n(t).width()>800?s.isSwiping="x":(a=Math.abs(180*Math.atan2(s.distanceY,s.distanceX)/Math.PI),s.isSwiping=a>45&&a<135?"y":"x"),"y"===s.isSwiping&&n.fancybox.isMobile&&s.isScrollable)return void(s.isScrolling=!0);r.isDragging=s.isSwiping,s.startPoints=s.newPoints,n.each(r.slides,function(t,e){var o,i;n.fancybox.stop(e.$slide),o=n.fancybox.getTranslate(e.$slide),i=n.fancybox.getTranslate(r.$refs.stage),e.$slide.css({transform:"",opacity:"","transition-duration":""}).removeClass("fancybox-animated").removeClass(function(t,e){return(e.match(/(^|\s)fancybox-fx-\S+/g)||[]).join(" ")}),e.pos===r.current.pos&&(s.sliderStartPos.top=o.top-i.top,s.sliderStartPos.left=o.left-i.left),n.fancybox.setTranslate(e.$slide,{top:o.top-i.top,left:o.left-i.left})}),r.SlideShow&&r.SlideShow.isActive&&r.SlideShow.stop()}},d.prototype.onPan=function(){var t=this;if(s(t.newPoints[0],t.realPoints[0])<(n.fancybox.isMobile?10:5))return void(t.startPoints=t.newPoints);t.canTap=!1,t.contentLastPos=t.limitMovement(),t.requestId&&i(t.requestId),t.requestId=o(function(){n.fancybox.setTranslate(t.$content,t.contentLastPos)})},d.prototype.limitMovement=function(){var t,e,n,o,i,a,s=this,r=s.canvasWidth,c=s.canvasHeight,l=s.distanceX,d=s.distanceY,u=s.contentStartPos,f=u.left,p=u.top,h=u.width,g=u.height;return i=h>r?f+l:f,a=p+d,t=Math.max(0,.5*r-.5*h),e=Math.max(0,.5*c-.5*g),n=Math.min(r-h,.5*r-.5*h),o=Math.min(c-g,.5*c-.5*g),l>0&&i>t&&(i=t-1+Math.pow(-t+f+l,.8)||0),l<0&&i0&&a>e&&(a=e-1+Math.pow(-e+p+d,.8)||0),d<0&&aa?(t=t>0?0:t,t=ts?(e=e>0?0:e,e=e1&&(o.dMs>130&&s>10||s>50);o.sliderLastPos=null,"y"==t&&!e&&Math.abs(o.distanceY)>50?(n.fancybox.animate(o.instance.current.$slide,{top:o.sliderStartPos.top+o.distanceY+150*o.velocityY,opacity:0},200),i=o.instance.close(!0,250)):r&&o.distanceX>0?i=o.instance.previous(300):r&&o.distanceX<0&&(i=o.instance.next(300)),!1!==i||"x"!=t&&"y"!=t||o.instance.centerSlide(200),o.$container.removeClass("fancybox-is-sliding")},d.prototype.endPanning=function(){var t,e,o,i=this;i.contentLastPos&&(!1===i.opts.momentum||i.dMs>350?(t=i.contentLastPos.left,e=i.contentLastPos.top):(t=i.contentLastPos.left+500*i.velocityX,e=i.contentLastPos.top+500*i.velocityY),o=i.limitPosition(t,e,i.contentStartPos.width,i.contentStartPos.height),o.width=i.contentStartPos.width,o.height=i.contentStartPos.height,n.fancybox.animate(i.$content,o,366))},d.prototype.endZooming=function(){var t,e,o,i,a=this,s=a.instance.current,r=a.newWidth,c=a.newHeight;a.contentLastPos&&(t=a.contentLastPos.left,e=a.contentLastPos.top,i={top:e,left:t,width:r,height:c,scaleX:1,scaleY:1},n.fancybox.setTranslate(a.$content,i),rs.width||c>s.height?a.instance.scaleToActual(a.centerPointStartX,a.centerPointStartY,150):(o=a.limitPosition(t,e,r,c),n.fancybox.animate(a.$content,o,150)))},d.prototype.onTap=function(e){var o,i=this,s=n(e.target),r=i.instance,c=r.current,l=e&&a(e)||i.startPoints,d=l[0]?l[0].x-n(t).scrollLeft()-i.stagePos.left:0,u=l[0]?l[0].y-n(t).scrollTop()-i.stagePos.top:0,f=function(t){var o=c.opts[t];if(n.isFunction(o)&&(o=o.apply(r,[c,e])),o)switch(o){case"close":r.close(i.startEvent);break;case"toggleControls":r.toggleControls();break;case"next":r.next();break;case"nextOrClose":r.group.length>1?r.next():r.close(i.startEvent);break;case"zoom":"image"==c.type&&(c.isLoaded||c.$ghost)&&(r.canPan()?r.scaleToFit():r.isScaledDown()?r.scaleToActual(d,u):r.group.length<2&&r.close(i.startEvent))}};if((!e.originalEvent||2!=e.originalEvent.button)&&(s.is("img")||!(d>s[0].clientWidth+s.offset().left))){if(s.is(".fancybox-bg,.fancybox-inner,.fancybox-outer,.fancybox-container"))o="Outside";else if(s.is(".fancybox-slide"))o="Slide";else{if(!r.current.$content||!r.current.$content.find(s).addBack().filter(s).length)return;o="Content"}if(i.tapped){if(clearTimeout(i.tapped),i.tapped=null,Math.abs(d-i.tapX)>50||Math.abs(u-i.tapY)>50)return this;f("dblclick"+o)}else i.tapX=d,i.tapY=u,c.opts["dblclick"+o]&&c.opts["dblclick"+o]!==c.opts["click"+o]?i.tapped=setTimeout(function(){i.tapped=null,r.isAnimating||f("click"+o)},500):f("click"+o);return this}},n(e).on("onActivate.fb",function(t,e){e&&!e.Guestures&&(e.Guestures=new d(e))}).on("beforeClose.fb",function(t,e){e&&e.Guestures&&e.Guestures.destroy()})}(window,document,jQuery),function(t,e){"use strict";e.extend(!0,e.fancybox.defaults,{btnTpl:{slideShow:''},slideShow:{autoStart:!1,speed:3e3,progress:!0}});var n=function(t){this.instance=t,this.init()};e.extend(n.prototype,{timer:null,isActive:!1,$button:null,init:function(){var t=this,n=t.instance,o=n.group[n.currIndex].opts.slideShow;t.$button=n.$refs.toolbar.find("[data-fancybox-play]").on("click",function(){t.toggle()}),n.group.length<2||!o?t.$button.hide():o.progress&&(t.$progress=e('').appendTo(n.$refs.inner))},set:function(t){var n=this,o=n.instance,i=o.current;i&&(!0===t||i.opts.loop||o.currIndex'},fullScreen:{autoStart:!1}}),e(t).on(n.fullscreenchange,function(){var t=o.isFullscreen(),n=e.fancybox.getInstance();n&&(n.current&&"image"===n.current.type&&n.isAnimating&&(n.isAnimating=!1,n.update(!0,!0,0),n.isComplete||n.complete()),n.trigger("onFullscreenChange",t),n.$refs.container.toggleClass("fancybox-is-fullscreen",t),n.$refs.toolbar.find("[data-fancybox-fullscreen]").toggleClass("fancybox-button--fsenter",!t).toggleClass("fancybox-button--fsexit",t))})}e(t).on({"onInit.fb":function(t,e){var i;if(!n)return void e.$refs.toolbar.find("[data-fancybox-fullscreen]").remove();e&&e.group[e.currIndex].opts.fullScreen?(i=e.$refs.container,i.on("click.fb-fullscreen","[data-fancybox-fullscreen]",function(t){t.stopPropagation(),t.preventDefault(),o.toggle()}),e.opts.fullScreen&&!0===e.opts.fullScreen.autoStart&&o.request(),e.FullScreen=o):e&&e.$refs.toolbar.find("[data-fancybox-fullscreen]").hide()},"afterKeydown.fb":function(t,e,n,o,i){e&&e.FullScreen&&70===i&&(o.preventDefault(),e.FullScreen.toggle())},"beforeClose.fb":function(t,e){e&&e.FullScreen&&e.$refs.container.hasClass("fancybox-is-fullscreen")&&o.exit()}})}(document,jQuery),function(t,e){"use strict";var n="fancybox-thumbs";e.fancybox.defaults=e.extend(!0,{btnTpl:{thumbs:''},thumbs:{autoStart:!1,hideOnClose:!0,parentEl:".fancybox-container",axis:"y"}},e.fancybox.defaults);var o=function(t){this.init(t)};e.extend(o.prototype,{$button:null,$grid:null,$list:null,isVisible:!1,isActive:!1,init:function(t){var e=this,n=t.group,o=0;e.instance=t,e.opts=n[t.currIndex].opts.thumbs,t.Thumbs=e,e.$button=t.$refs.toolbar.find("[data-fancybox-thumbs]");for(var i=0,a=n.length;i1));i++);o>1&&e.opts?(e.$button.removeAttr("style").on("click",function(){e.toggle()}),e.isActive=!0):e.$button.hide()},create:function(){var t,o=this,i=o.instance,a=o.opts.parentEl,s=[];o.$grid||(o.$grid=e('').appendTo(i.$refs.container.find(a).addBack().filter(a)),o.$grid.on("click","a",function(){i.jumpTo(e(this).attr("data-index"))})),o.$list||(o.$list=e('').appendTo(o.$grid)),e.each(i.group,function(e,n){t=n.thumb,t||"image"!==n.type||(t=n.src),s.push('
")}),o.$list[0].innerHTML=s.join(""),"x"===o.opts.axis&&o.$list.width(parseInt(o.$grid.css("padding-right"),10)+i.group.length*o.$list.children().eq(0).outerWidth(!0))},focus:function(t){var e,n,o=this,i=o.$list,a=o.$grid;o.instance.current&&(e=i.children().removeClass("fancybox-thumbs-active").filter('[data-index="'+o.instance.current.index+'"]').addClass("fancybox-thumbs-active"),n=e.position(),"y"===o.opts.axis&&(n.top<0||n.top>i.height()-e.outerHeight())?i.stop().animate({scrollTop:i.scrollTop()+n.top},t):"x"===o.opts.axis&&(n.left
a.scrollLeft()+(a.width()-e.outerWidth()))&&i.parent().stop().animate({scrollLeft:n.left},t))},update:function(){var t=this;t.instance.$refs.container.toggleClass("fancybox-show-thumbs",this.isVisible),t.isVisible?(t.$grid||t.create(),t.instance.trigger("onThumbsShow"),t.focus(0)):t.$grid&&t.instance.trigger("onThumbsHide"),t.instance.update()},hide:function(){this.isVisible=!1,this.update()},show:function(){this.isVisible=!0,this.update()},toggle:function(){this.isVisible=!this.isVisible,this.update()}}),e(t).on({"onInit.fb":function(t,e){var n;e&&!e.Thumbs&&(n=new o(e),n.isActive&&!0===n.opts.autoStart&&n.show())},"beforeShow.fb":function(t,e,n,o){var i=e&&e.Thumbs;i&&i.isVisible&&i.focus(o?0:250)},"afterKeydown.fb":function(t,e,n,o,i){var a=e&&e.Thumbs;a&&a.isActive&&71===i&&(o.preventDefault(),a.toggle())},"beforeClose.fb":function(t,e){var n=e&&e.Thumbs;n&&n.isVisible&&!1!==n.opts.hideOnClose&&n.$grid.hide()}})}(document,jQuery),function(t,e){"use strict";function n(t){var e={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/","`":"`","=":"="};return String(t).replace(/[&<>"'`=\/]/g,function(t){return e[t]})}e.extend(!0,e.fancybox.defaults,{btnTpl:{share:''},share:{url:function(t,e){return!t.currentHash&&"inline"!==e.type&&"html"!==e.type&&(e.origSrc||e.src)||window.location},
+tpl:''}}),e(t).on("click","[data-fancybox-share]",function(){var t,o,i=e.fancybox.getInstance(),a=i.current||null;a&&("function"===e.type(a.opts.share.url)&&(t=a.opts.share.url.apply(a,[i,a])),o=a.opts.share.tpl.replace(/\{\{media\}\}/g,"image"===a.type?encodeURIComponent(a.src):"").replace(/\{\{url\}\}/g,encodeURIComponent(t)).replace(/\{\{url_raw\}\}/g,n(t)).replace(/\{\{descr\}\}/g,i.$caption?encodeURIComponent(i.$caption.text()):""),e.fancybox.open({src:i.translate(i,o),type:"html",opts:{touch:!1,animationEffect:!1,afterLoad:function(t,e){i.$refs.container.one("beforeClose.fb",function(){t.close(null,0)}),e.$content.find(".fancybox-share__button").click(function(){return window.open(this.href,"Share","width=550, height=450"),!1})},mobile:{autoFocus:!1}}}))})}(document,jQuery),function(t,e,n){"use strict";function o(){var e=t.location.hash.substr(1),n=e.split("-"),o=n.length>1&&/^\+?\d+$/.test(n[n.length-1])?parseInt(n.pop(-1),10)||1:1,i=n.join("-");return{hash:e,index:o<1?1:o,gallery:i}}function i(t){""!==t.gallery&&n("[data-fancybox='"+n.escapeSelector(t.gallery)+"']").eq(t.index-1).focus().trigger("click.fb-start")}function a(t){var e,n;return!!t&&(e=t.current?t.current.opts:t.opts,""!==(n=e.hash||(e.$orig?e.$orig.data("fancybox")||e.$orig.data("fancybox-trigger"):""))&&n)}n.escapeSelector||(n.escapeSelector=function(t){return(t+"").replace(/([\0-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g,function(t,e){return e?"\0"===t?"�":t.slice(0,-1)+"\\"+t.charCodeAt(t.length-1).toString(16)+" ":"\\"+t})}),n(function(){!1!==n.fancybox.defaults.hash&&(n(e).on({"onInit.fb":function(t,e){var n,i;!1!==e.group[e.currIndex].opts.hash&&(n=o(),(i=a(e))&&n.gallery&&i==n.gallery&&(e.currIndex=n.index-1))},"beforeShow.fb":function(n,o,i,s){var r;i&&!1!==i.opts.hash&&(r=a(o))&&(o.currentHash=r+(o.group.length>1?"-"+(i.index+1):""),t.location.hash!=="#"+o.currentHash&&(s&&!o.origHash&&(o.origHash=t.location.hash),o.hashTimer&&clearTimeout(o.hashTimer),o.hashTimer=setTimeout(function(){"replaceState"in t.history?(t.history[s?"pushState":"replaceState"]({},e.title,t.location.pathname+t.location.search+"#"+o.currentHash),s&&(o.hasCreatedHistory=!0)):t.location.hash=o.currentHash,o.hashTimer=null},300)))},"beforeClose.fb":function(n,o,i){i&&!1!==i.opts.hash&&(clearTimeout(o.hashTimer),o.currentHash&&o.hasCreatedHistory?t.history.back():o.currentHash&&("replaceState"in t.history?t.history.replaceState({},e.title,t.location.pathname+t.location.search+(o.origHash||"")):t.location.hash=o.origHash),o.currentHash=null)}}),n(t).on("hashchange.fb",function(){var t=o(),e=null;n.each(n(".fancybox-container").get().reverse(),function(t,o){var i=n(o).data("FancyBox");if(i&&i.currentHash)return e=i,!1}),e?e.currentHash===t.gallery+"-"+t.index||1===t.index&&e.currentHash==t.gallery||(e.currentHash=null,e.close()):""!==t.gallery&&i(t)}),setTimeout(function(){n.fancybox.getInstance()||i(o())},50))})}(window,document,jQuery),function(t,e){"use strict";var n=(new Date).getTime();e(t).on({"onInit.fb":function(t,e,o){e.$refs.stage.on("mousewheel DOMMouseScroll wheel MozMousePixelScroll",function(t){var o=e.current,i=(new Date).getTime();e.group.length<2||!1===o.opts.wheel||"auto"===o.opts.wheel&&"image"!==o.type||(t.preventDefault(),t.stopPropagation(),o.$slide.hasClass("fancybox-animated")||(t=t.originalEvent||t,i-n<250||(n=i,e[(-t.deltaY||-t.deltaX||t.wheelDelta||-t.detail)<0?"next":"previous"]())))})}})}(document,jQuery);
\ No newline at end of file
diff --git a/dist/github-actions-sample-eslint-in-pull-request/index.html b/dist/github-actions-sample-eslint-in-pull-request/index.html
new file mode 100644
index 0000000..878e179
--- /dev/null
+++ b/dist/github-actions-sample-eslint-in-pull-request/index.html
@@ -0,0 +1,417 @@
+
+
+
+
+
+
+ 7. GitHub Actions - 在pull request中执行eslint检测的工作流例子 | TaoLiuJun's Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 7. GitHub Actions - 在pull request中执行eslint检测的工作流例子
+
+
+
+
+
+
+
+
原文链接:https://github.com/taoliujun/blog/issues/36
+
+
+
一个在pull request发起的时候执行eslint检测的workflow,点此查看完整代码,它实现的功能如下:
+
+- 在pull request创建、更新的时候执行。
+- 先回复一个评论,告诉用户正在运行。
+- 初始化仓库,并安装依赖,产生依赖缓存。
+- 运行eslint增量检查。
+- 运行typescript检查。
+- 运行jest检查。
+- 更新之前的评论,回复检查的结果。
+
+
运行截图:
+
+
为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:
+
+- workflow,工作流,可以理解为yml文件。
+- jobs,工作,一个workflow可以包含多个job,并行执行。
+- steps,作业,一个job可以包含多个step,串行执行。
+- action,操作,作业中具体的执行。
+
+
步骤
+
+
+
初始化workflow
在项目中新建文件.github/workflows/check-pull-request.yml
,内容如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| name: test check pull request run-name: 'check pull request #${{ github.event.pull_request.number }}' on: pull_request: types: [opened, synchronize, reopened] jobs: replyChecking: runs-on: ubuntu-latest steps: - run: echo 'replyChecking'
init: runs-on: ubuntu-latest steps: - run: echo 'init'
eslint: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'eslint'
typescript: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'typescript'
unitTest: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'unitTest'
replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - run: echo 'replyResult'
|
+
+
name和run-name
给workflow命名为check pull request
,它会出现在Actions页面的左侧菜单中。运行实例名为check pull request #44
,出现在右侧的运行列表中。如图:
+
+
run-name
中的${{ github.event.pull_request.number }}
是workflow的上下文,这里读取了上下文中的pr编号。
+
on
on
指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。
+
jobs
按照设想,需要定义几个job,分别是:
+
+- replyChecking:回复用户正在检查中
+- init:初始化仓库,缓存依赖项
+- eslint:运行eslint检查
+- typescript:运行typescript检查
+- unitTest:运行单元测试
+- replyResult:回复用户检查结果
+
+
jobs
是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。
+
其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了needs
管理它们的执行依赖关系。
+
runs-on
每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。
+
测试
发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:
+
+
+
+
replyChecking
在进行eslint检测之前,先在pr里回复checking
,并且带上拽酷炫的话。将replyChecking改成如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| replyChecking: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | **Checking...**
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
+
+
steps
每一步里name
、id
是可选的,name
在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。
+
Checkout
uses
表示使用一个action,名为actions/checkout@v4
,它用来拉取仓库。
+
+同其他编程语言一样,重复的action可以封装起来。action市场提供了很多。
+
+
with
属性指定了该action的输入参数,每个action的参数不尽相同。
+
ref
参数表示要拉取的分支,${{github.head_ref}}
也是一个上下文,表示当前pr的源分支。
+
Get Date time
这step还写了id
,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据id
读取到它的output
。
+
+output是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。
+
+
run
就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到$GITHUB_OUTPUT
中,键名为result
。
+
+$GITHUB_OUTPUT
是workflow注入到容器中的一个路径,用于存放output。
+
+
uses
使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。
+
+有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。
+
+
同理,该action也有with
属性,uniqueIdentifier
是回复评论的唯一标识,body
是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说${{steps.getDateTime.outputs.result}}
这个上下文表示获取getDateTime这个step中,键名为result
的值。
+
如果你不需要在内容里插入时间,那么上面的Get Date time
就可以省略了。
+
测试
因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:
+
+
+
+
这是一个封装的javascript action,用于对issue创建、更新唯一评论。
+
目录结构
创建目录./.github/actions/unique-comment
,最终目录结构如下:
+
1 2 3 4 5 6 7 8 9 10
| . ├── action.yml ├── config │ └── webpack.config.js ├── dist │ ├── index.js │ └── index.js.LICENSE.txt ├── package.json └── src └── index.js
|
+
+
action.yml
这是action的配置文件,必须存在,内容如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| name: unique-comment description: create or update a unique comment
runs: using: 'node20' main: './dist/index.js'
inputs: token: description: 'GitHub token' required: false default: ${{ github.token }} owner: description: 'Repository owner' required: false default: ${{ github.event.repository.owner.login }} repo: description: 'Repository name' required: false default: ${{ github.event.repository.name }} issue_number: description: 'Issue number' required: false default: ${{ github.event.number }} body: description: 'Comment body' required: false uniqueIdentifier: description: 'Unique identifier for comment' required: false default: 'unique-comment'
|
+
+
大部分属性不一一细讲了,都是简单的英文望文生义即可。
+
runs
表示运行在node20
环境下,入口文件为./dist/index.js
。
+
inputs
表示接受的参数,也就是之前提到的with
属性里要输入的参数。用required
表示是否必须传入,default
表示默认值。
+
src/index.js
为什么入口文件是dist/index.js
,而不是src/index.js
呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好src/index.js
,再打包就行。
+
该文件代码如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| const core = require('@actions/core'); const github = require('@actions/github');
const main = async () => { const token = core.getInput('token'); const owner = core.getInput('owner'); const repo = core.getInput('repo'); const issueNumber = core.getInput('issue_number'); const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`; const body = `${core.getInput('body')}\n\n${uniqueIdentifier}`;
core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);
const octokit = github.getOctokit(token);
const comments = await octokit.rest.issues.listComments({ owner, repo, issue_number: issueNumber, });
const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));
if (botComment) { core.info('update comment successfully.'); await octokit.rest.issues.updateComment({ owner, repo, comment_id: botComment.id, body, }); } else { core.info('create comment successfully.'); await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body, }); } };
try { main(); } catch (err) { core.setFailed(err.message); }
|
+
+
@actions/core
和@actions/github
是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。
+
main
函数的代码就是原生javascript,不一一解释了,主要通过uniqueIdentifier
来判断是否发布过评论,如果是,就更新评论,否则就创建评论。
+
+markdown语法[^uniqueIdentifier]
表示脚注,不会被渲染。
+
+
core.setFailed(err.message);
表示抛出退出代码。
+
config/webpack.config.js
打包用的,配置简单可用即可:
+
1 2 3 4 5 6 7 8 9
| module.exports = { mode: 'production', target: 'node20', entry: './src/index.js', output: { filename: 'index.js', clean: true, }, };
|
+
+
package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "name": "unique-comment", "version": "1.0.0", "private": true, "scripts": { "build": "webpack --config ./config/webpack.config.js" }, "dependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0" }, "devDependencies": { "webpack": "^5.89.0", "webpack-cli": "^5.1.4" } }
|
+
+
没啥好说的,列出了依赖项。和一个打包脚本。
+
测试
修改了src/index.js
得build
,然后push到github仓库。
+
记得将dist目录也提交到github仓库。
+
+
+
init
现在,开始搞正经的了。
+
先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要needs
这个job。
+
将init改成如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| init: runs-on: ubuntu-latest steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
|
+
+
相信经过对之前的job的了解,这里的配置就看起来很简单了。
+
Init pnpm
使用第三方action,安装pnpm@^8。
+
Init node
cache: 'pnpm'
指定缓存机制,它内部是利用了workflow的cache机制。
+
Install dependencies
安装依赖项,触发缓存。
+
+
+
eslint
将eslint改成如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
| eslint: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}} fetch-depth: 0
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run eslint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = ''; let diffFiles = '';
await exec.exec( `git diff --name-only origin/${{github.base_ref}}`, [], { // silent: true, // ignoreReturnCode: true, listeners: { stdout: (data) => { diffFiles += data.toString(); }, }, } );
const lintFiles = diffFiles.split(`\n`).filter((file) => { return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx') }).join(' ');
await exec.exec( // "pnpm run lint --format stylish", `pnpm eslint ${lintFiles}`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, non-eslint business errors.`; }
const errorMatch = output.match(/(\d+) errors?/); const warnMatch = output.match(/(\d+) warnings?/);
if (errorMatch && errorMatch?.[1] !== '0') { return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`; }
return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;
|
+
+
needs
使用needs
依赖init,可以使用到pnpm的缓存项,防止install太慢。
+
+因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。
+
+
outputs
job里的outputs,可以在依赖它的其他job中访问到。这里使用${{ steps.lint.outputs.result }}
去获取该job中lint这个step里的output里的result。
+
+output有job和step两个维度,注意区分。
+
+
Run eslint
它uses了actions/github-script@v7
,这是github官方提供的一个action,可以在with.script
里写js代码去执行,同时它会注入一些变量到script中去,见它的官方文档。
+
+对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。
+
+
result-encoding
是指定script返回的数据格式的,默认是json,这指定为string。
+
+为什么script里return了string,还要指定为string呢?
因为return 'hello'
在json encode后是'"hello"'
,而string encode后为'hello'
。
+
+
script里是原生的js代码了,里面的exec
是该action注入的变量,用来执行shell命令。
+
这段js代码做了两个事情,一是git diff
获取pr中改动的文件列表,二是eslint
检查这些增量文件,最后返回处理的结果。
+
fetch-depth
Init repo这个step里设置了fetch-depth: 0
,不然获取不到完整的git分支,具体看actions/checkout
的解释,涉及到git的知识不展开细说了。
+
steps.lint.outputs.result
steps.lint.outputs.result
为什么能拿到lint step里的output.result呢?因为actions/github-script
这个action内部将script的返回值,设置到$GITHUB_OUTPUT
里了,且键名为result
。
+
+
+
typescript
和eslint的配置大同小异,只是改了对检测结果的判断。
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| typescript: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run -r lint:ts`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, no business errors.`; }
const errorMatch = output.match(/error TS/g);
if (errorMatch) { return `:x: ${errorMatch?.length} errors`; }
return `:white_check_mark: ${'0 error'}`;
|
+
+
+
unitTest
和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| unitTest: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: | pnpm remove @nike/eslint-multi-formatter || true pnpm remove @nike/svg-packer || true pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run test`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
// why use outerr? https://github.com/jestjs/jest/issues/5064
const failMatch = outerr.match(/Test Suites: \d+ failed/);
if (failMatch) { return `:x: ${failMatch?.[0]}`; }
const errorMatch = outerr.match(/Jest: "global" coverage threshold for lines \([0-9\.]+%\) not met: [0-9\.]+%/);
if (errorMatch) { return `:x: ${errorMatch?.[0]}`; }
return `:white_check_mark: passed`;
|
+
+
+
replyResult
最后,将几个检测的结果进行汇总,回复到pr里就行了。
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | ## Eslint Check Result
${{needs.eslint.outputs.result}}
${{needs.typescript.outputs.result}}
${{needs.unitTest.outputs.result}}
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
+
+
和replyChecking差不多,在body里使用${{needs.eslint.outputs.result}}
去读取了eslint job的outputs。
+
测试
去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dist/index.html b/dist/index.html
new file mode 100644
index 0000000..0d1e32c
--- /dev/null
+++ b/dist/index.html
@@ -0,0 +1,476 @@
+
+
+
+
+
+
+ TaoLiuJun's Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
原文链接:https://github.com/taoliujun/blog/issues/36
+
+
+
一个在pull request发起的时候执行eslint检测的workflow,点此查看完整代码,它实现的功能如下:
+
+- 在pull request创建、更新的时候执行。
+- 先回复一个评论,告诉用户正在运行。
+- 初始化仓库,并安装依赖,产生依赖缓存。
+- 运行eslint增量检查。
+- 运行typescript检查。
+- 运行jest检查。
+- 更新之前的评论,回复检查的结果。
+
+
运行截图:
+
+
为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:
+
+- workflow,工作流,可以理解为yml文件。
+- jobs,工作,一个workflow可以包含多个job,并行执行。
+- steps,作业,一个job可以包含多个step,串行执行。
+- action,操作,作业中具体的执行。
+
+
步骤
+
+
+
初始化workflow
在项目中新建文件.github/workflows/check-pull-request.yml
,内容如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| name: test check pull request run-name: 'check pull request #${{ github.event.pull_request.number }}' on: pull_request: types: [opened, synchronize, reopened] jobs: replyChecking: runs-on: ubuntu-latest steps: - run: echo 'replyChecking'
init: runs-on: ubuntu-latest steps: - run: echo 'init'
eslint: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'eslint'
typescript: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'typescript'
unitTest: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'unitTest'
replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - run: echo 'replyResult'
|
+
+
name和run-name
给workflow命名为check pull request
,它会出现在Actions页面的左侧菜单中。运行实例名为check pull request #44
,出现在右侧的运行列表中。如图:
+
+
run-name
中的${{ github.event.pull_request.number }}
是workflow的上下文,这里读取了上下文中的pr编号。
+
on
on
指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。
+
jobs
按照设想,需要定义几个job,分别是:
+
+- replyChecking:回复用户正在检查中
+- init:初始化仓库,缓存依赖项
+- eslint:运行eslint检查
+- typescript:运行typescript检查
+- unitTest:运行单元测试
+- replyResult:回复用户检查结果
+
+
jobs
是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。
+
其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了needs
管理它们的执行依赖关系。
+
runs-on
每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。
+
测试
发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:
+
+
+
+
replyChecking
在进行eslint检测之前,先在pr里回复checking
,并且带上拽酷炫的话。将replyChecking改成如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| replyChecking: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | **Checking...**
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
+
+
steps
每一步里name
、id
是可选的,name
在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。
+
Checkout
uses
表示使用一个action,名为actions/checkout@v4
,它用来拉取仓库。
+
+同其他编程语言一样,重复的action可以封装起来。action市场提供了很多。
+
+
with
属性指定了该action的输入参数,每个action的参数不尽相同。
+
ref
参数表示要拉取的分支,${{github.head_ref}}
也是一个上下文,表示当前pr的源分支。
+
Get Date time
这step还写了id
,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据id
读取到它的output
。
+
+output是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。
+
+
run
就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到$GITHUB_OUTPUT
中,键名为result
。
+
+$GITHUB_OUTPUT
是workflow注入到容器中的一个路径,用于存放output。
+
+
uses
使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。
+
+有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。
+
+
同理,该action也有with
属性,uniqueIdentifier
是回复评论的唯一标识,body
是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说${{steps.getDateTime.outputs.result}}
这个上下文表示获取getDateTime这个step中,键名为result
的值。
+
如果你不需要在内容里插入时间,那么上面的Get Date time
就可以省略了。
+
测试
因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:
+
+
+
+
这是一个封装的javascript action,用于对issue创建、更新唯一评论。
+
目录结构
创建目录./.github/actions/unique-comment
,最终目录结构如下:
+
1 2 3 4 5 6 7 8 9 10
| . ├── action.yml ├── config │ └── webpack.config.js ├── dist │ ├── index.js │ └── index.js.LICENSE.txt ├── package.json └── src └── index.js
|
+
+
action.yml
这是action的配置文件,必须存在,内容如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| name: unique-comment description: create or update a unique comment
runs: using: 'node20' main: './dist/index.js'
inputs: token: description: 'GitHub token' required: false default: ${{ github.token }} owner: description: 'Repository owner' required: false default: ${{ github.event.repository.owner.login }} repo: description: 'Repository name' required: false default: ${{ github.event.repository.name }} issue_number: description: 'Issue number' required: false default: ${{ github.event.number }} body: description: 'Comment body' required: false uniqueIdentifier: description: 'Unique identifier for comment' required: false default: 'unique-comment'
|
+
+
大部分属性不一一细讲了,都是简单的英文望文生义即可。
+
runs
表示运行在node20
环境下,入口文件为./dist/index.js
。
+
inputs
表示接受的参数,也就是之前提到的with
属性里要输入的参数。用required
表示是否必须传入,default
表示默认值。
+
src/index.js
为什么入口文件是dist/index.js
,而不是src/index.js
呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好src/index.js
,再打包就行。
+
该文件代码如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| const core = require('@actions/core'); const github = require('@actions/github');
const main = async () => { const token = core.getInput('token'); const owner = core.getInput('owner'); const repo = core.getInput('repo'); const issueNumber = core.getInput('issue_number'); const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`; const body = `${core.getInput('body')}\n\n${uniqueIdentifier}`;
core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);
const octokit = github.getOctokit(token);
const comments = await octokit.rest.issues.listComments({ owner, repo, issue_number: issueNumber, });
const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));
if (botComment) { core.info('update comment successfully.'); await octokit.rest.issues.updateComment({ owner, repo, comment_id: botComment.id, body, }); } else { core.info('create comment successfully.'); await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body, }); } };
try { main(); } catch (err) { core.setFailed(err.message); }
|
+
+
@actions/core
和@actions/github
是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。
+
main
函数的代码就是原生javascript,不一一解释了,主要通过uniqueIdentifier
来判断是否发布过评论,如果是,就更新评论,否则就创建评论。
+
+markdown语法[^uniqueIdentifier]
表示脚注,不会被渲染。
+
+
core.setFailed(err.message);
表示抛出退出代码。
+
config/webpack.config.js
打包用的,配置简单可用即可:
+
1 2 3 4 5 6 7 8 9
| module.exports = { mode: 'production', target: 'node20', entry: './src/index.js', output: { filename: 'index.js', clean: true, }, };
|
+
+
package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "name": "unique-comment", "version": "1.0.0", "private": true, "scripts": { "build": "webpack --config ./config/webpack.config.js" }, "dependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0" }, "devDependencies": { "webpack": "^5.89.0", "webpack-cli": "^5.1.4" } }
|
+
+
没啥好说的,列出了依赖项。和一个打包脚本。
+
测试
修改了src/index.js
得build
,然后push到github仓库。
+
记得将dist目录也提交到github仓库。
+
+
+
init
现在,开始搞正经的了。
+
先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要needs
这个job。
+
将init改成如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| init: runs-on: ubuntu-latest steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
|
+
+
相信经过对之前的job的了解,这里的配置就看起来很简单了。
+
Init pnpm
使用第三方action,安装pnpm@^8。
+
Init node
cache: 'pnpm'
指定缓存机制,它内部是利用了workflow的cache机制。
+
Install dependencies
安装依赖项,触发缓存。
+
+
+
eslint
将eslint改成如下:
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
| eslint: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}} fetch-depth: 0
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run eslint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = ''; let diffFiles = '';
await exec.exec( `git diff --name-only origin/${{github.base_ref}}`, [], { // silent: true, // ignoreReturnCode: true, listeners: { stdout: (data) => { diffFiles += data.toString(); }, }, } );
const lintFiles = diffFiles.split(`\n`).filter((file) => { return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx') }).join(' ');
await exec.exec( // "pnpm run lint --format stylish", `pnpm eslint ${lintFiles}`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, non-eslint business errors.`; }
const errorMatch = output.match(/(\d+) errors?/); const warnMatch = output.match(/(\d+) warnings?/);
if (errorMatch && errorMatch?.[1] !== '0') { return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`; }
return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;
|
+
+
needs
使用needs
依赖init,可以使用到pnpm的缓存项,防止install太慢。
+
+因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。
+
+
outputs
job里的outputs,可以在依赖它的其他job中访问到。这里使用${{ steps.lint.outputs.result }}
去获取该job中lint这个step里的output里的result。
+
+output有job和step两个维度,注意区分。
+
+
Run eslint
它uses了actions/github-script@v7
,这是github官方提供的一个action,可以在with.script
里写js代码去执行,同时它会注入一些变量到script中去,见它的官方文档。
+
+对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。
+
+
result-encoding
是指定script返回的数据格式的,默认是json,这指定为string。
+
+为什么script里return了string,还要指定为string呢?
因为return 'hello'
在json encode后是'"hello"'
,而string encode后为'hello'
。
+
+
script里是原生的js代码了,里面的exec
是该action注入的变量,用来执行shell命令。
+
这段js代码做了两个事情,一是git diff
获取pr中改动的文件列表,二是eslint
检查这些增量文件,最后返回处理的结果。
+
fetch-depth
Init repo这个step里设置了fetch-depth: 0
,不然获取不到完整的git分支,具体看actions/checkout
的解释,涉及到git的知识不展开细说了。
+
steps.lint.outputs.result
steps.lint.outputs.result
为什么能拿到lint step里的output.result呢?因为actions/github-script
这个action内部将script的返回值,设置到$GITHUB_OUTPUT
里了,且键名为result
。
+
+
+
typescript
和eslint的配置大同小异,只是改了对检测结果的判断。
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| typescript: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run -r lint:ts`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, no business errors.`; }
const errorMatch = output.match(/error TS/g);
if (errorMatch) { return `:x: ${errorMatch?.length} errors`; }
return `:white_check_mark: ${'0 error'}`;
|
+
+
+
unitTest
和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| unitTest: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: | pnpm remove @nike/eslint-multi-formatter || true pnpm remove @nike/svg-packer || true pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run test`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
// why use outerr? https://github.com/jestjs/jest/issues/5064
const failMatch = outerr.match(/Test Suites: \d+ failed/);
if (failMatch) { return `:x: ${failMatch?.[0]}`; }
const errorMatch = outerr.match(/Jest: "global" coverage threshold for lines \([0-9\.]+%\) not met: [0-9\.]+%/);
if (errorMatch) { return `:x: ${errorMatch?.[0]}`; }
return `:white_check_mark: passed`;
|
+
+
+
replyResult
最后,将几个检测的结果进行汇总,回复到pr里就行了。
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | ## Eslint Check Result
${{needs.eslint.outputs.result}}
${{needs.typescript.outputs.result}}
${{needs.unitTest.outputs.result}}
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
+
+
和replyChecking差不多,在body里使用${{needs.eslint.outputs.result}}
去读取了eslint job的outputs。
+
测试
去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
原文链接:https://github.com/taoliujun/blog/issues/35
+
+
+
官方文档:https://docs.pmnd.rs/zustand
+
如何使用
Zustand 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:
+
+
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { create } from 'zustand';
interface State { loading: boolean; disabled: boolean; setLoadingByAge: (value: number) => void; }
export const useFormStateStore = create<State>((set) => ({ loading: false, disabled: false, setLoadingByAge: (value) => { set({ loading: value > 10 }); }, }));
|
+
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| import { useState, type FC } from 'react'; import { useFormStateStore } from './useFormStateStore'; import { Button } from '@/components/Button';
const Loading: FC = () => { const { loading } = useFormStateStore(); return <div>loading: {String(loading)}</div>; };
const Disabled: FC = () => { const { disabled } = useFormStateStore(); return <div>disabled: {String(disabled)}</div>; };
const Main: FC = () => { const { setLoadingByAge } = useFormStateStore(); const [age, setAge] = useState(0);
return ( <div> <Loading /> <br /> <Disabled /> <br /> <Button onClick={() => { useFormStateStore.setState({ loading: true, }); }} > set loading true </Button> <Button onClick={() => { useFormStateStore.setState({ loading: false, }); }} > set loading false </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: true, }); }} > set disabled true </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: false, }); }} > set disabled false </Button> <br /> <input type="number" value={age} onChange={(e) => { setAge(Number(e.target.value)); }} /> <br /> <Button onClick={() => { setLoadingByAge(age); }} > set loading by age </Button> </div> ); };
export default Main;
|
+
+
在useFormStateStore.ts
中定义了状态,然后在app.tsx
中使用,就是这么简单粗暴!这里有几点介绍下:
+
+
Zustand
使用非常简单,API也很少,它的原理是使用了Proxy
,所以它的性能非常好。
+
相比Redux
相比Redux,Zustand的代码非常简单明了,不需要使用connect
、mapStateToProps
、mapDispatchToProps
这些方法。
+
相比React Context
React Context需要一个Provider
包裹组件以传递状态,需要一个useContext
使用状态,光从层级上就让人绕起来了。而Zustand只需要一个create
方法,就可以使用了,且状态是全局的,不需要传递。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dist/js/jquery-3.6.4.min.js b/dist/js/jquery-3.6.4.min.js
new file mode 100644
index 0000000..0de648e
--- /dev/null
+++ b/dist/js/jquery-3.6.4.min.js
@@ -0,0 +1,2 @@
+/*! jQuery v3.6.4 | (c) OpenJS Foundation and other contributors | jquery.org/license */
+!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.4",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.cssHas=ce(function(){try{return C.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),d.cssHas||y.push(":has"),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType&&e.documentElement||e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,""],col:[2,""],tr:[2,""],td:[3,""],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0',
+ '',
+ '',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ ' '
+ ].join('');
+
+ var box = $(html);
+
+ $('body').append(box);
+ }
+
+ $('.article-share-box.on').hide();
+
+ box.css({
+ top: offset.top + 25,
+ left: offset.left
+ }).addClass('on');
+ }).on('click', '.article-share-box', function(e){
+ e.stopPropagation();
+ }).on('click', '.article-share-box-input', function(){
+ $(this).select();
+ }).on('click', '.article-share-box-link', function(e){
+ e.preventDefault();
+ e.stopPropagation();
+
+ window.open(this.href, 'article-share-box-window-' + Date.now(), 'width=500,height=450');
+ });
+
+ // Caption
+ $('.article-entry').each(function(i){
+ $(this).find('img').each(function(){
+ if ($(this).parent().hasClass('fancybox') || $(this).parent().is('a')) return;
+
+ var alt = this.alt;
+
+ if (alt) $(this).after('' + alt + '');
+
+ $(this).wrap('')
+ });
+
+ $(this).find('.fancybox').each(function(){
+ $(this).attr('rel', 'article' + i);
+ });
+ });
+
+ if ($.fancybox){
+ $('.fancybox').fancybox();
+ }
+
+ // Mobile nav
+ var $container = $('#container'),
+ isMobileNavAnim = false,
+ mobileNavAnimDuration = 200;
+
+ var startMobileNavAnim = function(){
+ isMobileNavAnim = true;
+ };
+
+ var stopMobileNavAnim = function(){
+ setTimeout(function(){
+ isMobileNavAnim = false;
+ }, mobileNavAnimDuration);
+ }
+
+ $('#main-nav-toggle').on('click', function(){
+ if (isMobileNavAnim) return;
+
+ startMobileNavAnim();
+ $container.toggleClass('mobile-nav-on');
+ stopMobileNavAnim();
+ });
+
+ $('#wrap').on('click', function(){
+ if (isMobileNavAnim || !$container.hasClass('mobile-nav-on')) return;
+
+ $container.removeClass('mobile-nav-on');
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/dist/react-zustand/index.html b/dist/react-zustand/index.html
new file mode 100644
index 0000000..3f06139
--- /dev/null
+++ b/dist/react-zustand/index.html
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+ React公共状态利器 - Zustand | TaoLiuJun's Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ React公共状态利器 - Zustand
+
+
+
+
+
+
+
+
原文链接:https://github.com/taoliujun/blog/issues/35
+
+
+
官方文档:https://docs.pmnd.rs/zustand
+
如何使用
Zustand 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:
+
+
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { create } from 'zustand';
interface State { loading: boolean; disabled: boolean; setLoadingByAge: (value: number) => void; }
export const useFormStateStore = create<State>((set) => ({ loading: false, disabled: false, setLoadingByAge: (value) => { set({ loading: value > 10 }); }, }));
|
+
+
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| import { useState, type FC } from 'react'; import { useFormStateStore } from './useFormStateStore'; import { Button } from '@/components/Button';
const Loading: FC = () => { const { loading } = useFormStateStore(); return <div>loading: {String(loading)}</div>; };
const Disabled: FC = () => { const { disabled } = useFormStateStore(); return <div>disabled: {String(disabled)}</div>; };
const Main: FC = () => { const { setLoadingByAge } = useFormStateStore(); const [age, setAge] = useState(0);
return ( <div> <Loading /> <br /> <Disabled /> <br /> <Button onClick={() => { useFormStateStore.setState({ loading: true, }); }} > set loading true </Button> <Button onClick={() => { useFormStateStore.setState({ loading: false, }); }} > set loading false </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: true, }); }} > set disabled true </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: false, }); }} > set disabled false </Button> <br /> <input type="number" value={age} onChange={(e) => { setAge(Number(e.target.value)); }} /> <br /> <Button onClick={() => { setLoadingByAge(age); }} > set loading by age </Button> </div> ); };
export default Main;
|
+
+
在useFormStateStore.ts
中定义了状态,然后在app.tsx
中使用,就是这么简单粗暴!这里有几点介绍下:
+
+
Zustand
使用非常简单,API也很少,它的原理是使用了Proxy
,所以它的性能非常好。
+
相比Redux
相比Redux,Zustand的代码非常简单明了,不需要使用connect
、mapStateToProps
、mapDispatchToProps
这些方法。
+
相比React Context
React Context需要一个Provider
包裹组件以传递状态,需要一个useContext
使用状态,光从层级上就让人绕起来了。而Zustand只需要一个create
方法,就可以使用了,且状态是全局的,不需要传递。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dist/tags/github-actions/index.html b/dist/tags/github-actions/index.html
new file mode 100644
index 0000000..22df6aa
--- /dev/null
+++ b/dist/tags/github-actions/index.html
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+ Tag: github actions | TaoLiuJun's Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dist/tags/react-store/index.html b/dist/tags/react-store/index.html
new file mode 100644
index 0000000..25e9a7f
--- /dev/null
+++ b/dist/tags/react-store/index.html
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+ Tag: react store | TaoLiuJun's Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dist/tags/zustand/index.html b/dist/tags/zustand/index.html
new file mode 100644
index 0000000..79191ab
--- /dev/null
+++ b/dist/tags/zustand/index.html
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+ Tag: zustand | TaoLiuJun's Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/db.json b/src/db.json
new file mode 100644
index 0000000..33060ce
--- /dev/null
+++ b/src/db.json
@@ -0,0 +1 @@
+{"meta":{"version":1,"warehouse":"4.0.2"},"models":{"Asset":[{"_id":"node_modules/hexo-theme-landscape/source/css/style.styl","path":"css/style.styl","modified":1,"renderable":1},{"_id":"node_modules/hexo-theme-landscape/source/fancybox/jquery.fancybox.min.css","path":"fancybox/jquery.fancybox.min.css","modified":1,"renderable":1},{"_id":"node_modules/hexo-theme-landscape/source/fancybox/jquery.fancybox.min.js","path":"fancybox/jquery.fancybox.min.js","modified":1,"renderable":1},{"_id":"node_modules/hexo-theme-landscape/source/js/jquery-3.6.4.min.js","path":"js/jquery-3.6.4.min.js","modified":1,"renderable":1},{"_id":"node_modules/hexo-theme-landscape/source/js/script.js","path":"js/script.js","modified":1,"renderable":1},{"_id":"node_modules/hexo-theme-landscape/source/css/images/banner.jpg","path":"css/images/banner.jpg","modified":1,"renderable":1}],"Cache":[{"_id":"source/_posts/github-actions-sample-eslint-in-pull-request.md","hash":"d8d26a081af1360062c354f884a2c6de84872710","modified":1704273216691},{"_id":"source/_posts/react-zustand.md","hash":"346576eb884e3157eb3e630225a314d9f0cd36fe","modified":1704273216695},{"_id":"node_modules/hexo-theme-landscape/LICENSE","hash":"c480fce396b23997ee23cc535518ffaaf7f458f8","modified":1704270132837},{"_id":"node_modules/hexo-theme-landscape/README.md","hash":"1a9b279e6dd29fd19245f913f0c4a316ffaa62db","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/_config.yml","hash":"b608c1f1322760dce9805285a602a95832730a2e","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/package.json","hash":"4bf95d52f77edf811f23f6d264a7493311a8d078","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/languages/de-DE.yml","hash":"d29d1c4256b7ed9df42f511c2ff0a23ad5fd6c1f","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/de.yml","hash":"3ebf0775abbee928c8d7bda943c191d166ded0d3","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/default.yml","hash":"ea5e6aee4cb14510793ac4593a3bddffe23e530c","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/en-GB.yml","hash":"ea5e6aee4cb14510793ac4593a3bddffe23e530c","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/en-US.yml","hash":"ea5e6aee4cb14510793ac4593a3bddffe23e530c","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/en.yml","hash":"3083f319b352d21d80fc5e20113ddf27889c9d11","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/es-ES.yml","hash":"7008a8fc91f18d2a735864817b8ebda30c7a2c66","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/es.yml","hash":"76edb1171b86532ef12cfd15f5f2c1ac3949f061","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/fr-FR.yml","hash":"8d09dbdab00a30a2870b56f7c0a7ca7deafa7b88","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/fr.yml","hash":"415e1c580ced8e4ce20b3b0aeedc3610341c76fb","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/hu-HU.yml","hash":"712d18664898fa21ba38d4973e90ef41a324ea25","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/hu.yml","hash":"284d557130bf54a74e7dcef9d42096130e4d9550","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/it-IT.yml","hash":"2cb6dc2fab9bd2dbe1c8bb869a9e8bf85a564fdd","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/it.yml","hash":"89b7d91306b2c1a0f3ac023b657bf974f798a1e8","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/ja-JP.yml","hash":"08481267e0c112e1f6855620f2837ec4c4a98bbd","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/ja.yml","hash":"a73e1b9c80fd6e930e2628b393bfe3fb716a21a9","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/ko-KR.yml","hash":"19209ad8f9d4057e8df808937f950eb265e1db69","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/ko.yml","hash":"881d6a0a101706e0452af81c580218e0bfddd9cf","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/mn-MN.yml","hash":"b9e5f3e7c0c2f779cf2cfded6db847b5941637ca","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/mn.yml","hash":"2e7523951072a9403ead3840ad823edd1084c116","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/nl-NL.yml","hash":"5ebbc30021f05d99938f96dfff280392df7f91f0","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/nl.yml","hash":"12ed59faba1fc4e8cdd1d42ab55ef518dde8039c","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/no.yml","hash":"965a171e70347215ec726952e63f5b47930931ef","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/pt-PT.yml","hash":"0f852b6b228e6ea59aa3540574bb89b233f2a098","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/pt.yml","hash":"57d07b75d434fbfc33b0ddb543021cb5f53318a8","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/ru-RU.yml","hash":"360d11a28bb768afb1dd15f63fa7fd3a8cc547ee","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/ru.yml","hash":"4fda301bbd8b39f2c714e2c934eccc4b27c0a2b0","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/th-TH.yml","hash":"ebfdba9bc4842c829473c1e6e4544344f182724d","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/th.yml","hash":"84a55b00aa01f03982be294e43c33a20e6d32862","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/tr.yml","hash":"a1cdbfa17682d7a971de8ab8588bf57c74224b5b","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/zh-CN.yml","hash":"1efd95774f401c80193eac6ee3f1794bfe93dc5a","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/languages/zh-TW.yml","hash":"53ce3000c5f767759c7d2c4efcaa9049788599c3","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/layout/archive.ejs","hash":"2703b07cc8ac64ae46d1d263f4653013c7e1666b","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/layout/category.ejs","hash":"765426a9c8236828dc34759e604cc2c52292835a","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/index.ejs","hash":"aa1b4456907bdb43e629be3931547e2d29ac58c8","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/layout.ejs","hash":"0d1765036e4874500e68256fedb7470e96eeb6ee","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/page.ejs","hash":"7d80e4e36b14d30a7cd2ac1f61376d9ebf264e8b","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/post.ejs","hash":"7d80e4e36b14d30a7cd2ac1f61376d9ebf264e8b","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/tag.ejs","hash":"eaa7b4ccb2ca7befb90142e4e68995fb1ea68b2e","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/scripts/fancybox.js","hash":"c857d7a5e4a5d71c743a009c5932bf84229db428","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/after-footer.ejs","hash":"377d257d5d16e0158a4405c72401517b074fd7ff","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/archive-post.ejs","hash":"c7a71425a946d05414c069ec91811b5c09a92c47","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/archive.ejs","hash":"7cb70a7a54f8c7ae49b10d1f37c0a9b74eab8826","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/article.ejs","hash":"56597e951203dd662a6d2c817c7c4f1c920d4a25","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/footer.ejs","hash":"3656eb692254346671abc03cb3ba1459829e0dce","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/gauges-analytics.ejs","hash":"21a1e2a3907d1a3dad1cd0ab855fe6735f233c74","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/google-analytics.ejs","hash":"2ea7442ea1e1a8ab4e41e26c563f58413b59a3d0","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/head.ejs","hash":"f05bced793b0314d4f2ef0c993b3a51d0b7d203a","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/header.ejs","hash":"6a5033d189554c9a6d42e2ef7952ae5c9742648e","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/mobile-nav.ejs","hash":"e952a532dfc583930a666b9d4479c32d4a84b44e","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/sidebar.ejs","hash":"930da35cc2d447a92e5ee8f835735e6fd2232469","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_widget/archive.ejs","hash":"beb4a86fcc82a9bdda9289b59db5a1988918bec3","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/layout/_widget/category.ejs","hash":"dd1e5af3c6af3f5d6c85dfd5ca1766faed6a0b05","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_widget/recent_posts.ejs","hash":"60c4b012dcc656438ff59997e60367e5a21ab746","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_widget/tag.ejs","hash":"2de380865df9ab5f577f7d3bcadf44261eb5faae","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_widget/tagcloud.ejs","hash":"b4a2079101643f63993dcdb32925c9b071763b46","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_extend.styl","hash":"222fbe6d222531d61c1ef0f868c90f747b1c2ced","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_variables.styl","hash":"ca28281423ae57d76b6c1eb91cd845fd4e518bd6","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/style.styl","hash":"e55a1d92954ed20f6887f92dc727bb995a010a43","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/source/fancybox/jquery.fancybox.min.css","hash":"1be9b79be02a1cfc5d96c4a5e0feb8f472babd95","modified":1704270133201},{"_id":"node_modules/hexo-theme-landscape/source/js/script.js","hash":"49773efcb2221bbdf2d86f3f5c5ff2d841b528cc","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/post/category.ejs","hash":"c6bcd0e04271ffca81da25bcff5adf3d46f02fc0","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/post/date.ejs","hash":"f1458584b679545830b75bef2526e2f3eb931045","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/post/gallery.ejs","hash":"3d9d81a3c693ff2378ef06ddb6810254e509de5b","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/post/nav.ejs","hash":"16a904de7bceccbb36b4267565f2215704db2880","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/post/tag.ejs","hash":"2fcb0bf9c8847a644167a27824c9bb19ac74dd14","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/layout/_partial/post/title.ejs","hash":"4d7e62574ddf46de9b41605fe3140d77b5ddb26d","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/archive.styl","hash":"db15f5677dc68f1730e82190bab69c24611ca292","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/article.styl","hash":"2d1f6f79ebf9cb55ebdb3865a2474437eb2b37c6","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/comment.styl","hash":"79d280d8d203abb3bd933ca9b8e38c78ec684987","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/footer.styl","hash":"e35a060b8512031048919709a8e7b1ec0e40bc1b","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/header.styl","hash":"268d2989acb06e2ddd06cc36a6918c6cd865476b","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/highlight.styl","hash":"9cc3b2927d814f2f6e8e188f9d3657b94f4c6ef3","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/mobile.styl","hash":"a399cf9e1e1cec3e4269066e2948d7ae5854d745","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/sidebar-aside.styl","hash":"890349df5145abf46ce7712010c89237900b3713","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/sidebar-bottom.styl","hash":"8fd4f30d319542babfd31f087ddbac550f000a8a","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/source/css/_partial/sidebar.styl","hash":"404ec059dc674a48b9ab89cd83f258dec4dcb24d","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/source/css/_util/grid.styl","hash":"0bf55ee5d09f193e249083602ac5fcdb1e571aed","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/_util/mixin.styl","hash":"44f32767d9fd3c1c08a60d91f181ee53c8f0dbb3","modified":1704270133209},{"_id":"node_modules/hexo-theme-landscape/source/fancybox/jquery.fancybox.min.js","hash":"6181412e73966696d08e1e5b1243a572d0f22ba6","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/js/jquery-3.6.4.min.js","hash":"eda46747c71d38a880bee44f9a439c3858bb8f99","modified":1704270133205},{"_id":"node_modules/hexo-theme-landscape/source/css/images/banner.jpg","hash":"f44aa591089fcb3ec79770a1e102fd3289a7c6a6","modified":1704270133205},{"_id":"public/github-actions-sample-eslint-in-pull-request/index.html","hash":"c127049c6f7716fd05343ec91d13791ececa161a","modified":1704273217808},{"_id":"public/react-zustand/index.html","hash":"0865fe2a8dcebbe8380ac5894b63ce618a7ce525","modified":1704273217808},{"_id":"public/archives/index.html","hash":"a3e5ef891425cf7f9a74cc481391db0cef6df832","modified":1704273217808},{"_id":"public/archives/2023/index.html","hash":"af6009a0e1abd053eca5b1825440974d94e3c0dc","modified":1704273217808},{"_id":"public/archives/2023/12/index.html","hash":"4942d8918efe645a56c8a18d35ac700a090708ad","modified":1704273217808},{"_id":"public/categories/工程化/index.html","hash":"9b6fc65ca27999579f2879a6e2d6781bd182f126","modified":1704273217808},{"_id":"public/categories/React/index.html","hash":"7bbde442712865bf309ed38411f8c4d8a3059329","modified":1704273217808},{"_id":"public/index.html","hash":"9bd1ab1740439ca15fa6443dc054be964fb00166","modified":1704273217808},{"_id":"public/tags/github-actions/index.html","hash":"6d466024facde7a014b9a68f733dc9b10bb3e1e0","modified":1704273217808},{"_id":"public/tags/zustand/index.html","hash":"4f999b262ec480f48c4129a3d6281685629f0cdf","modified":1704273217808},{"_id":"public/tags/react-store/index.html","hash":"46b2eb630e9d320ef78f339d44623736ac8cc72b","modified":1704273217808},{"_id":"public/css/style.css","hash":"ddb3792605d744ab3d9f0a649c82b62e9b16daa6","modified":1704273217808},{"_id":"public/fancybox/jquery.fancybox.min.css","hash":"1be9b79be02a1cfc5d96c4a5e0feb8f472babd95","modified":1704273217808},{"_id":"public/fancybox/jquery.fancybox.min.js","hash":"6181412e73966696d08e1e5b1243a572d0f22ba6","modified":1704273217808},{"_id":"public/js/jquery-3.6.4.min.js","hash":"eda46747c71d38a880bee44f9a439c3858bb8f99","modified":1704273217808},{"_id":"public/js/script.js","hash":"49773efcb2221bbdf2d86f3f5c5ff2d841b528cc","modified":1704273217808},{"_id":"public/css/images/banner.jpg","hash":"f44aa591089fcb3ec79770a1e102fd3289a7c6a6","modified":1704273217808}],"Category":[{"name":"工程化","_id":"clqxk9r670003joorffjb03fe"},{"name":"React","_id":"clqxk9r680005joor13xp87sb"}],"Data":[],"Page":[],"Post":[{"title":"7. GitHub Actions - 在pull request中执行eslint检测的工作流例子","date":"2023-12-28T19:56:49.000Z","url":"github-actions-sample-eslint-in-pull-request","_content":"\n\n原文链接:[https://github.com/taoliujun/blog/issues/36](https://github.com/taoliujun/blog/issues/36)\n\n\n\n一个在pull request发起的时候执行eslint检测的workflow,[点此查看完整代码](https://github.com/taoliujun/npm-packages/blob/master/.github/workflows/check-pull-request.yml),它实现的功能如下:\n\n- 在pull request创建、更新的时候执行。\n- 先回复一个评论,告诉用户正在运行。\n- 初始化仓库,并安装依赖,产生依赖缓存。\n- 运行eslint增量检查。\n- 运行typescript检查。\n- 运行jest检查。\n- 更新之前的评论,回复检查的结果。\n\n运行截图:\n\n![Alt text](https://github.com/taoliujun/blog/assets/5689134/09c86bc1-ada1-41c3-9f8f-7e6c46f8204e)\n\n为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:\n\n* workflow,工作流,可以理解为yml文件。\n* jobs,工作,一个workflow可以包含多个job,并行执行。\n* steps,作业,一个job可以包含多个step,串行执行。\n* action,操作,作业中具体的执行。\n\n## 步骤\n\n- [初始化workflow](https://github.com/taoliujun/blog/issues/36#issuecomment-1871790603)\n- [reply checking](https://github.com/taoliujun/blog/issues/36#issuecomment-1871806576)\n- [./.github/actions/unique-comment](https://github.com/taoliujun/blog/issues/36#issuecomment-1871818126)\n- [init](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862632)\n- [eslint](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862779)\n- [typescript](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862850)\n- [unit test](https://github.com/taoliujun/blog/issues/36#issuecomment-1871863037)\n- [reply result](https://github.com/taoliujun/blog/issues/36#issuecomment-1871863117)\n\n\n\n# 初始化workflow\n\n在项目中新建文件`.github/workflows/check-pull-request.yml`,内容如下:\n\n```yaml\nname: test check pull request\nrun-name: 'check pull request #${{ github.event.pull_request.number }}'\non:\n pull_request:\n types: [opened, synchronize, reopened]\njobs:\n replyChecking:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'replyChecking'\n\n init:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'init'\n\n eslint:\n runs-on: ubuntu-latest\n needs: [init]\n steps:\n - run: echo 'eslint'\n\n typescript:\n runs-on: ubuntu-latest\n needs: [init]\n steps:\n - run: echo 'typescript'\n\n unitTest:\n runs-on: ubuntu-latest\n needs: [init]\n steps:\n - run: echo 'unitTest'\n\n replyResult:\n runs-on: ubuntu-latest\n needs: [replyChecking, eslint, typescript, unitTest]\n steps:\n - run: echo 'replyResult'\n```\n\n## name和run-name\n\n给workflow命名为`check pull request`,它会出现在Actions页面的左侧菜单中。运行实例名为`check pull request #44`,出现在右侧的运行列表中。如图:\n\n![](https://github.com/taoliujun/blog/assets/5689134/c1371ff2-8fc3-4e5b-8b60-3c572419938b)\n\n`run-name`中的`${{ github.event.pull_request.number }}`是workflow的上下文,这里读取了上下文中的pr编号。\n\n## on\n\n`on`指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。\n\n## jobs\n\n按照设想,需要定义几个job,分别是:\n\n- replyChecking:回复用户正在检查中\n- init:初始化仓库,缓存依赖项\n- eslint:运行eslint检查\n- typescript:运行typescript检查\n- unitTest:运行单元测试\n- replyResult:回复用户检查结果\n\n`jobs`是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。\n\n其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了`needs`管理它们的执行依赖关系。\n\n### runs-on\n\n每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。\n\n## 测试\n\n发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:\n\n![](https://github.com/taoliujun/blog/assets/5689134/09c86bc1-ada1-41c3-9f8f-7e6c46f8204e)\n\n\n\n# replyChecking\n\n在进行eslint检测之前,先在pr里回复`checking`,并且带上拽酷炫的话。将replyChecking改成如下:\n\n```yaml\nreplyChecking:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n - name: Get date time\n id: getDateTime\n run: echo \"result=$(TZ=Asia/Shanghai date)\" >> \"$GITHUB_OUTPUT\"\n - name: Create or update a comment\n uses: ./.github/actions/unique-comment\n with:\n uniqueIdentifier: ${{ github.workflow }}\n body: |\n **Checking...**\n\n ---\n\n Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.\n```\n\n`steps`每一步里`name`、`id`是可选的,`name`在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。\n\n## Checkout\n\n`uses`表示使用一个action,名为`actions/checkout@v4`,它用来拉取仓库。\n\n> 同其他编程语言一样,重复的action可以封装起来。[action市场](https://github.com/marketplace?type=actions)提供了很多。\n\n`with`属性指定了该action的输入参数,每个action的参数不尽相同。\n\n`ref`参数表示要拉取的分支,`${{github.head_ref}}`也是一个上下文,表示当前pr的源分支。\n\n\n## Get Date time\n\n这step还写了`id`,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据`id`读取到它的`output`。\n\n> **output**是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。\n\n`run`就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到`$GITHUB_OUTPUT`中,键名为`result`。\n\n> `$GITHUB_OUTPUT`是workflow注入到容器中的一个路径,用于存放output。\n\n## Create or update a comment\n\n`uses`使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。\n\n> 有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。\n\n同理,该action也有`with`属性,`uniqueIdentifier`是回复评论的唯一标识,`body`是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说`${{steps.getDateTime.outputs.result}}`这个上下文表示获取getDateTime这个step中,键名为`result`的值。\n\n如果你不需要在内容里插入时间,那么上面的`Get Date time`就可以省略了。\n\n## 测试\n\n因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:\n\n![](https://github.com/taoliujun/blog/assets/5689134/42396a84-b798-4f4e-9f39-5bf92a8acb15)\n\n\n# ./.github/actions/unique-comment\n\n这是一个封装的javascript action,用于对issue创建、更新唯一评论。\n\n## 目录结构\n\n创建目录`./.github/actions/unique-comment`,最终目录结构如下:\n\n```bash\n.\n├── action.yml\n├── config\n│ └── webpack.config.js\n├── dist\n│ ├── index.js\n│ └── index.js.LICENSE.txt\n├── package.json\n└── src\n └── index.js\n```\n\n## action.yml\n\n这是action的配置文件,必须存在,内容如下:\n\n```yaml\nname: unique-comment\ndescription: create or update a unique comment\n\nruns:\n using: 'node20'\n main: './dist/index.js'\n\ninputs:\n token:\n description: 'GitHub token'\n required: false\n default: ${{ github.token }}\n owner:\n description: 'Repository owner'\n required: false\n default: ${{ github.event.repository.owner.login }}\n repo:\n description: 'Repository name'\n required: false\n default: ${{ github.event.repository.name }}\n issue_number:\n description: 'Issue number'\n required: false\n default: ${{ github.event.number }}\n body:\n description: 'Comment body'\n required: false\n uniqueIdentifier:\n description: 'Unique identifier for comment'\n required: false\n default: 'unique-comment'\n```\n\n大部分属性不一一细讲了,都是简单的英文望文生义即可。\n\n`runs`表示运行在`node20`环境下,入口文件为`./dist/index.js`。\n\n`inputs`表示接受的参数,也就是之前提到的`with`属性里要输入的参数。用`required`表示是否必须传入,`default`表示默认值。\n\n## src/index.js\n\n为什么入口文件是`dist/index.js`,而不是`src/index.js`呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好`src/index.js`,再打包就行。\n\n该文件代码如下:\n\n```javascript\nconst core = require('@actions/core');\nconst github = require('@actions/github');\n\nconst main = async () => {\n const token = core.getInput('token');\n const owner = core.getInput('owner');\n const repo = core.getInput('repo');\n const issueNumber = core.getInput('issue_number');\n const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`;\n const body = `${core.getInput('body')}\\n\\n${uniqueIdentifier}`;\n\n core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);\n\n const octokit = github.getOctokit(token);\n\n const comments = await octokit.rest.issues.listComments({\n owner,\n repo,\n issue_number: issueNumber,\n });\n\n const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));\n\n if (botComment) {\n core.info('update comment successfully.');\n await octokit.rest.issues.updateComment({\n owner,\n repo,\n comment_id: botComment.id,\n body,\n });\n } else {\n core.info('create comment successfully.');\n await octokit.rest.issues.createComment({\n owner,\n repo,\n issue_number: issueNumber,\n body,\n });\n }\n};\n\ntry {\n main();\n} catch (err) {\n core.setFailed(err.message);\n}\n```\n\n`@actions/core`和`@actions/github`是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。\n\n`main`函数的代码就是原生javascript,不一一解释了,主要通过`uniqueIdentifier`来判断是否发布过评论,如果是,就更新评论,否则就创建评论。\n\n> markdown语法`[^uniqueIdentifier]`表示脚注,不会被渲染。\n\n`core.setFailed(err.message);`表示抛出退出代码。\n\n## config/webpack.config.js\n\n打包用的,配置简单可用即可:\n\n```javascript\nmodule.exports = {\n mode: 'production',\n target: 'node20',\n entry: './src/index.js',\n output: {\n filename: 'index.js',\n clean: true,\n },\n};\n```\n\n## package.json\n\n```json\n{\n \"name\": \"unique-comment\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"scripts\": {\n \"build\": \"webpack --config ./config/webpack.config.js\"\n },\n \"dependencies\": {\n \"@actions/core\": \"^1.10.1\",\n \"@actions/github\": \"^6.0.0\"\n },\n \"devDependencies\": {\n \"webpack\": \"^5.89.0\",\n \"webpack-cli\": \"^5.1.4\"\n }\n}\n```\n\n没啥好说的,列出了依赖项。和一个打包脚本。\n\n## 测试\n\n修改了`src/index.js`得`build`,然后push到github仓库。\n\n记得将**dist**目录也提交到github仓库。\n\n\n\n# init\n\n现在,开始搞正经的了。\n\n先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要`needs`这个job。\n\n将init改成如下:\n\n```yaml\ninit:\n runs-on: ubuntu-latest\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install\n```\n\n相信经过对之前的job的了解,这里的配置就看起来很简单了。\n\n## Init pnpm\n\n使用第三方action,安装pnpm@^8。\n\n## Init node\n\n`cache: 'pnpm'`指定缓存机制,它内部是利用了workflow的cache机制。\n\n## Install dependencies\n\n安装依赖项,触发缓存。\n\n\n# eslint\n\n将eslint改成如下:\n\n```yaml\neslint:\n runs-on: ubuntu-latest\n needs: [init]\n outputs:\n result: ${{ steps.lint.outputs.result }}\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n fetch-depth: 0\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install\n\n - name: Run eslint\n id: lint\n uses: actions/github-script@v7\n with:\n result-encoding: string\n script: |\n let output = '';\n let outerr = '';\n let diffFiles = '';\n\n await exec.exec(\n `git diff --name-only origin/${{github.base_ref}}`,\n [],\n {\n // silent: true,\n // ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n diffFiles += data.toString();\n },\n },\n }\n );\n\n const lintFiles = diffFiles.split(`\\n`).filter((file) => {\n return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx')\n }).join(' ');\n\n await exec.exec(\n // \"pnpm run lint --format stylish\",\n `pnpm eslint ${lintFiles}`,\n [],\n {\n // silent: true,\n ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n output += data.toString();\n },\n stderr: (data) => {\n outerr += data.toString();\n },\n },\n }\n );\n\n if (outerr) {\n return `:x: Some command execution errors, non-eslint business errors.`;\n }\n\n const errorMatch = output.match(/(\\d+) errors?/);\n const warnMatch = output.match(/(\\d+) warnings?/);\n\n if (errorMatch && errorMatch?.[1] !== '0') {\n return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`;\n }\n\n return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;\n```\n\n## needs\n\n使用`needs`依赖init,可以使用到pnpm的缓存项,防止install太慢。\n\n> 因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。\n\n## outputs\n\njob里的outputs,可以在依赖它的其他job中访问到。这里使用`${{ steps.lint.outputs.result }}`去获取该job中lint这个step里的output里的result。\n\n> output有job和step两个维度,注意区分。\n\n\n## Run eslint\n\n它uses了`actions/github-script@v7`,这是github官方提供的一个action,可以在`with.script`里写js代码去执行,同时它会注入一些变量到script中去,见它的[官方文档](https://github.com/actions/github-script/tree/v7/)。\n\n> 对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。\n\n`result-encoding`是指定script返回的数据格式的,默认是json,这指定为string。\n\n> 为什么script里return了string,还要指定为string呢?\n> 因为`return 'hello'`在json encode后是`'\"hello\"'`,而string encode后为`'hello'`。\n\nscript里是原生的js代码了,里面的`exec`是该action注入的变量,用来执行shell命令。\n\n这段js代码做了两个事情,一是`git diff`获取pr中改动的文件列表,二是`eslint`检查这些增量文件,最后返回处理的结果。\n\n## fetch-depth\n\nInit repo这个step里设置了`fetch-depth: 0`,不然获取不到完整的git分支,具体看`actions/checkout`的解释,涉及到git的知识不展开细说了。\n\n## steps.lint.outputs.result\n\n`steps.lint.outputs.result`为什么能拿到lint step里的output.result呢?因为`actions/github-script`这个action内部将script的返回值,设置到`$GITHUB_OUTPUT`里了,且键名为`result`。\n\n\n# typescript\n\n和eslint的配置大同小异,只是改了对检测结果的判断。\n\n```yaml\ntypescript:\n runs-on: ubuntu-latest\n needs: [init]\n outputs:\n result: ${{ steps.lint.outputs.result }}\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install\n\n - name: Run lint\n id: lint\n uses: actions/github-script@v7\n with:\n result-encoding: string\n script: |\n let output = '';\n let outerr = '';\n\n await exec.exec(\n `pnpm run -r lint:ts`,\n [],\n {\n // silent: true,\n ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n output += data.toString();\n },\n stderr: (data) => {\n outerr += data.toString();\n },\n },\n }\n );\n\n if (outerr) {\n return `:x: Some command execution errors, no business errors.`;\n }\n\n const errorMatch = output.match(/error TS/g);\n\n if (errorMatch) {\n return `:x: ${errorMatch?.length} errors`;\n }\n\n return `:white_check_mark: ${'0 error'}`;\n```\n\n\n# unitTest\n\n和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。\n\n```yaml\nunitTest:\n runs-on: ubuntu-latest\n needs: [init]\n outputs:\n result: ${{ steps.lint.outputs.result }}\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: |\n pnpm remove @nike/eslint-multi-formatter || true\n pnpm remove @nike/svg-packer || true\n pnpm install\n\n - name: Run lint\n id: lint\n uses: actions/github-script@v7\n with:\n result-encoding: string\n script: |\n let output = '';\n let outerr = '';\n\n await exec.exec(\n `pnpm run test`,\n [],\n {\n // silent: true,\n ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n output += data.toString();\n },\n stderr: (data) => {\n outerr += data.toString();\n },\n },\n }\n );\n\n // why use outerr? https://github.com/jestjs/jest/issues/5064\n\n const failMatch = outerr.match(/Test Suites: \\d+ failed/);\n\n if (failMatch) {\n return `:x: ${failMatch?.[0]}`;\n }\n\n const errorMatch = outerr.match(/Jest: \"global\" coverage threshold for lines \\([0-9\\.]+%\\) not met: [0-9\\.]+%/);\n\n if (errorMatch) {\n return `:x: ${errorMatch?.[0]}`;\n }\n\n return `:white_check_mark: passed`;\n```\n\n\n# replyResult\n\n最后,将几个检测的结果进行汇总,回复到pr里就行了。\n\n```yaml\nreplyResult:\n runs-on: ubuntu-latest\n needs: [replyChecking, eslint, typescript, unitTest]\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n - name: Get date time\n id: getDateTime\n run: echo \"result=$(TZ=Asia/Shanghai date)\" >> \"$GITHUB_OUTPUT\"\n - name: Create or update a comment\n uses: ./.github/actions/unique-comment\n with:\n uniqueIdentifier: ${{ github.workflow }}\n body: |\n ## Eslint Check Result\n\n ${{needs.eslint.outputs.result}}\n\n ## Typescript Check Result\n\n ${{needs.typescript.outputs.result}}\n\n ## UnitTest Check Result\n\n ${{needs.unitTest.outputs.result}}\n\n ---\n\n Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.\n```\n\n和replyChecking差不多,在body里使用`${{needs.eslint.outputs.result}}`去读取了eslint job的outputs。\n\n## 测试\n\n去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~\n\n","source":"_posts/github-actions-sample-eslint-in-pull-request.md","raw":"---\ntitle: \"7. GitHub Actions - 在pull request中执行eslint检测的工作流例子\"\ndate: \"2023-12-29T03:56:49Z\"\ncategories:\n - [工程化]\n\nurl: github-actions-sample-eslint-in-pull-request\ntags:\n - github actions\n\n---\n\n\n原文链接:[https://github.com/taoliujun/blog/issues/36](https://github.com/taoliujun/blog/issues/36)\n\n\n\n一个在pull request发起的时候执行eslint检测的workflow,[点此查看完整代码](https://github.com/taoliujun/npm-packages/blob/master/.github/workflows/check-pull-request.yml),它实现的功能如下:\n\n- 在pull request创建、更新的时候执行。\n- 先回复一个评论,告诉用户正在运行。\n- 初始化仓库,并安装依赖,产生依赖缓存。\n- 运行eslint增量检查。\n- 运行typescript检查。\n- 运行jest检查。\n- 更新之前的评论,回复检查的结果。\n\n运行截图:\n\n![Alt text](https://github.com/taoliujun/blog/assets/5689134/09c86bc1-ada1-41c3-9f8f-7e6c46f8204e)\n\n为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:\n\n* workflow,工作流,可以理解为yml文件。\n* jobs,工作,一个workflow可以包含多个job,并行执行。\n* steps,作业,一个job可以包含多个step,串行执行。\n* action,操作,作业中具体的执行。\n\n## 步骤\n\n- [初始化workflow](https://github.com/taoliujun/blog/issues/36#issuecomment-1871790603)\n- [reply checking](https://github.com/taoliujun/blog/issues/36#issuecomment-1871806576)\n- [./.github/actions/unique-comment](https://github.com/taoliujun/blog/issues/36#issuecomment-1871818126)\n- [init](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862632)\n- [eslint](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862779)\n- [typescript](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862850)\n- [unit test](https://github.com/taoliujun/blog/issues/36#issuecomment-1871863037)\n- [reply result](https://github.com/taoliujun/blog/issues/36#issuecomment-1871863117)\n\n\n\n# 初始化workflow\n\n在项目中新建文件`.github/workflows/check-pull-request.yml`,内容如下:\n\n```yaml\nname: test check pull request\nrun-name: 'check pull request #${{ github.event.pull_request.number }}'\non:\n pull_request:\n types: [opened, synchronize, reopened]\njobs:\n replyChecking:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'replyChecking'\n\n init:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'init'\n\n eslint:\n runs-on: ubuntu-latest\n needs: [init]\n steps:\n - run: echo 'eslint'\n\n typescript:\n runs-on: ubuntu-latest\n needs: [init]\n steps:\n - run: echo 'typescript'\n\n unitTest:\n runs-on: ubuntu-latest\n needs: [init]\n steps:\n - run: echo 'unitTest'\n\n replyResult:\n runs-on: ubuntu-latest\n needs: [replyChecking, eslint, typescript, unitTest]\n steps:\n - run: echo 'replyResult'\n```\n\n## name和run-name\n\n给workflow命名为`check pull request`,它会出现在Actions页面的左侧菜单中。运行实例名为`check pull request #44`,出现在右侧的运行列表中。如图:\n\n![](https://github.com/taoliujun/blog/assets/5689134/c1371ff2-8fc3-4e5b-8b60-3c572419938b)\n\n`run-name`中的`${{ github.event.pull_request.number }}`是workflow的上下文,这里读取了上下文中的pr编号。\n\n## on\n\n`on`指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。\n\n## jobs\n\n按照设想,需要定义几个job,分别是:\n\n- replyChecking:回复用户正在检查中\n- init:初始化仓库,缓存依赖项\n- eslint:运行eslint检查\n- typescript:运行typescript检查\n- unitTest:运行单元测试\n- replyResult:回复用户检查结果\n\n`jobs`是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。\n\n其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了`needs`管理它们的执行依赖关系。\n\n### runs-on\n\n每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。\n\n## 测试\n\n发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:\n\n![](https://github.com/taoliujun/blog/assets/5689134/09c86bc1-ada1-41c3-9f8f-7e6c46f8204e)\n\n\n\n# replyChecking\n\n在进行eslint检测之前,先在pr里回复`checking`,并且带上拽酷炫的话。将replyChecking改成如下:\n\n```yaml\nreplyChecking:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n - name: Get date time\n id: getDateTime\n run: echo \"result=$(TZ=Asia/Shanghai date)\" >> \"$GITHUB_OUTPUT\"\n - name: Create or update a comment\n uses: ./.github/actions/unique-comment\n with:\n uniqueIdentifier: ${{ github.workflow }}\n body: |\n **Checking...**\n\n ---\n\n Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.\n```\n\n`steps`每一步里`name`、`id`是可选的,`name`在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。\n\n## Checkout\n\n`uses`表示使用一个action,名为`actions/checkout@v4`,它用来拉取仓库。\n\n> 同其他编程语言一样,重复的action可以封装起来。[action市场](https://github.com/marketplace?type=actions)提供了很多。\n\n`with`属性指定了该action的输入参数,每个action的参数不尽相同。\n\n`ref`参数表示要拉取的分支,`${{github.head_ref}}`也是一个上下文,表示当前pr的源分支。\n\n\n## Get Date time\n\n这step还写了`id`,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据`id`读取到它的`output`。\n\n> **output**是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。\n\n`run`就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到`$GITHUB_OUTPUT`中,键名为`result`。\n\n> `$GITHUB_OUTPUT`是workflow注入到容器中的一个路径,用于存放output。\n\n## Create or update a comment\n\n`uses`使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。\n\n> 有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。\n\n同理,该action也有`with`属性,`uniqueIdentifier`是回复评论的唯一标识,`body`是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说`${{steps.getDateTime.outputs.result}}`这个上下文表示获取getDateTime这个step中,键名为`result`的值。\n\n如果你不需要在内容里插入时间,那么上面的`Get Date time`就可以省略了。\n\n## 测试\n\n因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:\n\n![](https://github.com/taoliujun/blog/assets/5689134/42396a84-b798-4f4e-9f39-5bf92a8acb15)\n\n\n# ./.github/actions/unique-comment\n\n这是一个封装的javascript action,用于对issue创建、更新唯一评论。\n\n## 目录结构\n\n创建目录`./.github/actions/unique-comment`,最终目录结构如下:\n\n```bash\n.\n├── action.yml\n├── config\n│ └── webpack.config.js\n├── dist\n│ ├── index.js\n│ └── index.js.LICENSE.txt\n├── package.json\n└── src\n └── index.js\n```\n\n## action.yml\n\n这是action的配置文件,必须存在,内容如下:\n\n```yaml\nname: unique-comment\ndescription: create or update a unique comment\n\nruns:\n using: 'node20'\n main: './dist/index.js'\n\ninputs:\n token:\n description: 'GitHub token'\n required: false\n default: ${{ github.token }}\n owner:\n description: 'Repository owner'\n required: false\n default: ${{ github.event.repository.owner.login }}\n repo:\n description: 'Repository name'\n required: false\n default: ${{ github.event.repository.name }}\n issue_number:\n description: 'Issue number'\n required: false\n default: ${{ github.event.number }}\n body:\n description: 'Comment body'\n required: false\n uniqueIdentifier:\n description: 'Unique identifier for comment'\n required: false\n default: 'unique-comment'\n```\n\n大部分属性不一一细讲了,都是简单的英文望文生义即可。\n\n`runs`表示运行在`node20`环境下,入口文件为`./dist/index.js`。\n\n`inputs`表示接受的参数,也就是之前提到的`with`属性里要输入的参数。用`required`表示是否必须传入,`default`表示默认值。\n\n## src/index.js\n\n为什么入口文件是`dist/index.js`,而不是`src/index.js`呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好`src/index.js`,再打包就行。\n\n该文件代码如下:\n\n```javascript\nconst core = require('@actions/core');\nconst github = require('@actions/github');\n\nconst main = async () => {\n const token = core.getInput('token');\n const owner = core.getInput('owner');\n const repo = core.getInput('repo');\n const issueNumber = core.getInput('issue_number');\n const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`;\n const body = `${core.getInput('body')}\\n\\n${uniqueIdentifier}`;\n\n core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);\n\n const octokit = github.getOctokit(token);\n\n const comments = await octokit.rest.issues.listComments({\n owner,\n repo,\n issue_number: issueNumber,\n });\n\n const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));\n\n if (botComment) {\n core.info('update comment successfully.');\n await octokit.rest.issues.updateComment({\n owner,\n repo,\n comment_id: botComment.id,\n body,\n });\n } else {\n core.info('create comment successfully.');\n await octokit.rest.issues.createComment({\n owner,\n repo,\n issue_number: issueNumber,\n body,\n });\n }\n};\n\ntry {\n main();\n} catch (err) {\n core.setFailed(err.message);\n}\n```\n\n`@actions/core`和`@actions/github`是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。\n\n`main`函数的代码就是原生javascript,不一一解释了,主要通过`uniqueIdentifier`来判断是否发布过评论,如果是,就更新评论,否则就创建评论。\n\n> markdown语法`[^uniqueIdentifier]`表示脚注,不会被渲染。\n\n`core.setFailed(err.message);`表示抛出退出代码。\n\n## config/webpack.config.js\n\n打包用的,配置简单可用即可:\n\n```javascript\nmodule.exports = {\n mode: 'production',\n target: 'node20',\n entry: './src/index.js',\n output: {\n filename: 'index.js',\n clean: true,\n },\n};\n```\n\n## package.json\n\n```json\n{\n \"name\": \"unique-comment\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"scripts\": {\n \"build\": \"webpack --config ./config/webpack.config.js\"\n },\n \"dependencies\": {\n \"@actions/core\": \"^1.10.1\",\n \"@actions/github\": \"^6.0.0\"\n },\n \"devDependencies\": {\n \"webpack\": \"^5.89.0\",\n \"webpack-cli\": \"^5.1.4\"\n }\n}\n```\n\n没啥好说的,列出了依赖项。和一个打包脚本。\n\n## 测试\n\n修改了`src/index.js`得`build`,然后push到github仓库。\n\n记得将**dist**目录也提交到github仓库。\n\n\n\n# init\n\n现在,开始搞正经的了。\n\n先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要`needs`这个job。\n\n将init改成如下:\n\n```yaml\ninit:\n runs-on: ubuntu-latest\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install\n```\n\n相信经过对之前的job的了解,这里的配置就看起来很简单了。\n\n## Init pnpm\n\n使用第三方action,安装pnpm@^8。\n\n## Init node\n\n`cache: 'pnpm'`指定缓存机制,它内部是利用了workflow的cache机制。\n\n## Install dependencies\n\n安装依赖项,触发缓存。\n\n\n# eslint\n\n将eslint改成如下:\n\n```yaml\neslint:\n runs-on: ubuntu-latest\n needs: [init]\n outputs:\n result: ${{ steps.lint.outputs.result }}\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n fetch-depth: 0\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install\n\n - name: Run eslint\n id: lint\n uses: actions/github-script@v7\n with:\n result-encoding: string\n script: |\n let output = '';\n let outerr = '';\n let diffFiles = '';\n\n await exec.exec(\n `git diff --name-only origin/${{github.base_ref}}`,\n [],\n {\n // silent: true,\n // ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n diffFiles += data.toString();\n },\n },\n }\n );\n\n const lintFiles = diffFiles.split(`\\n`).filter((file) => {\n return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx')\n }).join(' ');\n\n await exec.exec(\n // \"pnpm run lint --format stylish\",\n `pnpm eslint ${lintFiles}`,\n [],\n {\n // silent: true,\n ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n output += data.toString();\n },\n stderr: (data) => {\n outerr += data.toString();\n },\n },\n }\n );\n\n if (outerr) {\n return `:x: Some command execution errors, non-eslint business errors.`;\n }\n\n const errorMatch = output.match(/(\\d+) errors?/);\n const warnMatch = output.match(/(\\d+) warnings?/);\n\n if (errorMatch && errorMatch?.[1] !== '0') {\n return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`;\n }\n\n return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;\n```\n\n## needs\n\n使用`needs`依赖init,可以使用到pnpm的缓存项,防止install太慢。\n\n> 因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。\n\n## outputs\n\njob里的outputs,可以在依赖它的其他job中访问到。这里使用`${{ steps.lint.outputs.result }}`去获取该job中lint这个step里的output里的result。\n\n> output有job和step两个维度,注意区分。\n\n\n## Run eslint\n\n它uses了`actions/github-script@v7`,这是github官方提供的一个action,可以在`with.script`里写js代码去执行,同时它会注入一些变量到script中去,见它的[官方文档](https://github.com/actions/github-script/tree/v7/)。\n\n> 对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。\n\n`result-encoding`是指定script返回的数据格式的,默认是json,这指定为string。\n\n> 为什么script里return了string,还要指定为string呢?\n> 因为`return 'hello'`在json encode后是`'\"hello\"'`,而string encode后为`'hello'`。\n\nscript里是原生的js代码了,里面的`exec`是该action注入的变量,用来执行shell命令。\n\n这段js代码做了两个事情,一是`git diff`获取pr中改动的文件列表,二是`eslint`检查这些增量文件,最后返回处理的结果。\n\n## fetch-depth\n\nInit repo这个step里设置了`fetch-depth: 0`,不然获取不到完整的git分支,具体看`actions/checkout`的解释,涉及到git的知识不展开细说了。\n\n## steps.lint.outputs.result\n\n`steps.lint.outputs.result`为什么能拿到lint step里的output.result呢?因为`actions/github-script`这个action内部将script的返回值,设置到`$GITHUB_OUTPUT`里了,且键名为`result`。\n\n\n# typescript\n\n和eslint的配置大同小异,只是改了对检测结果的判断。\n\n```yaml\ntypescript:\n runs-on: ubuntu-latest\n needs: [init]\n outputs:\n result: ${{ steps.lint.outputs.result }}\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install\n\n - name: Run lint\n id: lint\n uses: actions/github-script@v7\n with:\n result-encoding: string\n script: |\n let output = '';\n let outerr = '';\n\n await exec.exec(\n `pnpm run -r lint:ts`,\n [],\n {\n // silent: true,\n ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n output += data.toString();\n },\n stderr: (data) => {\n outerr += data.toString();\n },\n },\n }\n );\n\n if (outerr) {\n return `:x: Some command execution errors, no business errors.`;\n }\n\n const errorMatch = output.match(/error TS/g);\n\n if (errorMatch) {\n return `:x: ${errorMatch?.length} errors`;\n }\n\n return `:white_check_mark: ${'0 error'}`;\n```\n\n\n# unitTest\n\n和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。\n\n```yaml\nunitTest:\n runs-on: ubuntu-latest\n needs: [init]\n outputs:\n result: ${{ steps.lint.outputs.result }}\n steps:\n - name: Init repo\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n\n - name: Init pnpm\n uses: pnpm/action-setup@v2\n with:\n version: 8\n\n - name: Init node\n uses: actions/setup-node@v4\n with:\n node-version: 20\n cache: 'pnpm'\n\n - name: Install dependencies\n run: |\n pnpm remove @nike/eslint-multi-formatter || true\n pnpm remove @nike/svg-packer || true\n pnpm install\n\n - name: Run lint\n id: lint\n uses: actions/github-script@v7\n with:\n result-encoding: string\n script: |\n let output = '';\n let outerr = '';\n\n await exec.exec(\n `pnpm run test`,\n [],\n {\n // silent: true,\n ignoreReturnCode: true,\n listeners: {\n stdout: (data) => {\n output += data.toString();\n },\n stderr: (data) => {\n outerr += data.toString();\n },\n },\n }\n );\n\n // why use outerr? https://github.com/jestjs/jest/issues/5064\n\n const failMatch = outerr.match(/Test Suites: \\d+ failed/);\n\n if (failMatch) {\n return `:x: ${failMatch?.[0]}`;\n }\n\n const errorMatch = outerr.match(/Jest: \"global\" coverage threshold for lines \\([0-9\\.]+%\\) not met: [0-9\\.]+%/);\n\n if (errorMatch) {\n return `:x: ${errorMatch?.[0]}`;\n }\n\n return `:white_check_mark: passed`;\n```\n\n\n# replyResult\n\n最后,将几个检测的结果进行汇总,回复到pr里就行了。\n\n```yaml\nreplyResult:\n runs-on: ubuntu-latest\n needs: [replyChecking, eslint, typescript, unitTest]\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n with:\n ref: ${{github.head_ref}}\n - name: Get date time\n id: getDateTime\n run: echo \"result=$(TZ=Asia/Shanghai date)\" >> \"$GITHUB_OUTPUT\"\n - name: Create or update a comment\n uses: ./.github/actions/unique-comment\n with:\n uniqueIdentifier: ${{ github.workflow }}\n body: |\n ## Eslint Check Result\n\n ${{needs.eslint.outputs.result}}\n\n ## Typescript Check Result\n\n ${{needs.typescript.outputs.result}}\n\n ## UnitTest Check Result\n\n ${{needs.unitTest.outputs.result}}\n\n ---\n\n Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.\n```\n\n和replyChecking差不多,在body里使用`${{needs.eslint.outputs.result}}`去读取了eslint job的outputs。\n\n## 测试\n\n去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~\n\n","slug":"github-actions-sample-eslint-in-pull-request","published":1,"updated":"2024-01-03T09:13:36.691Z","comments":1,"layout":"post","photos":[],"link":"","_id":"clqxk9r600000joor84vf3yg8","content":"原文链接:https://github.com/taoliujun/blog/issues/36
\n\n\n一个在pull request发起的时候执行eslint检测的workflow,点此查看完整代码,它实现的功能如下:
\n\n- 在pull request创建、更新的时候执行。
\n- 先回复一个评论,告诉用户正在运行。
\n- 初始化仓库,并安装依赖,产生依赖缓存。
\n- 运行eslint增量检查。
\n- 运行typescript检查。
\n- 运行jest检查。
\n- 更新之前的评论,回复检查的结果。
\n
\n运行截图:
\n\n为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:
\n\n- workflow,工作流,可以理解为yml文件。
\n- jobs,工作,一个workflow可以包含多个job,并行执行。
\n- steps,作业,一个job可以包含多个step,串行执行。
\n- action,操作,作业中具体的执行。
\n
\n步骤
\n\n\n初始化workflow
在项目中新建文件.github/workflows/check-pull-request.yml
,内容如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| name: test check pull request run-name: 'check pull request #${{ github.event.pull_request.number }}' on: pull_request: types: [opened, synchronize, reopened] jobs: replyChecking: runs-on: ubuntu-latest steps: - run: echo 'replyChecking'
init: runs-on: ubuntu-latest steps: - run: echo 'init'
eslint: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'eslint'
typescript: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'typescript'
unitTest: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'unitTest'
replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - run: echo 'replyResult'
|
\n\nname和run-name
给workflow命名为check pull request
,它会出现在Actions页面的左侧菜单中。运行实例名为check pull request #44
,出现在右侧的运行列表中。如图:
\n\nrun-name
中的${{ github.event.pull_request.number }}
是workflow的上下文,这里读取了上下文中的pr编号。
\non
on
指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。
\njobs
按照设想,需要定义几个job,分别是:
\n\n- replyChecking:回复用户正在检查中
\n- init:初始化仓库,缓存依赖项
\n- eslint:运行eslint检查
\n- typescript:运行typescript检查
\n- unitTest:运行单元测试
\n- replyResult:回复用户检查结果
\n
\njobs
是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。
\n其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了needs
管理它们的执行依赖关系。
\nruns-on
每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。
\n测试
发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:
\n\n\n\nreplyChecking
在进行eslint检测之前,先在pr里回复checking
,并且带上拽酷炫的话。将replyChecking改成如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| replyChecking: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | **Checking...**
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
\n\nsteps
每一步里name
、id
是可选的,name
在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。
\nCheckout
uses
表示使用一个action,名为actions/checkout@v4
,它用来拉取仓库。
\n\n同其他编程语言一样,重复的action可以封装起来。action市场提供了很多。
\n
\nwith
属性指定了该action的输入参数,每个action的参数不尽相同。
\nref
参数表示要拉取的分支,${{github.head_ref}}
也是一个上下文,表示当前pr的源分支。
\nGet Date time
这step还写了id
,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据id
读取到它的output
。
\n\noutput是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。
\n
\nrun
就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到$GITHUB_OUTPUT
中,键名为result
。
\n\n$GITHUB_OUTPUT
是workflow注入到容器中的一个路径,用于存放output。
\n
\nuses
使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。
\n\n有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。
\n
\n同理,该action也有with
属性,uniqueIdentifier
是回复评论的唯一标识,body
是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说${{steps.getDateTime.outputs.result}}
这个上下文表示获取getDateTime这个step中,键名为result
的值。
\n如果你不需要在内容里插入时间,那么上面的Get Date time
就可以省略了。
\n测试
因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:
\n\n\n\n这是一个封装的javascript action,用于对issue创建、更新唯一评论。
\n目录结构
创建目录./.github/actions/unique-comment
,最终目录结构如下:
\n1 2 3 4 5 6 7 8 9 10
| . ├── action.yml ├── config │ └── webpack.config.js ├── dist │ ├── index.js │ └── index.js.LICENSE.txt ├── package.json └── src └── index.js
|
\n\naction.yml
这是action的配置文件,必须存在,内容如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| name: unique-comment description: create or update a unique comment
runs: using: 'node20' main: './dist/index.js'
inputs: token: description: 'GitHub token' required: false default: ${{ github.token }} owner: description: 'Repository owner' required: false default: ${{ github.event.repository.owner.login }} repo: description: 'Repository name' required: false default: ${{ github.event.repository.name }} issue_number: description: 'Issue number' required: false default: ${{ github.event.number }} body: description: 'Comment body' required: false uniqueIdentifier: description: 'Unique identifier for comment' required: false default: 'unique-comment'
|
\n\n大部分属性不一一细讲了,都是简单的英文望文生义即可。
\nruns
表示运行在node20
环境下,入口文件为./dist/index.js
。
\ninputs
表示接受的参数,也就是之前提到的with
属性里要输入的参数。用required
表示是否必须传入,default
表示默认值。
\nsrc/index.js
为什么入口文件是dist/index.js
,而不是src/index.js
呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好src/index.js
,再打包就行。
\n该文件代码如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| const core = require('@actions/core'); const github = require('@actions/github');
const main = async () => { const token = core.getInput('token'); const owner = core.getInput('owner'); const repo = core.getInput('repo'); const issueNumber = core.getInput('issue_number'); const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`; const body = `${core.getInput('body')}\\n\\n${uniqueIdentifier}`;
core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);
const octokit = github.getOctokit(token);
const comments = await octokit.rest.issues.listComments({ owner, repo, issue_number: issueNumber, });
const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));
if (botComment) { core.info('update comment successfully.'); await octokit.rest.issues.updateComment({ owner, repo, comment_id: botComment.id, body, }); } else { core.info('create comment successfully.'); await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body, }); } };
try { main(); } catch (err) { core.setFailed(err.message); }
|
\n\n@actions/core
和@actions/github
是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。
\nmain
函数的代码就是原生javascript,不一一解释了,主要通过uniqueIdentifier
来判断是否发布过评论,如果是,就更新评论,否则就创建评论。
\n\nmarkdown语法[^uniqueIdentifier]
表示脚注,不会被渲染。
\n
\ncore.setFailed(err.message);
表示抛出退出代码。
\nconfig/webpack.config.js
打包用的,配置简单可用即可:
\n1 2 3 4 5 6 7 8 9
| module.exports = { mode: 'production', target: 'node20', entry: './src/index.js', output: { filename: 'index.js', clean: true, }, };
|
\n\npackage.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "name": "unique-comment", "version": "1.0.0", "private": true, "scripts": { "build": "webpack --config ./config/webpack.config.js" }, "dependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0" }, "devDependencies": { "webpack": "^5.89.0", "webpack-cli": "^5.1.4" } }
|
\n\n没啥好说的,列出了依赖项。和一个打包脚本。
\n测试
修改了src/index.js
得build
,然后push到github仓库。
\n记得将dist目录也提交到github仓库。
\n\n\ninit
现在,开始搞正经的了。
\n先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要needs
这个job。
\n将init改成如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| init: runs-on: ubuntu-latest steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
|
\n\n相信经过对之前的job的了解,这里的配置就看起来很简单了。
\nInit pnpm
使用第三方action,安装pnpm@^8。
\nInit node
cache: 'pnpm'
指定缓存机制,它内部是利用了workflow的cache机制。
\nInstall dependencies
安装依赖项,触发缓存。
\n\n\neslint
将eslint改成如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
| eslint: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}} fetch-depth: 0
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run eslint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = ''; let diffFiles = '';
await exec.exec( `git diff --name-only origin/${{github.base_ref}}`, [], { // silent: true, // ignoreReturnCode: true, listeners: { stdout: (data) => { diffFiles += data.toString(); }, }, } );
const lintFiles = diffFiles.split(`\\n`).filter((file) => { return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx') }).join(' ');
await exec.exec( // "pnpm run lint --format stylish", `pnpm eslint ${lintFiles}`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, non-eslint business errors.`; }
const errorMatch = output.match(/(\\d+) errors?/); const warnMatch = output.match(/(\\d+) warnings?/);
if (errorMatch && errorMatch?.[1] !== '0') { return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`; }
return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;
|
\n\nneeds
使用needs
依赖init,可以使用到pnpm的缓存项,防止install太慢。
\n\n因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。
\n
\noutputs
job里的outputs,可以在依赖它的其他job中访问到。这里使用${{ steps.lint.outputs.result }}
去获取该job中lint这个step里的output里的result。
\n\noutput有job和step两个维度,注意区分。
\n
\nRun eslint
它uses了actions/github-script@v7
,这是github官方提供的一个action,可以在with.script
里写js代码去执行,同时它会注入一些变量到script中去,见它的官方文档。
\n\n对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。
\n
\nresult-encoding
是指定script返回的数据格式的,默认是json,这指定为string。
\n\n为什么script里return了string,还要指定为string呢?
因为return 'hello'
在json encode后是'"hello"'
,而string encode后为'hello'
。
\n
\nscript里是原生的js代码了,里面的exec
是该action注入的变量,用来执行shell命令。
\n这段js代码做了两个事情,一是git diff
获取pr中改动的文件列表,二是eslint
检查这些增量文件,最后返回处理的结果。
\nfetch-depth
Init repo这个step里设置了fetch-depth: 0
,不然获取不到完整的git分支,具体看actions/checkout
的解释,涉及到git的知识不展开细说了。
\nsteps.lint.outputs.result
steps.lint.outputs.result
为什么能拿到lint step里的output.result呢?因为actions/github-script
这个action内部将script的返回值,设置到$GITHUB_OUTPUT
里了,且键名为result
。
\n\n\ntypescript
和eslint的配置大同小异,只是改了对检测结果的判断。
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| typescript: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run -r lint:ts`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, no business errors.`; }
const errorMatch = output.match(/error TS/g);
if (errorMatch) { return `:x: ${errorMatch?.length} errors`; }
return `:white_check_mark: ${'0 error'}`;
|
\n\n\nunitTest
和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| unitTest: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: | pnpm remove @nike/eslint-multi-formatter || true pnpm remove @nike/svg-packer || true pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run test`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
// why use outerr? https://github.com/jestjs/jest/issues/5064
const failMatch = outerr.match(/Test Suites: \\d+ failed/);
if (failMatch) { return `:x: ${failMatch?.[0]}`; }
const errorMatch = outerr.match(/Jest: "global" coverage threshold for lines \\([0-9\\.]+%\\) not met: [0-9\\.]+%/);
if (errorMatch) { return `:x: ${errorMatch?.[0]}`; }
return `:white_check_mark: passed`;
|
\n\n\nreplyResult
最后,将几个检测的结果进行汇总,回复到pr里就行了。
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | ## Eslint Check Result
${{needs.eslint.outputs.result}}
${{needs.typescript.outputs.result}}
${{needs.unitTest.outputs.result}}
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
\n\n和replyChecking差不多,在body里使用${{needs.eslint.outputs.result}}
去读取了eslint job的outputs。
\n测试
去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~
\n","site":{"data":{}},"excerpt":"","more":"原文链接:https://github.com/taoliujun/blog/issues/36
\n\n\n一个在pull request发起的时候执行eslint检测的workflow,点此查看完整代码,它实现的功能如下:
\n\n- 在pull request创建、更新的时候执行。
\n- 先回复一个评论,告诉用户正在运行。
\n- 初始化仓库,并安装依赖,产生依赖缓存。
\n- 运行eslint增量检查。
\n- 运行typescript检查。
\n- 运行jest检查。
\n- 更新之前的评论,回复检查的结果。
\n
\n运行截图:
\n\n为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:
\n\n- workflow,工作流,可以理解为yml文件。
\n- jobs,工作,一个workflow可以包含多个job,并行执行。
\n- steps,作业,一个job可以包含多个step,串行执行。
\n- action,操作,作业中具体的执行。
\n
\n步骤
\n\n\n初始化workflow
在项目中新建文件.github/workflows/check-pull-request.yml
,内容如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| name: test check pull request run-name: 'check pull request #${{ github.event.pull_request.number }}' on: pull_request: types: [opened, synchronize, reopened] jobs: replyChecking: runs-on: ubuntu-latest steps: - run: echo 'replyChecking'
init: runs-on: ubuntu-latest steps: - run: echo 'init'
eslint: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'eslint'
typescript: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'typescript'
unitTest: runs-on: ubuntu-latest needs: [init] steps: - run: echo 'unitTest'
replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - run: echo 'replyResult'
|
\n\nname和run-name
给workflow命名为check pull request
,它会出现在Actions页面的左侧菜单中。运行实例名为check pull request #44
,出现在右侧的运行列表中。如图:
\n\nrun-name
中的${{ github.event.pull_request.number }}
是workflow的上下文,这里读取了上下文中的pr编号。
\non
on
指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。
\njobs
按照设想,需要定义几个job,分别是:
\n\n- replyChecking:回复用户正在检查中
\n- init:初始化仓库,缓存依赖项
\n- eslint:运行eslint检查
\n- typescript:运行typescript检查
\n- unitTest:运行单元测试
\n- replyResult:回复用户检查结果
\n
\njobs
是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。
\n其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了needs
管理它们的执行依赖关系。
\nruns-on
每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。
\n测试
发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:
\n\n\n\nreplyChecking
在进行eslint检测之前,先在pr里回复checking
,并且带上拽酷炫的话。将replyChecking改成如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| replyChecking: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | **Checking...**
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
\n\nsteps
每一步里name
、id
是可选的,name
在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。
\nCheckout
uses
表示使用一个action,名为actions/checkout@v4
,它用来拉取仓库。
\n\n同其他编程语言一样,重复的action可以封装起来。action市场提供了很多。
\n
\nwith
属性指定了该action的输入参数,每个action的参数不尽相同。
\nref
参数表示要拉取的分支,${{github.head_ref}}
也是一个上下文,表示当前pr的源分支。
\nGet Date time
这step还写了id
,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据id
读取到它的output
。
\n\noutput是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。
\n
\nrun
就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到$GITHUB_OUTPUT
中,键名为result
。
\n\n$GITHUB_OUTPUT
是workflow注入到容器中的一个路径,用于存放output。
\n
\nuses
使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。
\n\n有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。
\n
\n同理,该action也有with
属性,uniqueIdentifier
是回复评论的唯一标识,body
是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说${{steps.getDateTime.outputs.result}}
这个上下文表示获取getDateTime这个step中,键名为result
的值。
\n如果你不需要在内容里插入时间,那么上面的Get Date time
就可以省略了。
\n测试
因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:
\n\n\n\n这是一个封装的javascript action,用于对issue创建、更新唯一评论。
\n目录结构
创建目录./.github/actions/unique-comment
,最终目录结构如下:
\n1 2 3 4 5 6 7 8 9 10
| . ├── action.yml ├── config │ └── webpack.config.js ├── dist │ ├── index.js │ └── index.js.LICENSE.txt ├── package.json └── src └── index.js
|
\n\naction.yml
这是action的配置文件,必须存在,内容如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| name: unique-comment description: create or update a unique comment
runs: using: 'node20' main: './dist/index.js'
inputs: token: description: 'GitHub token' required: false default: ${{ github.token }} owner: description: 'Repository owner' required: false default: ${{ github.event.repository.owner.login }} repo: description: 'Repository name' required: false default: ${{ github.event.repository.name }} issue_number: description: 'Issue number' required: false default: ${{ github.event.number }} body: description: 'Comment body' required: false uniqueIdentifier: description: 'Unique identifier for comment' required: false default: 'unique-comment'
|
\n\n大部分属性不一一细讲了,都是简单的英文望文生义即可。
\nruns
表示运行在node20
环境下,入口文件为./dist/index.js
。
\ninputs
表示接受的参数,也就是之前提到的with
属性里要输入的参数。用required
表示是否必须传入,default
表示默认值。
\nsrc/index.js
为什么入口文件是dist/index.js
,而不是src/index.js
呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好src/index.js
,再打包就行。
\n该文件代码如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| const core = require('@actions/core'); const github = require('@actions/github');
const main = async () => { const token = core.getInput('token'); const owner = core.getInput('owner'); const repo = core.getInput('repo'); const issueNumber = core.getInput('issue_number'); const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`; const body = `${core.getInput('body')}\\n\\n${uniqueIdentifier}`;
core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);
const octokit = github.getOctokit(token);
const comments = await octokit.rest.issues.listComments({ owner, repo, issue_number: issueNumber, });
const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));
if (botComment) { core.info('update comment successfully.'); await octokit.rest.issues.updateComment({ owner, repo, comment_id: botComment.id, body, }); } else { core.info('create comment successfully.'); await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body, }); } };
try { main(); } catch (err) { core.setFailed(err.message); }
|
\n\n@actions/core
和@actions/github
是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。
\nmain
函数的代码就是原生javascript,不一一解释了,主要通过uniqueIdentifier
来判断是否发布过评论,如果是,就更新评论,否则就创建评论。
\n\nmarkdown语法[^uniqueIdentifier]
表示脚注,不会被渲染。
\n
\ncore.setFailed(err.message);
表示抛出退出代码。
\nconfig/webpack.config.js
打包用的,配置简单可用即可:
\n1 2 3 4 5 6 7 8 9
| module.exports = { mode: 'production', target: 'node20', entry: './src/index.js', output: { filename: 'index.js', clean: true, }, };
|
\n\npackage.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "name": "unique-comment", "version": "1.0.0", "private": true, "scripts": { "build": "webpack --config ./config/webpack.config.js" }, "dependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0" }, "devDependencies": { "webpack": "^5.89.0", "webpack-cli": "^5.1.4" } }
|
\n\n没啥好说的,列出了依赖项。和一个打包脚本。
\n测试
修改了src/index.js
得build
,然后push到github仓库。
\n记得将dist目录也提交到github仓库。
\n\n\ninit
现在,开始搞正经的了。
\n先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要needs
这个job。
\n将init改成如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| init: runs-on: ubuntu-latest steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
|
\n\n相信经过对之前的job的了解,这里的配置就看起来很简单了。
\nInit pnpm
使用第三方action,安装pnpm@^8。
\nInit node
cache: 'pnpm'
指定缓存机制,它内部是利用了workflow的cache机制。
\nInstall dependencies
安装依赖项,触发缓存。
\n\n\neslint
将eslint改成如下:
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
| eslint: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}} fetch-depth: 0
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run eslint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = ''; let diffFiles = '';
await exec.exec( `git diff --name-only origin/${{github.base_ref}}`, [], { // silent: true, // ignoreReturnCode: true, listeners: { stdout: (data) => { diffFiles += data.toString(); }, }, } );
const lintFiles = diffFiles.split(`\\n`).filter((file) => { return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx') }).join(' ');
await exec.exec( // "pnpm run lint --format stylish", `pnpm eslint ${lintFiles}`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, non-eslint business errors.`; }
const errorMatch = output.match(/(\\d+) errors?/); const warnMatch = output.match(/(\\d+) warnings?/);
if (errorMatch && errorMatch?.[1] !== '0') { return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`; }
return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;
|
\n\nneeds
使用needs
依赖init,可以使用到pnpm的缓存项,防止install太慢。
\n\n因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。
\n
\noutputs
job里的outputs,可以在依赖它的其他job中访问到。这里使用${{ steps.lint.outputs.result }}
去获取该job中lint这个step里的output里的result。
\n\noutput有job和step两个维度,注意区分。
\n
\nRun eslint
它uses了actions/github-script@v7
,这是github官方提供的一个action,可以在with.script
里写js代码去执行,同时它会注入一些变量到script中去,见它的官方文档。
\n\n对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。
\n
\nresult-encoding
是指定script返回的数据格式的,默认是json,这指定为string。
\n\n为什么script里return了string,还要指定为string呢?
因为return 'hello'
在json encode后是'"hello"'
,而string encode后为'hello'
。
\n
\nscript里是原生的js代码了,里面的exec
是该action注入的变量,用来执行shell命令。
\n这段js代码做了两个事情,一是git diff
获取pr中改动的文件列表,二是eslint
检查这些增量文件,最后返回处理的结果。
\nfetch-depth
Init repo这个step里设置了fetch-depth: 0
,不然获取不到完整的git分支,具体看actions/checkout
的解释,涉及到git的知识不展开细说了。
\nsteps.lint.outputs.result
steps.lint.outputs.result
为什么能拿到lint step里的output.result呢?因为actions/github-script
这个action内部将script的返回值,设置到$GITHUB_OUTPUT
里了,且键名为result
。
\n\n\ntypescript
和eslint的配置大同小异,只是改了对检测结果的判断。
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| typescript: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run -r lint:ts`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
if (outerr) { return `:x: Some command execution errors, no business errors.`; }
const errorMatch = output.match(/error TS/g);
if (errorMatch) { return `:x: ${errorMatch?.length} errors`; }
return `:white_check_mark: ${'0 error'}`;
|
\n\n\nunitTest
和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| unitTest: runs-on: ubuntu-latest needs: [init] outputs: result: ${{ steps.lint.outputs.result }} steps: - name: Init repo uses: actions/checkout@v4 with: ref: ${{github.head_ref}}
- name: Init pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Init node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: | pnpm remove @nike/eslint-multi-formatter || true pnpm remove @nike/svg-packer || true pnpm install
- name: Run lint id: lint uses: actions/github-script@v7 with: result-encoding: string script: | let output = ''; let outerr = '';
await exec.exec( `pnpm run test`, [], { // silent: true, ignoreReturnCode: true, listeners: { stdout: (data) => { output += data.toString(); }, stderr: (data) => { outerr += data.toString(); }, }, } );
// why use outerr? https://github.com/jestjs/jest/issues/5064
const failMatch = outerr.match(/Test Suites: \\d+ failed/);
if (failMatch) { return `:x: ${failMatch?.[0]}`; }
const errorMatch = outerr.match(/Jest: "global" coverage threshold for lines \\([0-9\\.]+%\\) not met: [0-9\\.]+%/);
if (errorMatch) { return `:x: ${errorMatch?.[0]}`; }
return `:white_check_mark: passed`;
|
\n\n\nreplyResult
最后,将几个检测的结果进行汇总,回复到pr里就行了。
\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| replyResult: runs-on: ubuntu-latest needs: [replyChecking, eslint, typescript, unitTest] steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{github.head_ref}} - name: Get date time id: getDateTime run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT" - name: Create or update a comment uses: ./.github/actions/unique-comment with: uniqueIdentifier: ${{ github.workflow }} body: | ## Eslint Check Result
${{needs.eslint.outputs.result}}
${{needs.typescript.outputs.result}}
${{needs.unitTest.outputs.result}}
---
Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
|
\n\n和replyChecking差不多,在body里使用${{needs.eslint.outputs.result}}
去读取了eslint job的outputs。
\n测试
去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~
\n"},{"title":"React公共状态利器 - Zustand","date":"2023-12-12T22:46:26.000Z","url":"react-zustand","_content":"\n\n原文链接:[https://github.com/taoliujun/blog/issues/35](https://github.com/taoliujun/blog/issues/35)\n\n\n\n官方文档:https://docs.pmnd.rs/zustand\n\n# 如何使用\n\n**Zustand** 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:\n\n```bash\n> pnpm add zustand\n```\n\n```ts\n// useFormStateStore.ts\nimport { create } from 'zustand';\n\ninterface State {\n loading: boolean;\n disabled: boolean;\n setLoadingByAge: (value: number) => void;\n}\n\nexport const useFormStateStore = create((set) => ({\n loading: false,\n disabled: false,\n setLoadingByAge: (value) => {\n set({ loading: value > 10 });\n },\n}));\n```\n\n```tsx\n// app.tsx\nimport { useState, type FC } from 'react';\nimport { useFormStateStore } from './useFormStateStore';\nimport { Button } from '@/components/Button';\n\nconst Loading: FC = () => {\n const { loading } = useFormStateStore();\n return loading: {String(loading)}
;\n};\n\nconst Disabled: FC = () => {\n const { disabled } = useFormStateStore();\n return disabled: {String(disabled)}
;\n};\n\nconst Main: FC = () => {\n const { setLoadingByAge } = useFormStateStore();\n const [age, setAge] = useState(0);\n\n return (\n \n \n
\n \n
\n \n \n \n \n
\n {\n setAge(Number(e.target.value));\n }}\n />\n
\n \n
\n );\n};\n\nexport default Main;\n```\n\n在`useFormStateStore.ts`中定义了状态,然后在`app.tsx`中使用,就是这么简单粗暴!这里有几点介绍下:\n\n- 对于简单的状态更新,使用`setState`方法就可以,它的参数是一个对象,这个对象就是你要更新的状态,它会和之前的状态进行合并,然后返回一个新的状态,从而触发组件更新。\n\n- 对于需要通用的逻辑处理的状态更新,参照`useFormStateStore.ts`中的`setLoadingByAge`方法,将它作为状态里的一个方法就行了。\n\n# Zustand\n\n使用非常简单,API也很少,它的原理是使用了`Proxy`,所以它的性能非常好。\n\n# 相比Redux\n\n相比Redux,Zustand的代码非常简单明了,不需要使用`connect`、`mapStateToProps`、`mapDispatchToProps`这些方法。\n\n# 相比React Context\n\nReact Context需要一个`Provider`包裹组件以传递状态,需要一个`useContext`使用状态,光从层级上就让人绕起来了。而Zustand只需要一个`create`方法,就可以使用了,且状态是全局的,不需要传递。\n\n\n\n\n","source":"_posts/react-zustand.md","raw":"---\ntitle: \"React公共状态利器 - Zustand\"\ndate: \"2023-12-13T06:46:26Z\"\ncategories:\n - [React]\n\nurl: react-zustand\ntags:\n - zustand\n - react store\n\n---\n\n\n原文链接:[https://github.com/taoliujun/blog/issues/35](https://github.com/taoliujun/blog/issues/35)\n\n\n\n官方文档:https://docs.pmnd.rs/zustand\n\n# 如何使用\n\n**Zustand** 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:\n\n```bash\n> pnpm add zustand\n```\n\n```ts\n// useFormStateStore.ts\nimport { create } from 'zustand';\n\ninterface State {\n loading: boolean;\n disabled: boolean;\n setLoadingByAge: (value: number) => void;\n}\n\nexport const useFormStateStore = create((set) => ({\n loading: false,\n disabled: false,\n setLoadingByAge: (value) => {\n set({ loading: value > 10 });\n },\n}));\n```\n\n```tsx\n// app.tsx\nimport { useState, type FC } from 'react';\nimport { useFormStateStore } from './useFormStateStore';\nimport { Button } from '@/components/Button';\n\nconst Loading: FC = () => {\n const { loading } = useFormStateStore();\n return loading: {String(loading)}
;\n};\n\nconst Disabled: FC = () => {\n const { disabled } = useFormStateStore();\n return disabled: {String(disabled)}
;\n};\n\nconst Main: FC = () => {\n const { setLoadingByAge } = useFormStateStore();\n const [age, setAge] = useState(0);\n\n return (\n \n \n
\n \n
\n \n \n \n \n
\n {\n setAge(Number(e.target.value));\n }}\n />\n
\n \n
\n );\n};\n\nexport default Main;\n```\n\n在`useFormStateStore.ts`中定义了状态,然后在`app.tsx`中使用,就是这么简单粗暴!这里有几点介绍下:\n\n- 对于简单的状态更新,使用`setState`方法就可以,它的参数是一个对象,这个对象就是你要更新的状态,它会和之前的状态进行合并,然后返回一个新的状态,从而触发组件更新。\n\n- 对于需要通用的逻辑处理的状态更新,参照`useFormStateStore.ts`中的`setLoadingByAge`方法,将它作为状态里的一个方法就行了。\n\n# Zustand\n\n使用非常简单,API也很少,它的原理是使用了`Proxy`,所以它的性能非常好。\n\n# 相比Redux\n\n相比Redux,Zustand的代码非常简单明了,不需要使用`connect`、`mapStateToProps`、`mapDispatchToProps`这些方法。\n\n# 相比React Context\n\nReact Context需要一个`Provider`包裹组件以传递状态,需要一个`useContext`使用状态,光从层级上就让人绕起来了。而Zustand只需要一个`create`方法,就可以使用了,且状态是全局的,不需要传递。\n\n\n\n\n","slug":"react-zustand","published":1,"updated":"2024-01-03T09:13:36.695Z","comments":1,"layout":"post","photos":[],"link":"","_id":"clqxk9r640001joorghsabmh1","content":"原文链接:https://github.com/taoliujun/blog/issues/35
\n\n\n官方文档:https://docs.pmnd.rs/zustand
\n如何使用
Zustand 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:
\n\n\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { create } from 'zustand';
interface State { loading: boolean; disabled: boolean; setLoadingByAge: (value: number) => void; }
export const useFormStateStore = create<State>((set) => ({ loading: false, disabled: false, setLoadingByAge: (value) => { set({ loading: value > 10 }); }, }));
|
\n\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| import { useState, type FC } from 'react'; import { useFormStateStore } from './useFormStateStore'; import { Button } from '@/components/Button';
const Loading: FC = () => { const { loading } = useFormStateStore(); return <div>loading: {String(loading)}</div>; };
const Disabled: FC = () => { const { disabled } = useFormStateStore(); return <div>disabled: {String(disabled)}</div>; };
const Main: FC = () => { const { setLoadingByAge } = useFormStateStore(); const [age, setAge] = useState(0);
return ( <div> <Loading /> <br /> <Disabled /> <br /> <Button onClick={() => { useFormStateStore.setState({ loading: true, }); }} > set loading true </Button> <Button onClick={() => { useFormStateStore.setState({ loading: false, }); }} > set loading false </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: true, }); }} > set disabled true </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: false, }); }} > set disabled false </Button> <br /> <input type="number" value={age} onChange={(e) => { setAge(Number(e.target.value)); }} /> <br /> <Button onClick={() => { setLoadingByAge(age); }} > set loading by age </Button> </div> ); };
export default Main;
|
\n\n在useFormStateStore.ts
中定义了状态,然后在app.tsx
中使用,就是这么简单粗暴!这里有几点介绍下:
\n\nZustand
使用非常简单,API也很少,它的原理是使用了Proxy
,所以它的性能非常好。
\n相比Redux
相比Redux,Zustand的代码非常简单明了,不需要使用connect
、mapStateToProps
、mapDispatchToProps
这些方法。
\n相比React Context
React Context需要一个Provider
包裹组件以传递状态,需要一个useContext
使用状态,光从层级上就让人绕起来了。而Zustand只需要一个create
方法,就可以使用了,且状态是全局的,不需要传递。
\n","site":{"data":{}},"excerpt":"","more":"原文链接:https://github.com/taoliujun/blog/issues/35
\n\n\n官方文档:https://docs.pmnd.rs/zustand
\n如何使用
Zustand 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:
\n\n\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { create } from 'zustand';
interface State { loading: boolean; disabled: boolean; setLoadingByAge: (value: number) => void; }
export const useFormStateStore = create<State>((set) => ({ loading: false, disabled: false, setLoadingByAge: (value) => { set({ loading: value > 10 }); }, }));
|
\n\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| import { useState, type FC } from 'react'; import { useFormStateStore } from './useFormStateStore'; import { Button } from '@/components/Button';
const Loading: FC = () => { const { loading } = useFormStateStore(); return <div>loading: {String(loading)}</div>; };
const Disabled: FC = () => { const { disabled } = useFormStateStore(); return <div>disabled: {String(disabled)}</div>; };
const Main: FC = () => { const { setLoadingByAge } = useFormStateStore(); const [age, setAge] = useState(0);
return ( <div> <Loading /> <br /> <Disabled /> <br /> <Button onClick={() => { useFormStateStore.setState({ loading: true, }); }} > set loading true </Button> <Button onClick={() => { useFormStateStore.setState({ loading: false, }); }} > set loading false </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: true, }); }} > set disabled true </Button> <Button onClick={() => { useFormStateStore.setState({ disabled: false, }); }} > set disabled false </Button> <br /> <input type="number" value={age} onChange={(e) => { setAge(Number(e.target.value)); }} /> <br /> <Button onClick={() => { setLoadingByAge(age); }} > set loading by age </Button> </div> ); };
export default Main;
|
\n\n在useFormStateStore.ts
中定义了状态,然后在app.tsx
中使用,就是这么简单粗暴!这里有几点介绍下:
\n\nZustand
使用非常简单,API也很少,它的原理是使用了Proxy
,所以它的性能非常好。
\n相比Redux
相比Redux,Zustand的代码非常简单明了,不需要使用connect
、mapStateToProps
、mapDispatchToProps
这些方法。
\n相比React Context
React Context需要一个Provider
包裹组件以传递状态,需要一个useContext
使用状态,光从层级上就让人绕起来了。而Zustand只需要一个create
方法,就可以使用了,且状态是全局的,不需要传递。
\n"}],"PostAsset":[],"PostCategory":[{"post_id":"clqxk9r600000joor84vf3yg8","category_id":"clqxk9r670003joorffjb03fe","_id":"clqxk9r690008joor4imo859j"},{"post_id":"clqxk9r640001joorghsabmh1","category_id":"clqxk9r680005joor13xp87sb","_id":"clqxk9r6a0009joor69azc42l"}],"PostTag":[{"post_id":"clqxk9r600000joor84vf3yg8","tag_id":"clqxk9r660002joorb3zc5fqo","_id":"clqxk9r690006joorhst22hj6"},{"post_id":"clqxk9r640001joorghsabmh1","tag_id":"clqxk9r680004joor9f4327js","_id":"clqxk9r6a000ajoorb4fqgt2j"},{"post_id":"clqxk9r640001joorghsabmh1","tag_id":"clqxk9r690007joor0mwh9cfx","_id":"clqxk9r6a000bjoor5eui8kh4"}],"Tag":[{"name":"github actions","_id":"clqxk9r660002joorb3zc5fqo"},{"name":"zustand","_id":"clqxk9r680004joor9f4327js"},{"name":"react store","_id":"clqxk9r690007joor0mwh9cfx"}]}}
\ No newline at end of file
diff --git a/src/source/_posts/github-actions-sample-eslint-in-pull-request.md b/src/source/_posts/github-actions-sample-eslint-in-pull-request.md
new file mode 100644
index 0000000..52d34b8
--- /dev/null
+++ b/src/source/_posts/github-actions-sample-eslint-in-pull-request.md
@@ -0,0 +1,751 @@
+---
+title: "7. GitHub Actions - 在pull request中执行eslint检测的工作流例子"
+date: "2023-12-29T03:56:49Z"
+categories:
+ - [工程化]
+
+url: github-actions-sample-eslint-in-pull-request
+tags:
+ - github actions
+
+---
+
+
+原文链接:[https://github.com/taoliujun/blog/issues/36](https://github.com/taoliujun/blog/issues/36)
+
+
+
+一个在pull request发起的时候执行eslint检测的workflow,[点此查看完整代码](https://github.com/taoliujun/npm-packages/blob/master/.github/workflows/check-pull-request.yml),它实现的功能如下:
+
+- 在pull request创建、更新的时候执行。
+- 先回复一个评论,告诉用户正在运行。
+- 初始化仓库,并安装依赖,产生依赖缓存。
+- 运行eslint增量检查。
+- 运行typescript检查。
+- 运行jest检查。
+- 更新之前的评论,回复检查的结果。
+
+运行截图:
+
+![Alt text](https://github.com/taoliujun/blog/assets/5689134/09c86bc1-ada1-41c3-9f8f-7e6c46f8204e)
+
+为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:
+
+* workflow,工作流,可以理解为yml文件。
+* jobs,工作,一个workflow可以包含多个job,并行执行。
+* steps,作业,一个job可以包含多个step,串行执行。
+* action,操作,作业中具体的执行。
+
+## 步骤
+
+- [初始化workflow](https://github.com/taoliujun/blog/issues/36#issuecomment-1871790603)
+- [reply checking](https://github.com/taoliujun/blog/issues/36#issuecomment-1871806576)
+- [./.github/actions/unique-comment](https://github.com/taoliujun/blog/issues/36#issuecomment-1871818126)
+- [init](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862632)
+- [eslint](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862779)
+- [typescript](https://github.com/taoliujun/blog/issues/36#issuecomment-1871862850)
+- [unit test](https://github.com/taoliujun/blog/issues/36#issuecomment-1871863037)
+- [reply result](https://github.com/taoliujun/blog/issues/36#issuecomment-1871863117)
+
+
+
+# 初始化workflow
+
+在项目中新建文件`.github/workflows/check-pull-request.yml`,内容如下:
+
+```yaml
+name: test check pull request
+run-name: 'check pull request #${{ github.event.pull_request.number }}'
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+jobs:
+ replyChecking:
+ runs-on: ubuntu-latest
+ steps:
+ - run: echo 'replyChecking'
+
+ init:
+ runs-on: ubuntu-latest
+ steps:
+ - run: echo 'init'
+
+ eslint:
+ runs-on: ubuntu-latest
+ needs: [init]
+ steps:
+ - run: echo 'eslint'
+
+ typescript:
+ runs-on: ubuntu-latest
+ needs: [init]
+ steps:
+ - run: echo 'typescript'
+
+ unitTest:
+ runs-on: ubuntu-latest
+ needs: [init]
+ steps:
+ - run: echo 'unitTest'
+
+ replyResult:
+ runs-on: ubuntu-latest
+ needs: [replyChecking, eslint, typescript, unitTest]
+ steps:
+ - run: echo 'replyResult'
+```
+
+## name和run-name
+
+给workflow命名为`check pull request`,它会出现在Actions页面的左侧菜单中。运行实例名为`check pull request #44`,出现在右侧的运行列表中。如图:
+
+![](https://github.com/taoliujun/blog/assets/5689134/c1371ff2-8fc3-4e5b-8b60-3c572419938b)
+
+`run-name`中的`${{ github.event.pull_request.number }}`是workflow的上下文,这里读取了上下文中的pr编号。
+
+## on
+
+`on`指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。
+
+## jobs
+
+按照设想,需要定义几个job,分别是:
+
+- replyChecking:回复用户正在检查中
+- init:初始化仓库,缓存依赖项
+- eslint:运行eslint检查
+- typescript:运行typescript检查
+- unitTest:运行单元测试
+- replyResult:回复用户检查结果
+
+`jobs`是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。
+
+其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了`needs`管理它们的执行依赖关系。
+
+### runs-on
+
+每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。
+
+## 测试
+
+发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:
+
+![](https://github.com/taoliujun/blog/assets/5689134/09c86bc1-ada1-41c3-9f8f-7e6c46f8204e)
+
+
+
+# replyChecking
+
+在进行eslint检测之前,先在pr里回复`checking`,并且带上拽酷炫的话。将replyChecking改成如下:
+
+```yaml
+replyChecking:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: ${{github.head_ref}}
+ - name: Get date time
+ id: getDateTime
+ run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT"
+ - name: Create or update a comment
+ uses: ./.github/actions/unique-comment
+ with:
+ uniqueIdentifier: ${{ github.workflow }}
+ body: |
+ **Checking...**
+
+ ---
+
+ Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
+```
+
+`steps`每一步里`name`、`id`是可选的,`name`在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。
+
+## Checkout
+
+`uses`表示使用一个action,名为`actions/checkout@v4`,它用来拉取仓库。
+
+> 同其他编程语言一样,重复的action可以封装起来。[action市场](https://github.com/marketplace?type=actions)提供了很多。
+
+`with`属性指定了该action的输入参数,每个action的参数不尽相同。
+
+`ref`参数表示要拉取的分支,`${{github.head_ref}}`也是一个上下文,表示当前pr的源分支。
+
+
+## Get Date time
+
+这step还写了`id`,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据`id`读取到它的`output`。
+
+> **output**是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。
+
+`run`就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到`$GITHUB_OUTPUT`中,键名为`result`。
+
+> `$GITHUB_OUTPUT`是workflow注入到容器中的一个路径,用于存放output。
+
+## Create or update a comment
+
+`uses`使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。
+
+> 有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。
+
+同理,该action也有`with`属性,`uniqueIdentifier`是回复评论的唯一标识,`body`是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说`${{steps.getDateTime.outputs.result}}`这个上下文表示获取getDateTime这个step中,键名为`result`的值。
+
+如果你不需要在内容里插入时间,那么上面的`Get Date time`就可以省略了。
+
+## 测试
+
+因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:
+
+![](https://github.com/taoliujun/blog/assets/5689134/42396a84-b798-4f4e-9f39-5bf92a8acb15)
+
+
+# ./.github/actions/unique-comment
+
+这是一个封装的javascript action,用于对issue创建、更新唯一评论。
+
+## 目录结构
+
+创建目录`./.github/actions/unique-comment`,最终目录结构如下:
+
+```bash
+.
+├── action.yml
+├── config
+│ └── webpack.config.js
+├── dist
+│ ├── index.js
+│ └── index.js.LICENSE.txt
+├── package.json
+└── src
+ └── index.js
+```
+
+## action.yml
+
+这是action的配置文件,必须存在,内容如下:
+
+```yaml
+name: unique-comment
+description: create or update a unique comment
+
+runs:
+ using: 'node20'
+ main: './dist/index.js'
+
+inputs:
+ token:
+ description: 'GitHub token'
+ required: false
+ default: ${{ github.token }}
+ owner:
+ description: 'Repository owner'
+ required: false
+ default: ${{ github.event.repository.owner.login }}
+ repo:
+ description: 'Repository name'
+ required: false
+ default: ${{ github.event.repository.name }}
+ issue_number:
+ description: 'Issue number'
+ required: false
+ default: ${{ github.event.number }}
+ body:
+ description: 'Comment body'
+ required: false
+ uniqueIdentifier:
+ description: 'Unique identifier for comment'
+ required: false
+ default: 'unique-comment'
+```
+
+大部分属性不一一细讲了,都是简单的英文望文生义即可。
+
+`runs`表示运行在`node20`环境下,入口文件为`./dist/index.js`。
+
+`inputs`表示接受的参数,也就是之前提到的`with`属性里要输入的参数。用`required`表示是否必须传入,`default`表示默认值。
+
+## src/index.js
+
+为什么入口文件是`dist/index.js`,而不是`src/index.js`呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好`src/index.js`,再打包就行。
+
+该文件代码如下:
+
+```javascript
+const core = require('@actions/core');
+const github = require('@actions/github');
+
+const main = async () => {
+ const token = core.getInput('token');
+ const owner = core.getInput('owner');
+ const repo = core.getInput('repo');
+ const issueNumber = core.getInput('issue_number');
+ const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`;
+ const body = `${core.getInput('body')}\n\n${uniqueIdentifier}`;
+
+ core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);
+
+ const octokit = github.getOctokit(token);
+
+ const comments = await octokit.rest.issues.listComments({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ });
+
+ const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));
+
+ if (botComment) {
+ core.info('update comment successfully.');
+ await octokit.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: botComment.id,
+ body,
+ });
+ } else {
+ core.info('create comment successfully.');
+ await octokit.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ body,
+ });
+ }
+};
+
+try {
+ main();
+} catch (err) {
+ core.setFailed(err.message);
+}
+```
+
+`@actions/core`和`@actions/github`是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。
+
+`main`函数的代码就是原生javascript,不一一解释了,主要通过`uniqueIdentifier`来判断是否发布过评论,如果是,就更新评论,否则就创建评论。
+
+> markdown语法`[^uniqueIdentifier]`表示脚注,不会被渲染。
+
+`core.setFailed(err.message);`表示抛出退出代码。
+
+## config/webpack.config.js
+
+打包用的,配置简单可用即可:
+
+```javascript
+module.exports = {
+ mode: 'production',
+ target: 'node20',
+ entry: './src/index.js',
+ output: {
+ filename: 'index.js',
+ clean: true,
+ },
+};
+```
+
+## package.json
+
+```json
+{
+ "name": "unique-comment",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "build": "webpack --config ./config/webpack.config.js"
+ },
+ "dependencies": {
+ "@actions/core": "^1.10.1",
+ "@actions/github": "^6.0.0"
+ },
+ "devDependencies": {
+ "webpack": "^5.89.0",
+ "webpack-cli": "^5.1.4"
+ }
+}
+```
+
+没啥好说的,列出了依赖项。和一个打包脚本。
+
+## 测试
+
+修改了`src/index.js`得`build`,然后push到github仓库。
+
+记得将**dist**目录也提交到github仓库。
+
+
+
+# init
+
+现在,开始搞正经的了。
+
+先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要`needs`这个job。
+
+将init改成如下:
+
+```yaml
+init:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Init repo
+ uses: actions/checkout@v4
+ with:
+ ref: ${{github.head_ref}}
+
+ - name: Init pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 8
+
+ - name: Init node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install
+```
+
+相信经过对之前的job的了解,这里的配置就看起来很简单了。
+
+## Init pnpm
+
+使用第三方action,安装pnpm@^8。
+
+## Init node
+
+`cache: 'pnpm'`指定缓存机制,它内部是利用了workflow的cache机制。
+
+## Install dependencies
+
+安装依赖项,触发缓存。
+
+
+# eslint
+
+将eslint改成如下:
+
+```yaml
+eslint:
+ runs-on: ubuntu-latest
+ needs: [init]
+ outputs:
+ result: ${{ steps.lint.outputs.result }}
+ steps:
+ - name: Init repo
+ uses: actions/checkout@v4
+ with:
+ ref: ${{github.head_ref}}
+ fetch-depth: 0
+
+ - name: Init pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 8
+
+ - name: Init node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Run eslint
+ id: lint
+ uses: actions/github-script@v7
+ with:
+ result-encoding: string
+ script: |
+ let output = '';
+ let outerr = '';
+ let diffFiles = '';
+
+ await exec.exec(
+ `git diff --name-only origin/${{github.base_ref}}`,
+ [],
+ {
+ // silent: true,
+ // ignoreReturnCode: true,
+ listeners: {
+ stdout: (data) => {
+ diffFiles += data.toString();
+ },
+ },
+ }
+ );
+
+ const lintFiles = diffFiles.split(`\n`).filter((file) => {
+ return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx')
+ }).join(' ');
+
+ await exec.exec(
+ // "pnpm run lint --format stylish",
+ `pnpm eslint ${lintFiles}`,
+ [],
+ {
+ // silent: true,
+ ignoreReturnCode: true,
+ listeners: {
+ stdout: (data) => {
+ output += data.toString();
+ },
+ stderr: (data) => {
+ outerr += data.toString();
+ },
+ },
+ }
+ );
+
+ if (outerr) {
+ return `:x: Some command execution errors, non-eslint business errors.`;
+ }
+
+ const errorMatch = output.match(/(\d+) errors?/);
+ const warnMatch = output.match(/(\d+) warnings?/);
+
+ if (errorMatch && errorMatch?.[1] !== '0') {
+ return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`;
+ }
+
+ return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;
+```
+
+## needs
+
+使用`needs`依赖init,可以使用到pnpm的缓存项,防止install太慢。
+
+> 因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。
+
+## outputs
+
+job里的outputs,可以在依赖它的其他job中访问到。这里使用`${{ steps.lint.outputs.result }}`去获取该job中lint这个step里的output里的result。
+
+> output有job和step两个维度,注意区分。
+
+
+## Run eslint
+
+它uses了`actions/github-script@v7`,这是github官方提供的一个action,可以在`with.script`里写js代码去执行,同时它会注入一些变量到script中去,见它的[官方文档](https://github.com/actions/github-script/tree/v7/)。
+
+> 对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。
+
+`result-encoding`是指定script返回的数据格式的,默认是json,这指定为string。
+
+> 为什么script里return了string,还要指定为string呢?
+> 因为`return 'hello'`在json encode后是`'"hello"'`,而string encode后为`'hello'`。
+
+script里是原生的js代码了,里面的`exec`是该action注入的变量,用来执行shell命令。
+
+这段js代码做了两个事情,一是`git diff`获取pr中改动的文件列表,二是`eslint`检查这些增量文件,最后返回处理的结果。
+
+## fetch-depth
+
+Init repo这个step里设置了`fetch-depth: 0`,不然获取不到完整的git分支,具体看`actions/checkout`的解释,涉及到git的知识不展开细说了。
+
+## steps.lint.outputs.result
+
+`steps.lint.outputs.result`为什么能拿到lint step里的output.result呢?因为`actions/github-script`这个action内部将script的返回值,设置到`$GITHUB_OUTPUT`里了,且键名为`result`。
+
+
+# typescript
+
+和eslint的配置大同小异,只是改了对检测结果的判断。
+
+```yaml
+typescript:
+ runs-on: ubuntu-latest
+ needs: [init]
+ outputs:
+ result: ${{ steps.lint.outputs.result }}
+ steps:
+ - name: Init repo
+ uses: actions/checkout@v4
+ with:
+ ref: ${{github.head_ref}}
+
+ - name: Init pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 8
+
+ - name: Init node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Run lint
+ id: lint
+ uses: actions/github-script@v7
+ with:
+ result-encoding: string
+ script: |
+ let output = '';
+ let outerr = '';
+
+ await exec.exec(
+ `pnpm run -r lint:ts`,
+ [],
+ {
+ // silent: true,
+ ignoreReturnCode: true,
+ listeners: {
+ stdout: (data) => {
+ output += data.toString();
+ },
+ stderr: (data) => {
+ outerr += data.toString();
+ },
+ },
+ }
+ );
+
+ if (outerr) {
+ return `:x: Some command execution errors, no business errors.`;
+ }
+
+ const errorMatch = output.match(/error TS/g);
+
+ if (errorMatch) {
+ return `:x: ${errorMatch?.length} errors`;
+ }
+
+ return `:white_check_mark: ${'0 error'}`;
+```
+
+
+# unitTest
+
+和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。
+
+```yaml
+unitTest:
+ runs-on: ubuntu-latest
+ needs: [init]
+ outputs:
+ result: ${{ steps.lint.outputs.result }}
+ steps:
+ - name: Init repo
+ uses: actions/checkout@v4
+ with:
+ ref: ${{github.head_ref}}
+
+ - name: Init pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 8
+
+ - name: Init node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: |
+ pnpm remove @nike/eslint-multi-formatter || true
+ pnpm remove @nike/svg-packer || true
+ pnpm install
+
+ - name: Run lint
+ id: lint
+ uses: actions/github-script@v7
+ with:
+ result-encoding: string
+ script: |
+ let output = '';
+ let outerr = '';
+
+ await exec.exec(
+ `pnpm run test`,
+ [],
+ {
+ // silent: true,
+ ignoreReturnCode: true,
+ listeners: {
+ stdout: (data) => {
+ output += data.toString();
+ },
+ stderr: (data) => {
+ outerr += data.toString();
+ },
+ },
+ }
+ );
+
+ // why use outerr? https://github.com/jestjs/jest/issues/5064
+
+ const failMatch = outerr.match(/Test Suites: \d+ failed/);
+
+ if (failMatch) {
+ return `:x: ${failMatch?.[0]}`;
+ }
+
+ const errorMatch = outerr.match(/Jest: "global" coverage threshold for lines \([0-9\.]+%\) not met: [0-9\.]+%/);
+
+ if (errorMatch) {
+ return `:x: ${errorMatch?.[0]}`;
+ }
+
+ return `:white_check_mark: passed`;
+```
+
+
+# replyResult
+
+最后,将几个检测的结果进行汇总,回复到pr里就行了。
+
+```yaml
+replyResult:
+ runs-on: ubuntu-latest
+ needs: [replyChecking, eslint, typescript, unitTest]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: ${{github.head_ref}}
+ - name: Get date time
+ id: getDateTime
+ run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT"
+ - name: Create or update a comment
+ uses: ./.github/actions/unique-comment
+ with:
+ uniqueIdentifier: ${{ github.workflow }}
+ body: |
+ ## Eslint Check Result
+
+ ${{needs.eslint.outputs.result}}
+
+ ## Typescript Check Result
+
+ ${{needs.typescript.outputs.result}}
+
+ ## UnitTest Check Result
+
+ ${{needs.unitTest.outputs.result}}
+
+ ---
+
+ Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.
+```
+
+和replyChecking差不多,在body里使用`${{needs.eslint.outputs.result}}`去读取了eslint job的outputs。
+
+## 测试
+
+去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~
+
diff --git a/src/source/_posts/react-zustand.md b/src/source/_posts/react-zustand.md
new file mode 100644
index 0000000..7edecaa
--- /dev/null
+++ b/src/source/_posts/react-zustand.md
@@ -0,0 +1,160 @@
+---
+title: "React公共状态利器 - Zustand"
+date: "2023-12-13T06:46:26Z"
+categories:
+ - [React]
+
+url: react-zustand
+tags:
+ - zustand
+ - react store
+
+---
+
+
+原文链接:[https://github.com/taoliujun/blog/issues/35](https://github.com/taoliujun/blog/issues/35)
+
+
+
+官方文档:https://docs.pmnd.rs/zustand
+
+# 如何使用
+
+**Zustand** 是一个非常简单粗暴的全局状态管理库,它的使用有多简单呢?如下:
+
+```bash
+> pnpm add zustand
+```
+
+```ts
+// useFormStateStore.ts
+import { create } from 'zustand';
+
+interface State {
+ loading: boolean;
+ disabled: boolean;
+ setLoadingByAge: (value: number) => void;
+}
+
+export const useFormStateStore = create((set) => ({
+ loading: false,
+ disabled: false,
+ setLoadingByAge: (value) => {
+ set({ loading: value > 10 });
+ },
+}));
+```
+
+```tsx
+// app.tsx
+import { useState, type FC } from 'react';
+import { useFormStateStore } from './useFormStateStore';
+import { Button } from '@/components/Button';
+
+const Loading: FC = () => {
+ const { loading } = useFormStateStore();
+ return loading: {String(loading)}
;
+};
+
+const Disabled: FC = () => {
+ const { disabled } = useFormStateStore();
+ return disabled: {String(disabled)}
;
+};
+
+const Main: FC = () => {
+ const { setLoadingByAge } = useFormStateStore();
+ const [age, setAge] = useState(0);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ setAge(Number(e.target.value));
+ }}
+ />
+
+
+
+ );
+};
+
+export default Main;
+```
+
+在`useFormStateStore.ts`中定义了状态,然后在`app.tsx`中使用,就是这么简单粗暴!这里有几点介绍下:
+
+- 对于简单的状态更新,使用`setState`方法就可以,它的参数是一个对象,这个对象就是你要更新的状态,它会和之前的状态进行合并,然后返回一个新的状态,从而触发组件更新。
+
+- 对于需要通用的逻辑处理的状态更新,参照`useFormStateStore.ts`中的`setLoadingByAge`方法,将它作为状态里的一个方法就行了。
+
+# Zustand
+
+使用非常简单,API也很少,它的原理是使用了`Proxy`,所以它的性能非常好。
+
+# 相比Redux
+
+相比Redux,Zustand的代码非常简单明了,不需要使用`connect`、`mapStateToProps`、`mapDispatchToProps`这些方法。
+
+# 相比React Context
+
+React Context需要一个`Provider`包裹组件以传递状态,需要一个`useContext`使用状态,光从层级上就让人绕起来了。而Zustand只需要一个`create`方法,就可以使用了,且状态是全局的,不需要传递。
+
+
+
+