@askexenow/exe-os 0.9.384 → 0.9.389
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deploy/compose/.env.askexe-control-plane.example +6 -0
- package/deploy/compose/.env.customer.example +1 -1
- package/deploy/compose/.env.example +1 -1
- package/deploy/compose/docker-compose.yml +16 -0
- package/deploy/compose/generate-env.ts +1 -1
- package/deploy/compose/sso-edge/sso-redirect.conf +18 -3
- package/deploy/stack-manifests/v0.9.json +1160 -1
- package/dist/.dist-verified.json +3 -3
- package/dist/{active-agent-F5V4E4UL.js → active-agent-RZ24KSJN.js} +8 -6
- package/dist/{active-agent-YTBB4ZQM.js → active-agent-YWSWPIEA.js} +8 -6
- package/dist/{agent-defs-generator-7TFISIJQ.js → agent-defs-generator-GY3UBREJ.js} +8 -6
- package/dist/{agent-heartbeat-CQPGJIFF.js → agent-heartbeat-GUVUUQVM.js} +1 -1
- package/dist/{agent-loop-EZ7LDAGD.js → agent-loop-HPNCH3HK.js} +1 -1
- package/dist/{agentic-ontology-XVTUVV7W.js → agentic-ontology-RHYNXEKE.js} +1 -1
- package/dist/{artifact-backup-TF6YWQT2.js → artifact-backup-HGUFSIJH.js} +19 -6
- package/dist/{backfill-metadata-J6MXIDCW.js → backfill-metadata-XLFASEOD.js} +11 -9
- package/dist/{backup-verify-6DVBQMDP.js → backup-verify-TUQEICWA.js} +1 -1
- package/dist/{behavior-injection-XDA7ZCYN.js → behavior-injection-GXO7ERGN.js} +14 -12
- package/dist/{behavior-promotion-4VLQ7LXK.js → behavior-promotion-V63WIZ4N.js} +13 -11
- package/dist/{behaviors-SCCZP7RZ.js → behaviors-RMLDPLOF.js} +18 -11
- package/dist/bin/agentic-ontology-backfill.js +12 -10
- package/dist/bin/agentic-reflection-backfill.js +13 -11
- package/dist/bin/agentic-semantic-label.js +12 -10
- package/dist/bin/backfill-conversations.js +11 -9
- package/dist/bin/backfill-responses.js +11 -9
- package/dist/bin/backfill-vectors.js +13 -11
- package/dist/bin/bulk-sync-postgres.js +20 -18
- package/dist/bin/cc-doctor.js +15 -13
- package/dist/bin/cleanup-stale-review-tasks.js +44 -32
- package/dist/bin/cli.js +109 -30
- package/dist/bin/customer-readiness.js +55 -25
- package/dist/bin/deferred-daemon-restart.js +111 -108
- package/dist/bin/exe-actions.js +7 -1
- package/dist/bin/exe-agent-config.js +8 -6
- package/dist/bin/exe-agent.js +18 -16
- package/dist/bin/exe-assign.js +13 -11
- package/dist/bin/exe-boot.js +49 -47
- package/dist/bin/exe-call.js +9 -7
- package/dist/bin/exe-cicd.js +19 -8
- package/dist/bin/exe-cloud.js +59 -25
- package/dist/bin/exe-config-dump.js +8 -6
- package/dist/bin/exe-dispatch.js +23 -21
- package/dist/bin/exe-doctor.js +11 -8
- package/dist/bin/exe-export-behaviors.js +16 -14
- package/dist/bin/exe-forget.js +14 -12
- package/dist/bin/exe-gateway.js +12 -10
- package/dist/bin/exe-grant.js +6 -6
- package/dist/bin/exe-healthcheck.js +15 -13
- package/dist/bin/exe-heartbeat.js +28 -26
- package/dist/bin/exe-kill.js +28 -26
- package/dist/bin/exe-launch-agent.js +35 -33
- package/dist/bin/exe-mcp.js +9 -7
- package/dist/bin/exe-memory-reads.js +11 -9
- package/dist/bin/exe-new-employee.js +13 -11
- package/dist/bin/exe-os-mcp-proxy.js +9 -7
- package/dist/bin/exe-pending-messages.js +23 -21
- package/dist/bin/exe-pending-notifications.js +22 -20
- package/dist/bin/exe-pending-reviews.js +23 -21
- package/dist/bin/exe-preflight.js +1 -1
- package/dist/bin/exe-rename.js +9 -7
- package/dist/bin/exe-review.js +59 -55
- package/dist/bin/exe-search-quality.js +16 -3
- package/dist/bin/exe-search.js +13 -11
- package/dist/bin/exe-session-cleanup.js +29 -27
- package/dist/bin/exe-settings.js +22 -20
- package/dist/bin/exe-start-codex.js +23 -21
- package/dist/bin/exe-start-opencode.js +21 -19
- package/dist/bin/exe-status.js +24 -22
- package/dist/bin/exe-support.js +3 -3
- package/dist/bin/exe-team.js +8 -6
- package/dist/bin/exe-verify-running-images.js +644 -17
- package/dist/bin/git-sweep.js +24 -22
- package/dist/bin/graph-backfill.js +12 -10
- package/dist/bin/graph-export.js +12 -10
- package/dist/bin/hooks-cli.js +4 -4
- package/dist/bin/import-history.js +16 -14
- package/dist/bin/install.js +113 -110
- package/dist/bin/intercom-check.js +5 -5
- package/dist/bin/mcp-sessions.js +2 -2
- package/dist/bin/orchestration-metrics.js +8 -6
- package/dist/bin/postgres-agentic-reflection-backfill.js +2 -2
- package/dist/bin/postgres-agentic-semantic-backfill.js +1 -1
- package/dist/bin/pre-build-guard.js +55 -16
- package/dist/bin/pre-publish.js +1 -1
- package/dist/bin/prune-orphan-chunks.js +154 -28
- package/dist/bin/reconcile-backlog.js +5 -3
- package/dist/bin/registry-proxy.js +1 -1
- package/dist/bin/repair-document-chunks.js +11 -9
- package/dist/bin/scan-tasks.js +23 -21
- package/dist/bin/setup.js +3 -3
- package/dist/bin/shard-migrate.js +12 -10
- package/dist/bin/stack-update.js +622 -43
- package/dist/bin/verify-stack.js +2 -2
- package/dist/bin/vps-health-gate.js +1 -1
- package/dist/{boot-generation-Q7LGQ3AO.js → boot-generation-VCQXVYGT.js} +7 -5
- package/dist/{boot-review-sections-G5774FR5.js → boot-review-sections-3QO6Q7MP.js} +21 -19
- package/dist/{branch-hygiene-44WLSOZR.js → branch-hygiene-DDXXI6ZA.js} +59 -21
- package/dist/bug-landed-reconciler-TQVNOUCU.js +353 -0
- package/dist/build-manifest.json +1269 -1194
- package/dist/{capability-cards-I3RHCF4F.js → capability-cards-XPDLT6LU.js} +6 -4
- package/dist/{capacity-monitor-7I7GDONU.js → capacity-monitor-N3AW242L.js} +23 -21
- package/dist/{catchup-brief-3T5PARIR.js → catchup-brief-3PKX7FUP.js} +29 -27
- package/dist/{chunk-C5ISP5FO.js → chunk-2BKJAOO4.js} +11 -1
- package/dist/{chunk-JJTPPAZY.js → chunk-2CUQHG6H.js} +1609 -1356
- package/dist/{chunk-HU3VD4MQ.js → chunk-2Q2GGYBX.js} +1 -1
- package/dist/{chunk-CHIUS5QB.js → chunk-36LMIOVU.js} +9 -9
- package/dist/{chunk-46VSBDUW.js → chunk-3DCBDG4T.js} +2 -2
- package/dist/{chunk-W3KPQE7H.js → chunk-3GL3E4TD.js} +3 -3
- package/dist/{chunk-GL47M7NY.js → chunk-3UD3OF3X.js} +202 -117
- package/dist/{chunk-WNBZJSQT.js → chunk-3VW5ZP2F.js} +1 -1
- package/dist/{chunk-JDDM6C57.js → chunk-3X74IQBU.js} +1 -1
- package/dist/{chunk-NVFNADTX.js → chunk-3ZL4MDQ6.js} +18 -4
- package/dist/{chunk-R5E7WNAC.js → chunk-4ESNDGMB.js} +3 -3
- package/dist/{chunk-CYCIX7AW.js → chunk-4FLLIQRQ.js} +2 -2
- package/dist/{chunk-3RGTLZNO.js → chunk-4GAKF7KR.js} +99 -74
- package/dist/{chunk-RVO5O5SR.js → chunk-4S3A52AV.js} +38 -10
- package/dist/{chunk-7J2ZQ4XN.js → chunk-4VQ3QR6B.js} +29 -28
- package/dist/{chunk-MROSARM5.js → chunk-5BJBTFFQ.js} +7 -1
- package/dist/chunk-5LQZABW5.js +6 -0
- package/dist/chunk-5ONMHTNI.js +236 -0
- package/dist/{chunk-APL7HUE3.js → chunk-5QQJUO3F.js} +200 -31
- package/dist/{chunk-UOPCVFVR.js → chunk-5RK75SWH.js} +205 -278
- package/dist/{chunk-T5FEAQES.js → chunk-5TYJYGS5.js} +1 -1
- package/dist/{chunk-P3QQ52QK.js → chunk-5U6EOTZV.js} +3 -3
- package/dist/{chunk-ALRHGEAJ.js → chunk-637FL62O.js} +1 -1
- package/dist/{chunk-PUBA5B4M.js → chunk-6L4VIYLZ.js} +33 -19
- package/dist/{chunk-VF2KRROX.js → chunk-754P2UXM.js} +1 -1
- package/dist/{chunk-CM7N5MBY.js → chunk-7FZG7WP4.js} +5 -5
- package/dist/{chunk-NYLJQ2M2.js → chunk-7NEBFB3L.js} +4 -4
- package/dist/{chunk-E7C2WNR5.js → chunk-AEIOFFFX.js} +5 -5
- package/dist/{chunk-VOVFUKEB.js → chunk-AIB2EWO2.js} +5 -5
- package/dist/{chunk-EGOEB42T.js → chunk-AP2C4OGS.js} +3 -3
- package/dist/{chunk-MPZ2AVNS.js → chunk-ATNV3BVZ.js} +1 -1
- package/dist/{chunk-F4BBMIPG.js → chunk-B4XJQBQB.js} +2 -2
- package/dist/{chunk-BPVFZ6Q4.js → chunk-B5EGDNG6.js} +2 -2
- package/dist/{chunk-AGC2V7JS.js → chunk-B6CBN6EF.js} +1 -1
- package/dist/{chunk-KTKIFXIP.js → chunk-B7FDRN7G.js} +1 -1
- package/dist/{chunk-C5HUMAA7.js → chunk-B7WVYLAQ.js} +67 -15
- package/dist/chunk-BGVP5RMT.js +185 -0
- package/dist/{chunk-FVLNNNH6.js → chunk-BNI4LIU5.js} +35 -4
- package/dist/{chunk-P4XLIPB7.js → chunk-BU5JRLZT.js} +1 -1
- package/dist/{chunk-PHUHIVD5.js → chunk-C6U7XWOC.js} +34 -5
- package/dist/chunk-C7GHNJPW.js +8 -0
- package/dist/{chunk-4AR5XKCH.js → chunk-CL2MU3OX.js} +1 -1
- package/dist/{chunk-ZPP63JSQ.js → chunk-CL3AB7Z2.js} +1 -1
- package/dist/{chunk-FHIFTSPL.js → chunk-CP7XUSOF.js} +8 -3
- package/dist/{chunk-3OX34OC3.js → chunk-CUDNRHHT.js} +30 -10
- package/dist/chunk-CYUUNIEL.js +21 -0
- package/dist/{chunk-P2UY34XE.js → chunk-D4C57S2H.js} +5 -5
- package/dist/{chunk-XSUALA32.js → chunk-DDYFNBYV.js} +3 -3
- package/dist/{chunk-LSNBTGW4.js → chunk-DPYZW42H.js} +1 -1
- package/dist/{chunk-FMIYFHLE.js → chunk-E2PRXEZK.js} +9 -3
- package/dist/{chunk-L63V25HS.js → chunk-E54SPUYJ.js} +3 -3
- package/dist/{chunk-DMUOTCDN.js → chunk-E5CKRHIZ.js} +1 -1
- package/dist/{chunk-BV2IET6B.js → chunk-EVMOKRBC.js} +15 -2
- package/dist/{chunk-YLFCH3VA.js → chunk-FDWI3T6I.js} +133 -31
- package/dist/{chunk-PWC3C5ME.js → chunk-FNJTCJGY.js} +4 -4
- package/dist/{chunk-TNS63ATU.js → chunk-FWRPDSO3.js} +350 -50
- package/dist/{chunk-JA3Y5TV6.js → chunk-GDLB6BPM.js} +141 -74
- package/dist/{chunk-IGJMS7C5.js → chunk-GOVGG7HU.js} +2 -2
- package/dist/{chunk-MB47DRF5.js → chunk-GP7CTIUK.js} +2 -2
- package/dist/{chunk-L5EPGGDP.js → chunk-GWAN2BDB.js} +4 -4
- package/dist/{chunk-YRQ26MNP.js → chunk-H3YF7X4J.js} +12 -5
- package/dist/{chunk-VLBHZ6TF.js → chunk-H6PLIFUE.js} +2 -2
- package/dist/{chunk-GSLFH4AI.js → chunk-HRNM3N4Y.js} +6 -15
- package/dist/chunk-IG2ISRTF.js +48 -0
- package/dist/{chunk-MWEIBVBS.js → chunk-IJCWBYA5.js} +14 -1
- package/dist/{chunk-NKEFYE4R.js → chunk-IPTUKX3N.js} +1 -1
- package/dist/{chunk-RB3J2WYQ.js → chunk-IVE7H6KA.js} +1 -1
- package/dist/{chunk-HA63SH5W.js → chunk-IWY2OGQG.js} +1 -1
- package/dist/{chunk-BQEZPRUC.js → chunk-IZWMWJZT.js} +64 -46
- package/dist/{chunk-AHESD3KW.js → chunk-J4UVI652.js} +1 -1
- package/dist/{chunk-PLII76NJ.js → chunk-JBUCA4OX.js} +33 -6
- package/dist/{chunk-2PZTMSMG.js → chunk-JOF4HQDF.js} +5 -5
- package/dist/{chunk-OBBO6EJR.js → chunk-JQN3QGSF.js} +1 -1
- package/dist/{chunk-P4PIZOL2.js → chunk-JWYIH5DR.js} +3 -3
- package/dist/{chunk-3XY57PKT.js → chunk-KZLKE7I3.js} +210 -48
- package/dist/{chunk-DYVLEMKS.js → chunk-L3GXMYBK.js} +1 -1
- package/dist/{chunk-72V3S44T.js → chunk-LUA4SLJY.js} +46 -19
- package/dist/{chunk-ZT4LFTUJ.js → chunk-LW3JCBXP.js} +20 -3
- package/dist/{chunk-YLB365YX.js → chunk-M5XHN4OM.js} +3 -3
- package/dist/{chunk-VHHX4UCP.js → chunk-MDTWARSF.js} +22 -93
- package/dist/{chunk-JFJP6ATJ.js → chunk-MWKXQSF6.js} +3 -2
- package/dist/{chunk-VJEJ7JNB.js → chunk-N32DVIKY.js} +1 -1
- package/dist/{chunk-MUCNYSJP.js → chunk-NRLJOV7M.js} +32 -4
- package/dist/{chunk-PL4CFSQ7.js → chunk-NSXJOSH7.js} +1 -1
- package/dist/{chunk-NF5FOSLD.js → chunk-NVTG66CK.js} +1 -1
- package/dist/chunk-O45GBJEH.js +1500 -0
- package/dist/{chunk-4AAMFWJT.js → chunk-OHZSTZXN.js} +4 -2
- package/dist/chunk-OSIS7ZQW.js +344 -0
- package/dist/{chunk-E3ZM32LF.js → chunk-P22VFGFK.js} +14 -5
- package/dist/{chunk-KILR4FBR.js → chunk-PAFLR7WD.js} +3 -3
- package/dist/{chunk-R3CZWFFC.js → chunk-PEYQ6MWO.js} +15 -3
- package/dist/{chunk-RDWIKDMY.js → chunk-PZR54BEI.js} +8 -20
- package/dist/{chunk-2FKU4ZEO.js → chunk-QDBKMEYY.js} +9 -9
- package/dist/{chunk-7UHZBVP3.js → chunk-QDVLVLXP.js} +97 -31
- package/dist/{chunk-PRDDPQLC.js → chunk-QLXEQRHD.js} +5 -5
- package/dist/{chunk-MUAWCFW6.js → chunk-QNPDXV5V.js} +5 -2
- package/dist/{chunk-LR5O2XWZ.js → chunk-QUZYLDNJ.js} +5 -5
- package/dist/chunk-RBQUXHYK.js +48 -0
- package/dist/{chunk-3SUPEE6H.js → chunk-RDMBSHGG.js} +3 -3
- package/dist/{chunk-I5XIW2BP.js → chunk-RGUM474V.js} +7 -7
- package/dist/{chunk-OAFENIHN.js → chunk-RJ67XAJU.js} +76 -28
- package/dist/{chunk-LFUBEHFW.js → chunk-RT57D7C4.js} +39 -1
- package/dist/{chunk-MKM6D7JM.js → chunk-RWG7DLOG.js} +42 -5
- package/dist/{chunk-P3UX5EH4.js → chunk-RWKAHHIX.js} +3 -3
- package/dist/{chunk-XEBKIWI7.js → chunk-RYNT3HB7.js} +12 -12
- package/dist/{chunk-EUGEFBGV.js → chunk-S2V272KJ.js} +3 -3
- package/dist/{chunk-W6BZ4UGA.js → chunk-SGYEM2PD.js} +1 -1
- package/dist/{chunk-VROK4X2L.js → chunk-SJP57MQM.js} +1 -1
- package/dist/{chunk-5MVKTNZP.js → chunk-SL4JWAMQ.js} +54 -4
- package/dist/{chunk-MOANZQXF.js → chunk-SLMKLEU6.js} +1 -1
- package/dist/{chunk-RZN2LEAK.js → chunk-SQA7XCYI.js} +3 -3
- package/dist/{chunk-4YNA3CM6.js → chunk-SSFS4NCH.js} +262 -56
- package/dist/{chunk-6FIC57PH.js → chunk-T4P2G3PO.js} +1 -1
- package/dist/{chunk-5U2IJBFV.js → chunk-TA3B4N24.js} +1 -1
- package/dist/{chunk-ZOTTYCP5.js → chunk-TEWWBVQP.js} +40 -4
- package/dist/{chunk-SDYWZRPN.js → chunk-TLSFGXXE.js} +1 -1
- package/dist/{chunk-L3VUW5M4.js → chunk-TQS2YHLB.js} +1 -1
- package/dist/{chunk-KXYKSLW4.js → chunk-TU4M757W.js} +399 -116
- package/dist/{chunk-MC4IM2CC.js → chunk-TYD4F4XU.js} +6 -6
- package/dist/{chunk-TKS5GMYA.js → chunk-U44Y7RC7.js} +2 -0
- package/dist/{chunk-B7AMNZTC.js → chunk-UKBPEEEQ.js} +2 -2
- package/dist/{chunk-ZBPZDADW.js → chunk-UWVXQEQQ.js} +467 -78
- package/dist/{chunk-P2VVMGMI.js → chunk-V4WH3FV7.js} +35 -16
- package/dist/{worktree-ownership-guard-O6JQF2CL.js → chunk-V6F4I2XY.js} +13 -14
- package/dist/{chunk-JFTUAV4J.js → chunk-VBKUESIW.js} +1 -1
- package/dist/{chunk-AZSTIAVL.js → chunk-VBPYYJPC.js} +1 -1
- package/dist/{chunk-LKTN22L6.js → chunk-VIP4LKLO.js} +57 -24
- package/dist/{chunk-L5CGXEZC.js → chunk-WAAGVFFN.js} +63 -71
- package/dist/{chunk-D3LXVAIQ.js → chunk-WC7AE3PT.js} +244 -103
- package/dist/{chunk-EOTZSAQL.js → chunk-WUCUDHKR.js} +1 -1
- package/dist/{chunk-VF3BZOZK.js → chunk-X7HZHKY4.js} +50 -3
- package/dist/{chunk-LFBLQE2P.js → chunk-XCAKYVKI.js} +1 -1
- package/dist/{chunk-UXN4BICC.js → chunk-XMX5J52Z.js} +1 -1
- package/dist/{chunk-6YDY6ZTD.js → chunk-XTCXGWTV.js} +9 -4
- package/dist/{chunk-XUZK23V7.js → chunk-XW2FW6LS.js} +2 -2
- package/dist/{chunk-CH7BUBP3.js → chunk-XWVHUDEK.js} +5 -4
- package/dist/{chunk-S26CV7BE.js → chunk-YA5LK6KB.js} +45 -8
- package/dist/{chunk-ELCRJGCH.js → chunk-YMRQIENV.js} +2 -2
- package/dist/{chunk-QNZ4562V.js → chunk-Z3LXWCB7.js} +1 -1
- package/dist/{chunk-EYMFVITZ.js → chunk-Z7I6NQMA.js} +2 -2
- package/dist/{chunk-6BJCDWSM.js → chunk-ZB4FS2EG.js} +1 -1
- package/dist/{chunk-ZAEUEDTN.js → chunk-ZD6FZ3WL.js} +1 -1
- package/dist/{chunk-FEU6QEEL.js → chunk-ZM6EZAVY.js} +1 -0
- package/dist/{chunk-2KY7WZZF.js → chunk-ZPA4R2UI.js} +1 -1
- package/dist/chunk-ZPPPHVSU.js +285 -0
- package/dist/{chunk-ELEGH4DX.js → chunk-ZUGQPYUY.js} +51 -4
- package/dist/{chunk-UX2EO653.js → chunk-ZZWFZ46U.js} +66 -8
- package/dist/{cleanup-graph-garbage-VQAJ2RBT.js → cleanup-graph-garbage-HFUD5ZQT.js} +12 -10
- package/dist/{cloud-sync-command-J7NEFUY7.js → cloud-sync-command-3JHJJKPN.js} +25 -3
- package/dist/{cloud-sync-db-init-KWEMNMGA.js → cloud-sync-db-init-2LHIQOSF.js} +1 -1
- package/dist/{cloud-sync-shutdown-P63AUTUI.js → cloud-sync-shutdown-ZXAXA3Z7.js} +6 -4
- package/dist/{cloud-sync-state-QF3TMMJ4.js → cloud-sync-state-VSQFGPP6.js} +1 -1
- package/dist/{co-activation-7IHRPBZN.js → co-activation-NX4EH4AP.js} +6 -4
- package/dist/{co-occurrence-R5AG4OAU.js → co-occurrence-5LBYCW3U.js} +10 -8
- package/dist/{consent-passphrase-5ZBDJ5G7.js → consent-passphrase-65W3DJIJ.js} +2 -2
- package/dist/{content-extractor-ZLXC6GNR.js → content-extractor-OGXETBOX.js} +2 -2
- package/dist/{conversation-entity-extractor-7FRKLLBD.js → conversation-entity-extractor-FQCOAH4F.js} +5 -4
- package/dist/conversations-retention-XTBFJBUM.js +166 -0
- package/dist/{crdt-sync-IFRZOEIS.js → crdt-sync-7TDUBTLH.js} +4 -4
- package/dist/{crm-webhook-L2S63ASV.js → crm-webhook-776JTY55.js} +2 -2
- package/dist/{cto-delegation-gate-3WU7OHX7.js → cto-delegation-gate-ZNUWVYCN.js} +25 -23
- package/dist/{daemon-orchestration-3VQYKLOR.js → daemon-orchestration-76BOFDEP.js} +30 -24
- package/dist/{doc-graph-extractor-YIR5E7OF.js → doc-graph-extractor-F724KZJX.js} +10 -8
- package/dist/{documents-QEHPTYIY.js → documents-Q24V4GRD.js} +13 -11
- package/dist/{dreaming-LNFE7DTN.js → dreaming-IY6NQTSP.js} +27 -25
- package/dist/{employee-boot-brief-ZTXDYNWM.js → employee-boot-brief-CCTXO5DQ.js} +35 -29
- package/dist/{entity-boost-R5N6JWXD.js → entity-boost-XGOB7HDM.js} +1 -1
- package/dist/{exe-drift-WYSJVZFO.js → exe-drift-NEPXLNGP.js} +7 -5
- package/dist/{exe-export-GOLNAEAV.js → exe-export-ZHJQFKR2.js} +14 -12
- package/dist/{exe-import-U4BSI6OH.js → exe-import-7SVNI4FW.js} +14 -12
- package/dist/{exe-key-V42G3NGO.js → exe-key-QJKU56WZ.js} +119 -108
- package/dist/{exe-org-U4GLQ6OP.js → exe-org-SJXHLV6K.js} +76 -51
- package/dist/{exe-snapshot-U7ONBYNY.js → exe-snapshot-EFWPXTXE.js} +45 -36
- package/dist/{fast-db-init-W3NGKAEU.js → fast-db-init-BQZOQNT3.js} +1 -1
- package/dist/{founder-context-ZSIBRVDM.js → founder-context-KG2ZAOXY.js} +2 -2
- package/dist/{founder-monitor-envelope-L3IWZLFY.js → founder-monitor-envelope-NYB5LLU4.js} +4 -4
- package/dist/gateway/index.js +20 -18
- package/dist/{gateway-client-DJFCVIPT.js → gateway-client-ODMUXUL3.js} +1 -1
- package/dist/{git-staleness-OF24QXFZ.js → git-staleness-OPGEAJA7.js} +6 -4
- package/dist/{global-procedures-WSIOWO43.js → global-procedures-3KXY4LL6.js} +8 -6
- package/dist/{graph-auto-extract-IEVVN3L3.js → graph-auto-extract-I7H2F5NJ.js} +10 -8
- package/dist/{graph-query-JDZWAVCU.js → graph-query-E7JBSNY5.js} +1 -1
- package/dist/{graph-rag-BKAD2XQA.js → graph-rag-H24CMFP2.js} +5 -4
- package/dist/{hook-integrity-TPWDGX2M.js → hook-integrity-PGQ3OX4S.js} +1 -1
- package/dist/{hook-prefetch-FD6VGJJA.js → hook-prefetch-7AZJER6Z.js} +23 -21
- package/dist/hook-registration-liveness-NGQRFKAA.js +11 -0
- package/dist/hooks/bug-report-worker.js +26 -24
- package/dist/hooks/codex-stop-task-finalizer.js +28 -26
- package/dist/hooks/commit-complete.js +26 -24
- package/dist/hooks/error-recall.js +16 -14
- package/dist/hooks/exe-heartbeat-hook.js +9 -7
- package/dist/hooks/ingest-worker.js +12 -10
- package/dist/hooks/ingest.js +22 -17
- package/dist/hooks/instructions-loaded.js +11 -9
- package/dist/hooks/manifest.json +20 -20
- package/dist/hooks/notification.js +10 -8
- package/dist/hooks/post-compact.js +9 -7
- package/dist/hooks/post-tool-combined.js +14 -14
- package/dist/hooks/pre-compact.js +26 -24
- package/dist/hooks/pre-tool-use.js +2 -2
- package/dist/hooks/prompt-submit.js +47 -41
- package/dist/hooks/session-end.js +38 -42
- package/dist/hooks/session-start.js +26 -18
- package/dist/hooks/stop.js +29 -27
- package/dist/hooks/subagent-stop.js +29 -27
- package/dist/hooks/summary-worker.js +32 -30
- package/dist/index.js +37 -35
- package/dist/{installer-OTI5SA43.js → installer-2ZS3LHCU.js} +12 -9
- package/dist/{installer-NMOBSEZ2.js → installer-NH3A3HWC.js} +14 -9
- package/dist/{installer-SRJGE4GU.js → installer-UJ73PGRD.js} +21 -9
- package/dist/{intercom-queue-4FFXCBHO.js → intercom-queue-OE25GJF2.js} +21 -19
- package/dist/lib/agent-config.js +2 -2
- package/dist/lib/cloud-sync.js +24 -18
- package/dist/lib/consolidation.js +13 -11
- package/dist/lib/crypto.js +3 -3
- package/dist/lib/database.js +6 -4
- package/dist/lib/db-backup.js +1 -1
- package/dist/lib/db-write-worker-client.js +3 -1
- package/dist/lib/db-write-worker.js +65 -89
- package/dist/lib/db.js +6 -4
- package/dist/lib/delivery-truth.js +5 -1
- package/dist/lib/device-registry.js +1 -1
- package/dist/lib/embedder.js +2 -2
- package/dist/lib/employee-templates.js +9 -7
- package/dist/lib/employees.js +6 -4
- package/dist/lib/exe-daemon.js +425 -314
- package/dist/lib/file-grep.js +15 -21
- package/dist/lib/hybrid-search.js +19 -13
- package/dist/lib/identity-templates.js +1 -1
- package/dist/lib/identity.js +6 -4
- package/dist/lib/keychain.js +1 -1
- package/dist/lib/license.js +22 -6
- package/dist/lib/messaging.js +22 -20
- package/dist/lib/post-tool-memory.js +10 -8
- package/dist/lib/registry-proxy.js +1 -1
- package/dist/lib/reminders.js +7 -5
- package/dist/lib/runtime-table.js +1 -1
- package/dist/lib/schedules.js +12 -10
- package/dist/lib/session-registry.js +8 -6
- package/dist/lib/skill-learning.js +13 -11
- package/dist/lib/status-brief.js +2 -2
- package/dist/lib/store.js +17 -9
- package/dist/lib/task-router.js +7 -5
- package/dist/lib/tasks.js +23 -21
- package/dist/lib/tmux-routing.js +21 -19
- package/dist/lib/tmux-status.js +4 -1
- package/dist/lib/token-spend.js +7 -5
- package/dist/lib/ws-client.js +1 -1
- package/dist/{license-gate-R4JWTWM4.js → license-gate-XQ2KN5I5.js} +3 -3
- package/dist/{license-recheck-3MBJ4IJX.js → license-recheck-IIBKR2V5.js} +1 -1
- package/dist/{license-sync-BVSG3WMW.js → license-sync-76JVI2M5.js} +151 -8
- package/dist/{local-secret-inventory-JWNBHZZ4.js → local-secret-inventory-MJT7NJLY.js} +2 -2
- package/dist/{mailbox-inbox-MTAT4MXX.js → mailbox-inbox-BPZP4GIZ.js} +1 -1
- package/dist/maintenance-worker-54VZTFIK.js +371 -0
- package/dist/mcp/register-tools.js +110 -107
- package/dist/mcp/server.js +113 -110
- package/dist/mcp/tools/complete-reminder.js +8 -6
- package/dist/mcp/tools/create-reminder.js +8 -6
- package/dist/mcp/tools/create-task.js +26 -24
- package/dist/mcp/tools/deactivate-behavior.js +15 -13
- package/dist/mcp/tools/list-reminders.js +8 -6
- package/dist/mcp/tools/list-tasks.js +26 -24
- package/dist/mcp/tools/send-message.js +25 -23
- package/dist/mcp/tools/update-task.js +24 -22
- package/dist/{mcp-child-registry-TIZERM7O.js → mcp-child-registry-5VUG5ATZ.js} +1 -1
- package/dist/{mcp-http-config-VUMSFWFH.js → mcp-http-config-SKXESWHA.js} +9 -7
- package/dist/{mcp-session-identity-DZHWEJRV.js → mcp-session-identity-HYMLJXXA.js} +21 -19
- package/dist/{memory-cards-JVXTMSAT.js → memory-cards-PAZDUQ3B.js} +16 -32
- package/dist/{memory-graph-extractor-PPBJMYFJ.js → memory-graph-extractor-BC6QKBY6.js} +11 -9
- package/dist/{memory-poisoning-defense-K5Q74XQ4.js → memory-poisoning-defense-BRGIHF6J.js} +9 -7
- package/dist/{memory-pressure-watchdog-YMJH5CSA.js → memory-pressure-watchdog-HR6TPOMT.js} +22 -20
- package/dist/{memory-reflection-7BZWIHRE.js → memory-reflection-LO7VFBL3.js} +38 -59
- package/dist/memory-write-repository-J25USX63.js +16 -0
- package/dist/{monitor-grants-Q7X3MYQP.js → monitor-grants-YQTO6NWR.js} +3 -3
- package/dist/{monitor-identity-ZQB4IXEE.js → monitor-identity-POVTG76Y.js} +2 -2
- package/dist/{notification-ledger-XRAXL5OI.js → notification-ledger-KLD6GZTG.js} +22 -20
- package/dist/{notifications-JE3PX55X.js → notifications-3R6HNHDR.js} +21 -19
- package/dist/{orchestration-events-JCQ5F5IJ.js → orchestration-events-CHY3GOJD.js} +7 -5
- package/dist/{orchestrator-X65INDTR.js → orchestrator-GAL3HOAD.js} +23 -21
- package/dist/{org-brief-session-3PQ22UIX.js → org-brief-session-LQ2DBYVF.js} +17 -15
- package/dist/{org-consent-policy-VGELJ7K7.js → org-consent-policy-KFUP7QPG.js} +5 -5
- package/dist/{org-join-6TO5ZYUZ.js → org-join-MXZCIQWX.js} +8 -8
- package/dist/{org-key-admin-XHYVRRAZ.js → org-key-admin-4UK6QUT7.js} +9 -9
- package/dist/{org-key-pin-TTTYGTQL.js → org-key-pin-ZOX4OM3O.js} +3 -3
- package/dist/{org-membership-Y7T2BS57.js → org-membership-D5TPN45F.js} +4 -4
- package/dist/{org-partner-config-OP4NEUNF.js → org-partner-config-3FXSYPQA.js} +7 -7
- package/dist/{org-partner-runtime-YNVUNT2R.js → org-partner-runtime-TH3LMPII.js} +20 -16
- package/dist/{org-row-visibility-JBBXNYXD.js → org-row-visibility-Y5R33C5U.js} +1 -1
- package/dist/{pipeline-router-EIBB7QNG.js → pipeline-router-E2JZLDK6.js} +7 -5
- package/dist/{plan-limits-MEWXYLEN.js → plan-limits-NYI6R4IK.js} +9 -7
- package/dist/{platform-procedures-Z7ZGBJWN.js → platform-procedures-RTD5E3BD.js} +5 -3
- package/dist/{pre-tool-use-impl-C6KJY22W.js → pre-tool-use-impl-LT4FRXBP.js} +127 -83
- package/dist/{project-boot-VEYFCCSL.js → project-boot-LIMXQKAF.js} +25 -17
- package/dist/{project-name-UYORSNGE.js → project-name-V62GWALX.js} +7 -5
- package/dist/{projection-worker-WYFDWS57.js → projection-worker-KLE3IZQ2.js} +5 -2
- package/dist/{prospective-memory-E6XBQFJA.js → prospective-memory-HRN7JFT2.js} +6 -4
- package/dist/{push-notifications-M7YU63FY.js → push-notifications-XC6EQEXF.js} +22 -20
- package/dist/{reproject-worktree-memories-B5XMGJSD.js → reproject-worktree-memories-4CAVZSFE.js} +21 -19
- package/dist/{reranker-SRA4BR7N.js → reranker-2RU6IIX2.js} +1 -1
- package/dist/{retrieval-health-P2JPZUPV.js → retrieval-health-4Q5TOV2S.js} +1 -1
- package/dist/{review-polling-KQPN7IQE.js → review-polling-WOWIMBCL.js} +22 -20
- package/dist/runtime/index.js +30 -28
- package/dist/{session-events-6LDDKKY3.js → session-events-OPMS2C5F.js} +22 -20
- package/dist/{session-kill-telemetry-5DSRGWCL.js → session-kill-telemetry-3GWATSIX.js} +22 -20
- package/dist/{session-scope-XG5R3P3D.js → session-scope-TIRGR4MH.js} +21 -19
- package/dist/{setup-wizard-7QFLZIKT.js → setup-wizard-B2JBVGNH.js} +3 -3
- package/dist/{shard-manager-HHHHV2KZ.js → shard-manager-C7LYX6E4.js} +5 -1
- package/dist/{skill-refinement-I5RMAHSC.js → skill-refinement-FWQFYNBI.js} +7 -5
- package/dist/skill-retrieval-PPLFC5RY.js +166 -0
- package/dist/skill-usage-NDB67PU2.js +25 -0
- package/dist/{stack-generate-manifest-AKMZ3AUX.js → stack-generate-manifest-AXI55MEQ.js} +6 -6
- package/dist/{stack-promote-34CE2HHF.js → stack-promote-6N23OGZY.js} +6 -6
- package/dist/stack-release-2CXFYVWM.js +2770 -0
- package/dist/{stack-update-ZQQ5YYPJ.js → stack-update-U53FBVCQ.js} +13 -5
- package/dist/{steward-gate-AQJ6KT2C.js → steward-gate-TUA3QCN7.js} +7 -5
- package/dist/{subagent-brief-injection-3GU7YAWY.js → subagent-brief-injection-N52RWYFZ.js} +34 -32
- package/dist/{subagent-spawn-ledger-52I66CLW.js → subagent-spawn-ledger-OI23OT4I.js} +1 -1
- package/dist/support-bug-actions-XWCJI6S5.js +30 -0
- package/dist/support-inbox-MMVTCXRX.js +77 -0
- package/dist/{support-outbox-KNI3FR3G.js → support-outbox-2TG2UV6J.js} +2 -2
- package/dist/{task-enforcement-CAA6D2MH.js → task-enforcement-JDMBAAOY.js} +22 -20
- package/dist/{task-request-inbox-QBZVU66E.js → task-request-inbox-PKJCYKJV.js} +9 -5
- package/dist/{task-scope-NHXB4ZRN.js → task-scope-32HQEV6W.js} +27 -19
- package/dist/{tasks-crud-JHSWL5NM.js → tasks-crud-CYKUKS3F.js} +21 -19
- package/dist/{tasks-notify-7PBE4U7T.js → tasks-notify-YND5MKWJ.js} +22 -20
- package/dist/{tasks-review-PPPVEE62.js → tasks-review-CG2A5GM3.js} +23 -19
- package/dist/{telemetry-upload-YU2VRNHX.js → telemetry-upload-WLUMEFAB.js} +11 -9
- package/dist/{token-budget-QCMWKG3K.js → token-budget-3YHAY5LL.js} +7 -5
- package/dist/{tool-capability-index-7AWQAJRP.js → tool-capability-index-2ADOWH43.js} +2 -2
- package/dist/{tool-gates-5Q3RSQEG.js → tool-gates-7ENOOYMR.js} +5 -3
- package/dist/{tool-gates-QD26D2NE.js → tool-gates-EKJTLVGN.js} +1 -1
- package/dist/{tool-telemetry-LTOIC64I.js → tool-telemetry-VUP7S6HC.js} +1 -1
- package/dist/tui/App.js +59 -35
- package/dist/{tui-data-NDBXD6U7.js → tui-data-BOQTOW2G.js} +25 -23
- package/dist/{update-support-BMJM4NG7.js → update-support-WT3S3AQ4.js} +21 -21
- package/dist/{whatsapp-QQLDAHUX.js → whatsapp-OZGIF2EM.js} +2 -2
- package/dist/{wiki-acl-W6WDCVCP.js → wiki-acl-LTEQJVDA.js} +1 -1
- package/dist/{work-claims-6DM32MGO.js → work-claims-CMV2GWIW.js} +8 -6
- package/dist/{work-state-heartbeat-HG7D4AWJ.js → work-state-heartbeat-S45TXK4M.js} +1 -1
- package/dist/{worker-gate-FTMW7GBB.js → worker-gate-7Z6NMM3G.js} +1 -1
- package/dist/{workflow-engine-ZWC6CCXE.js → workflow-engine-ZSQDXZQ7.js} +2 -2
- package/dist/{worktree-EMQCPGFE.js → worktree-NMWDVT6N.js} +8 -6
- package/dist/worktree-ownership-guard-ZVIWJAOP.js +35 -0
- package/dist/{worktree-sweep-XVUJZBXV.js → worktree-sweep-Q62ZIT3U.js} +9 -7
- package/package.json +14 -8
- package/release-notes.json +7 -41
- package/src/commands/exe/afk.md +6 -0
- package/src/commands/exe/call.md +6 -0
- package/src/commands/exe/employee-heartbeat.md +6 -0
- package/src/commands/exe/intercom.md +6 -0
- package/src/commands/exe/kill.md +6 -0
- package/src/commands/exe/launch.md +6 -0
- package/src/commands/exe/logs.md +1 -1
- package/src/commands/exe/sessions.md +6 -0
- package/src/commands/exe/setup.md +7 -3
- package/src/commands/exe/status.md +6 -0
- package/src/commands/exe-reference.md +25 -36
- package/src/commands/exe.md +19 -28
- package/stack.release.json +11 -11
- package/dist/chunk-3B6FLNLV.js +0 -30
- package/dist/chunk-FXU7JOXK.js +0 -6
- package/dist/chunk-QFAFKEB5.js +0 -820
- package/dist/conversations-retention-C4BLKFEL.js +0 -54
- package/dist/stack-release-5PGWV5D4.js +0 -1607
- package/dist/support-inbox-NY5TB3FN.js +0 -51
- /package/dist/{chunk-U2D6PJ5T.js → chunk-65G7ZTGE.js} +0 -0
- /package/dist/{chunk-DN3ZR3UE.js → chunk-CP7Z35MJ.js} +0 -0
- /package/dist/{chunk-XDVOGDBC.js → chunk-KBBWALJV.js} +0 -0
- /package/dist/{chunk-JRIH6I7D.js → chunk-LZXYPTY4.js} +0 -0
- /package/dist/{chunk-GIRD7O5X.js → chunk-MCWEW2SG.js} +0 -0
- /package/dist/{chunk-TKAU7IJ2.js → chunk-MQHUJLCA.js} +0 -0
- /package/dist/{chunk-M4XY4JRK.js → chunk-O53LTOXK.js} +0 -0
- /package/dist/{chunk-DZN7SDY2.js → chunk-OSVTH3SG.js} +0 -0
- /package/dist/{chunk-AJK2PVQW.js → chunk-QXXEXGX3.js} +0 -0
- /package/dist/{chunk-AWWDKF22.js → chunk-SFWNTO2F.js} +0 -0
- /package/dist/{chunk-2Q2CD6CD.js → chunk-WC5HF2FN.js} +0 -0
- /package/dist/{core-memory-O6D5UC3T.js → core-memory-MMMW3TF3.js} +0 -0
- /package/dist/{memory-isolation-EBFFC6HF.js → memory-isolation-67ACKQ2M.js} +0 -0
- /package/dist/{oauth-server-ZKBUHDFI.js → oauth-server-OILIKR7D.js} +0 -0
|
@@ -0,0 +1,2770 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
writeSignedStackManifest
|
|
4
|
+
} from "./chunk-SLMKLEU6.js";
|
|
5
|
+
import "./chunk-UWVXQEQQ.js";
|
|
6
|
+
import "./chunk-EC5H53YD.js";
|
|
7
|
+
import {
|
|
8
|
+
assertPreflightGate,
|
|
9
|
+
runPreflightOnManifest
|
|
10
|
+
} from "./chunk-JBUCA4OX.js";
|
|
11
|
+
import "./chunk-B7WVYLAQ.js";
|
|
12
|
+
import "./chunk-L25OAFOU.js";
|
|
13
|
+
import "./chunk-RT57D7C4.js";
|
|
14
|
+
import "./chunk-FWRPDSO3.js";
|
|
15
|
+
import "./chunk-A7XLR3K7.js";
|
|
16
|
+
import "./chunk-OSVTH3SG.js";
|
|
17
|
+
import "./chunk-MMS2HBRT.js";
|
|
18
|
+
import "./chunk-RH4AH3YB.js";
|
|
19
|
+
import "./chunk-HSGBZSCA.js";
|
|
20
|
+
import "./chunk-JKKZGDLP.js";
|
|
21
|
+
import "./chunk-MLKGABMK.js";
|
|
22
|
+
|
|
23
|
+
// src/bin/stack-release.ts
|
|
24
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
25
|
+
import { execFile as execFileCb } from "child_process";
|
|
26
|
+
import { promisify as promisify2 } from "util";
|
|
27
|
+
import path3 from "path";
|
|
28
|
+
import { createInterface } from "readline";
|
|
29
|
+
|
|
30
|
+
// src/lib/release-lock.ts
|
|
31
|
+
import {
|
|
32
|
+
closeSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
openSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
unlinkSync,
|
|
38
|
+
writeFileSync
|
|
39
|
+
} from "fs";
|
|
40
|
+
import { homedir, hostname as osHostname } from "os";
|
|
41
|
+
import path from "path";
|
|
42
|
+
import process2 from "process";
|
|
43
|
+
var DEFAULT_RELEASE_LOCK_TTL_MS = 2 * 60 * 60 * 1e3;
|
|
44
|
+
function defaultReleaseLockPath() {
|
|
45
|
+
return path.join(homedir(), ".exe-os", "release.lock");
|
|
46
|
+
}
|
|
47
|
+
function defaultIsPidAlive(pid) {
|
|
48
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
49
|
+
try {
|
|
50
|
+
process2.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
54
|
+
return code === "EPERM";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function readLock(lockPath) {
|
|
58
|
+
const raw = readFileSync(lockPath, "utf8");
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "string" || typeof parsed.targetVersion !== "string" || typeof parsed.branch !== "string" || typeof parsed.hostname !== "string") {
|
|
61
|
+
throw new Error("lock file is missing required fields");
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
function writeLockAtomic(lockPath, lock) {
|
|
66
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
67
|
+
const fd = openSync(lockPath, "wx", 384);
|
|
68
|
+
try {
|
|
69
|
+
writeFileSync(fd, JSON.stringify(lock, null, 2) + "\n", "utf8");
|
|
70
|
+
} finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function sameLock(a, b) {
|
|
75
|
+
return a.pid === b.pid && a.startedAt === b.startedAt && a.targetVersion === b.targetVersion && a.branch === b.branch && a.hostname === b.hostname;
|
|
76
|
+
}
|
|
77
|
+
function acquireReleaseLock(opts) {
|
|
78
|
+
const lockPath = opts.lockPath ?? defaultReleaseLockPath();
|
|
79
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_RELEASE_LOCK_TTL_MS;
|
|
80
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
81
|
+
const isPidAlive = opts.isPidAlive ?? defaultIsPidAlive;
|
|
82
|
+
const logNote = opts.log ?? (() => {
|
|
83
|
+
});
|
|
84
|
+
if (existsSync(lockPath)) {
|
|
85
|
+
let existing = null;
|
|
86
|
+
try {
|
|
87
|
+
existing = readLock(lockPath);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
90
|
+
if (!opts.force) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`release lock exists but cannot be read safely (${detail}); re-run with --force to override: ${lockPath}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
unlinkSync(lockPath);
|
|
96
|
+
logNote(`release lock force-cleared unreadable lock at ${lockPath}`);
|
|
97
|
+
}
|
|
98
|
+
if (existing) {
|
|
99
|
+
const startedMs = Date.parse(existing.startedAt);
|
|
100
|
+
if (!Number.isFinite(startedMs) && !opts.force) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`release lock exists with an invalid timestamp (${existing.startedAt}); re-run with --force to override: ${lockPath}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const ageMs = Number.isFinite(startedMs) ? now.getTime() - startedMs : Number.POSITIVE_INFINITY;
|
|
106
|
+
const pidAlive = isPidAlive(existing.pid);
|
|
107
|
+
const fresh = ageMs < ttlMs && pidAlive;
|
|
108
|
+
if (fresh && !opts.force) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`release already in progress: pid ${existing.pid} started ${existing.startedAt}; re-run with --force to override`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
unlinkSync(lockPath);
|
|
114
|
+
if (fresh) {
|
|
115
|
+
logNote(`release lock force-cleared: pid ${existing.pid} started ${existing.startedAt}`);
|
|
116
|
+
} else {
|
|
117
|
+
const reason = !pidAlive ? "pid is not alive" : `older than ${Math.round(ttlMs / 6e4)}m TTL`;
|
|
118
|
+
logNote(`stale release lock cleared: pid ${existing.pid} started ${existing.startedAt} (${reason})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const lock = {
|
|
123
|
+
pid: opts.pid ?? process2.pid,
|
|
124
|
+
startedAt: now.toISOString(),
|
|
125
|
+
targetVersion: opts.targetVersion,
|
|
126
|
+
branch: opts.branch,
|
|
127
|
+
hostname: opts.hostname ?? osHostname()
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
writeLockAtomic(lockPath, lock);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
133
|
+
if (code === "EEXIST") {
|
|
134
|
+
throw new Error(`release lock was acquired by another process; re-run when it completes: ${lockPath}`);
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
lockPath,
|
|
140
|
+
lock,
|
|
141
|
+
release: () => {
|
|
142
|
+
if (!existsSync(lockPath)) return;
|
|
143
|
+
try {
|
|
144
|
+
const current = readLock(lockPath);
|
|
145
|
+
if (sameLock(current, lock)) unlinkSync(lockPath);
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/lib/release-git-sha.ts
|
|
153
|
+
function normalize(sha) {
|
|
154
|
+
const trimmed = sha?.trim();
|
|
155
|
+
return trimmed ? trimmed.toLowerCase() : void 0;
|
|
156
|
+
}
|
|
157
|
+
function decideManifestGitSha(sources, previousSha) {
|
|
158
|
+
const label = normalize(sources.imageLabelSha);
|
|
159
|
+
const tag = normalize(sources.tagCommitSha);
|
|
160
|
+
const previous = normalize(previousSha);
|
|
161
|
+
const warnings = [];
|
|
162
|
+
if (label) {
|
|
163
|
+
if (tag && tag !== label) {
|
|
164
|
+
warnings.push(
|
|
165
|
+
`image label org.exe.git_sha (${label}) disagrees with the release tag's commit (${tag}). Recording the LABEL \u2014 it describes the bytes actually pinned. The disagreement itself is a release-provenance defect worth chasing (bug 19674e79).`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { gitSha: label, source: "image-label", warnings };
|
|
169
|
+
}
|
|
170
|
+
if (tag) {
|
|
171
|
+
warnings.push(
|
|
172
|
+
`image carries no org.exe.git_sha / org.opencontainers.image.revision label \u2014 recording the release tag's commit (${tag}) instead. This is declarative, not derived: it can only be trusted as far as the release checkout.`
|
|
173
|
+
);
|
|
174
|
+
return { gitSha: tag, source: "tag-commit", warnings };
|
|
175
|
+
}
|
|
176
|
+
warnings.push(
|
|
177
|
+
`no git SHA could be derived from the image and none resolved from the release tag \u2014 omitting gitSha (G4 deploy-time provenance will skip this service).`
|
|
178
|
+
);
|
|
179
|
+
if (previous) {
|
|
180
|
+
warnings.push(
|
|
181
|
+
`DROPPING the carried-forward gitSha ${previous} from the previous stack entry: the image has been re-pinned, so that commit no longer describes this entry. A provenance field is either correct or unknown, never confidently wrong (bug 19674e79).`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return { gitSha: void 0, source: "unknown", warnings };
|
|
185
|
+
}
|
|
186
|
+
function applyManifestGitSha(entry, sources) {
|
|
187
|
+
const previous = typeof entry.gitSha === "string" ? entry.gitSha : void 0;
|
|
188
|
+
const decision = decideManifestGitSha(sources, previous);
|
|
189
|
+
if (decision.gitSha) entry.gitSha = decision.gitSha;
|
|
190
|
+
else delete entry.gitSha;
|
|
191
|
+
return decision;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// scripts/lib/stack-release-repos.mjs
|
|
195
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
196
|
+
import { dirname, join } from "path";
|
|
197
|
+
import { fileURLToPath } from "url";
|
|
198
|
+
var DELIVERY_CATALOG_PATH = join(
|
|
199
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
200
|
+
"..",
|
|
201
|
+
"..",
|
|
202
|
+
"delivery",
|
|
203
|
+
"catalog.json"
|
|
204
|
+
);
|
|
205
|
+
var STACK_RELEASE_REPO_CONFIGS = Object.freeze([
|
|
206
|
+
{
|
|
207
|
+
serviceKey: "exe-os",
|
|
208
|
+
manifestServiceKey: "exe-os",
|
|
209
|
+
ghRepo: "AskExe/exe-os",
|
|
210
|
+
localDir: ".",
|
|
211
|
+
imageName: "update.askexe.com/askexe/exe-os",
|
|
212
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
213
|
+
simpleTag: true,
|
|
214
|
+
defaultBranch: "main"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
serviceKey: "gateway",
|
|
218
|
+
manifestServiceKey: "gateway",
|
|
219
|
+
ghRepo: "AskExe/exe-gateway",
|
|
220
|
+
localDir: "exe-gateway",
|
|
221
|
+
imageName: "update.askexe.com/askexe/exe-gateway",
|
|
222
|
+
releaseWorkflow: "release-image.yml",
|
|
223
|
+
simpleTag: true,
|
|
224
|
+
defaultBranch: "main"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
serviceKey: "crm",
|
|
228
|
+
manifestServiceKey: "crm",
|
|
229
|
+
ghRepo: "AskExe/exe-crm",
|
|
230
|
+
localDir: "exe-crm",
|
|
231
|
+
imageName: "update.askexe.com/askexe/exe-crm",
|
|
232
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
233
|
+
simpleTag: true,
|
|
234
|
+
defaultBranch: "main"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
serviceKey: "erp",
|
|
238
|
+
manifestServiceKey: "erp",
|
|
239
|
+
ghRepo: "AskExe/exe-erp",
|
|
240
|
+
localDir: "exe-erp",
|
|
241
|
+
imageName: "update.askexe.com/askexe/exe-erp",
|
|
242
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
243
|
+
simpleTag: true,
|
|
244
|
+
defaultBranch: "main"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
serviceKey: "wiki",
|
|
248
|
+
manifestServiceKey: "wiki",
|
|
249
|
+
ghRepo: "AskExe/exe-wiki",
|
|
250
|
+
localDir: "exe-wiki",
|
|
251
|
+
imageName: "update.askexe.com/askexe/exe-wiki",
|
|
252
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
253
|
+
simpleTag: true,
|
|
254
|
+
defaultBranch: "master"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
serviceKey: "monitor-hub",
|
|
258
|
+
manifestServiceKey: "monitorHub",
|
|
259
|
+
ghRepo: "AskExe/exe-monitor",
|
|
260
|
+
localDir: "exe-monitor",
|
|
261
|
+
imageName: "update.askexe.com/askexe/exe-monitor-hub",
|
|
262
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
263
|
+
simpleTag: true,
|
|
264
|
+
defaultBranch: "main"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
serviceKey: "monitor-agent",
|
|
268
|
+
manifestServiceKey: "monitorAgent",
|
|
269
|
+
ghRepo: "AskExe/exe-monitor",
|
|
270
|
+
localDir: "exe-monitor",
|
|
271
|
+
imageName: "update.askexe.com/askexe/exe-monitor-agent",
|
|
272
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
273
|
+
simpleTag: true,
|
|
274
|
+
defaultBranch: "main"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
serviceKey: "exe-build",
|
|
278
|
+
manifestServiceKey: "exe-build",
|
|
279
|
+
ghRepo: "AskExe/exe-build",
|
|
280
|
+
localDir: "exe-build",
|
|
281
|
+
imageName: "update.askexe.com/askexe/exe-build",
|
|
282
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
283
|
+
simpleTag: true,
|
|
284
|
+
defaultBranch: "main"
|
|
285
|
+
}
|
|
286
|
+
]);
|
|
287
|
+
var STACK_RECONCILE_APP_IMAGE_REPOS = Object.freeze([
|
|
288
|
+
// exe-auth is shipped in stack manifests but is not released by
|
|
289
|
+
// `stack-release`, so it must be covered by G6's exact-repo reconciliation map.
|
|
290
|
+
{
|
|
291
|
+
imageName: "update.askexe.com/askexe/exe-auth",
|
|
292
|
+
ghRepo: "AskExe/exe-auth"
|
|
293
|
+
},
|
|
294
|
+
// exe-dashboard: same gap — it was absent from both maps in this file, so
|
|
295
|
+
// G6's reconcile classified an unstamped dashboard image as "no expected
|
|
296
|
+
// source repo mapped here" (third-party, informational), exactly the false
|
|
297
|
+
// pass the "unstamped-third-party" distinction exists to avoid for images
|
|
298
|
+
// this repo family DOES build.
|
|
299
|
+
//
|
|
300
|
+
// 🔴 THE REPO HERE IS THE REPO THE IMAGE IS BUILT IN, NOT THE ONE THAT WILL
|
|
301
|
+
// EVENTUALLY OWN IT. G6 compares this value against the image's org.exe.repo
|
|
302
|
+
// label, which the shared release lane stamps with the repository the build
|
|
303
|
+
// actually ran in. exe-dashboard is still built from `dashboard-page/` INSIDE
|
|
304
|
+
// AskExe/exe-os (.github/workflows/release-dashboard-image.yml passes
|
|
305
|
+
// `context: dashboard-page`); AskExe/exe-dashboard exists but its history
|
|
306
|
+
// extraction is unfinished and nothing is built there yet. The real published
|
|
307
|
+
// ghcr.io/askexe/exe-dashboard:v0.9.382 carries org.exe.repo=AskExe/exe-os
|
|
308
|
+
// (recorded verbatim in release-service.yml's G2 arming note), so naming the
|
|
309
|
+
// destination repo here turns every correctly-stamped dashboard release into
|
|
310
|
+
// a "wrong-repo image (bug 218b10e4 class)" failure — and simultaneously
|
|
311
|
+
// makes that signal useless for the one service it would be reporting on.
|
|
312
|
+
//
|
|
313
|
+
// release-contract-services.mjs's `ghRepo: "AskExe/exe-dashboard"` is a
|
|
314
|
+
// different field with a different meaning: it names the repo that OWNS the
|
|
315
|
+
// service in the migration table. Copying it into this provenance map is how
|
|
316
|
+
// the wrong value got here.
|
|
317
|
+
//
|
|
318
|
+
// When the extraction completes, the lane's `context` flips to `.` and moves
|
|
319
|
+
// to AskExe/exe-dashboard; flip this with it. A test in
|
|
320
|
+
// tests/scripts/image-provenance.test.ts reads that `context` and fails if
|
|
321
|
+
// the two disagree, so this cannot drift silently a second time.
|
|
322
|
+
{
|
|
323
|
+
imageName: "update.askexe.com/askexe/exe-dashboard",
|
|
324
|
+
ghRepo: "AskExe/exe-os"
|
|
325
|
+
}
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
// scripts/check-stack-release-drift.mjs
|
|
329
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
330
|
+
import path2 from "path";
|
|
331
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
332
|
+
import { execFile } from "child_process";
|
|
333
|
+
import { promisify } from "util";
|
|
334
|
+
|
|
335
|
+
// scripts/lib/release-contract-services.mjs
|
|
336
|
+
var CONTRACT_SERVICES = Object.freeze([
|
|
337
|
+
{
|
|
338
|
+
id: "exe-os",
|
|
339
|
+
releaseComponent: null,
|
|
340
|
+
// exe-os is the top-level descriptor in this repo
|
|
341
|
+
manifestService: "exe-os",
|
|
342
|
+
ghRepo: "AskExe/exe-os",
|
|
343
|
+
defaultBranch: "main",
|
|
344
|
+
healthPath: "/health",
|
|
345
|
+
releaseWorkflow: ".github/workflows/release-stack-image.yml",
|
|
346
|
+
status: "pending",
|
|
347
|
+
migrationOrder: 2,
|
|
348
|
+
gotcha: "release-stack-image.yml has a THIRD job (publish-stack-release) that posts the signed manifest to billing.stack_releases. The shared lane deliberately does not own manifest publication \u2014 that job stays in this repo and runs `needs:` the shared lane's outputs. Migrate only after PR #879 lands, or the build-local->verify->push ordering fix is lost in the rewrite."
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "dashboard",
|
|
352
|
+
releaseComponent: "dashboard",
|
|
353
|
+
releaseEnvVar: "DASHBOARD_IMAGE_TAG",
|
|
354
|
+
composeService: "exe-dashboard",
|
|
355
|
+
manifestService: "dashboard",
|
|
356
|
+
ghRepo: "AskExe/exe-dashboard",
|
|
357
|
+
defaultBranch: "main",
|
|
358
|
+
healthPath: "/health",
|
|
359
|
+
releaseWorkflow: ".github/workflows/release-dashboard-image.yml",
|
|
360
|
+
status: "shared",
|
|
361
|
+
// THE PROOF REPO — migrated in this PR
|
|
362
|
+
migrationOrder: 0,
|
|
363
|
+
gotcha: "Future dashboard releases run in AskExe/exe-dashboard with root build context. exe-os publisher workflows are retired; its dashboard-page directory is frozen history. Existing image provenance mappings still name exe-os until a verified standalone image and its immutable deployment pins are switched together. See docs/dashboard-repository-extraction.md."
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
id: "gateway",
|
|
367
|
+
releaseComponent: null,
|
|
368
|
+
manifestService: "gateway",
|
|
369
|
+
ghRepo: "AskExe/exe-gateway",
|
|
370
|
+
defaultBranch: "main",
|
|
371
|
+
healthPath: "/health",
|
|
372
|
+
releaseWorkflow: null,
|
|
373
|
+
status: "pending",
|
|
374
|
+
migrationOrder: 1,
|
|
375
|
+
gotcha: "Cleanest migration after dashboard: single container, single Dockerfile, real JSON /health. Its current image tag is the date-stamped v2026.08.07-c15f093-stamped form, NOT vX.Y.Z \u2014 the shared lane's release-contract validator requires the semver tag, so the tag scheme must be converted in the same PR that adopts the lane."
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: "crm",
|
|
379
|
+
releaseComponent: null,
|
|
380
|
+
manifestService: "crm",
|
|
381
|
+
ghRepo: "AskExe/exe-crm",
|
|
382
|
+
defaultBranch: "main",
|
|
383
|
+
healthPath: "/healthz",
|
|
384
|
+
releaseWorkflow: null,
|
|
385
|
+
status: "pending",
|
|
386
|
+
migrationOrder: 3,
|
|
387
|
+
gotcha: "PROVEN UNTRUSTWORTHY PROVENANCE: image tag v0.9.54 was built from a different commit than git tag v0.9.54, and production ran an image hand-built from a since-deleted branch whose CI failed lint, typecheck, security scan and unit tests. Do NOT reuse any existing tag when adopting the lane \u2014 the immutability gate will refuse it, correctly. Cut a fresh version so the first shared-lane artifact has provenance that is true by construction."
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "erp",
|
|
391
|
+
releaseComponent: "erp",
|
|
392
|
+
releaseEnvVar: "ERP_IMAGE_TAG",
|
|
393
|
+
composeService: "exe-erp",
|
|
394
|
+
manifestService: "erp",
|
|
395
|
+
ghRepo: "AskExe/exe-erp",
|
|
396
|
+
defaultBranch: "main",
|
|
397
|
+
healthPath: "/api/method/ping",
|
|
398
|
+
releaseWorkflow: null,
|
|
399
|
+
status: "pending",
|
|
400
|
+
migrationOrder: 4,
|
|
401
|
+
gotcha: 'FOUR CONTAINERS SHARE ONE IMAGE (exe-erp, exe-erp-websocket, exe-erp-worker, exe-erp-scheduler differ only by entrypoint). The shared lane builds ONE image, which is correct here \u2014 but the health probe must target the web container only, and the ERP /api/method/ping response is Frappe\'s `{"message":"pong"}`, so the health contract\'s `status` field check needs the `--status-field message` override rather than a waiver.'
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: "wiki",
|
|
405
|
+
releaseComponent: null,
|
|
406
|
+
manifestService: "wiki",
|
|
407
|
+
ghRepo: "AskExe/exe-wiki",
|
|
408
|
+
defaultBranch: "master",
|
|
409
|
+
// NOT main — the one repo that differs
|
|
410
|
+
healthPath: "/api/ping",
|
|
411
|
+
releaseWorkflow: null,
|
|
412
|
+
status: "pending",
|
|
413
|
+
migrationOrder: 5,
|
|
414
|
+
gotcha: "TWO traps. (1) Default branch is `master`, so the lane must be called with `default-branch: master` or the ancestry check fails closed on every release. (2) It is an Outline fork and carries INHERITED UPSTREAM TAGS (v0.7x.x) that sort ABOVE ours (v0.9.x is lower than upstream's\u2026 check before assuming) \u2014 the `v*.*.*` trigger will fire on an upstream tag fetched by a sync. Restrict the caller's tag filter to the AskExe release prefix before adoption."
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: "monitor-hub",
|
|
418
|
+
releaseComponent: null,
|
|
419
|
+
manifestService: "monitorHub",
|
|
420
|
+
ghRepo: "AskExe/exe-monitor",
|
|
421
|
+
defaultBranch: "main",
|
|
422
|
+
healthPath: "/api/health",
|
|
423
|
+
releaseWorkflow: null,
|
|
424
|
+
status: "pending",
|
|
425
|
+
migrationOrder: 6,
|
|
426
|
+
gotcha: "Release workflow has not run since 2026-05-12; v0.9.4 and v0.9.5 were hand-built from a workstation and v0.9.4 carries NO LABELS AT ALL \u2014 permanently untraceable, and the immutability gate cannot retroactively fix it. Do not attempt to re-stamp those tags. Adopt the lane at a NEW version and treat everything <= v0.9.5 as provenance-unknown. One repo publishes TWO images (hub + agent), so it calls the shared lane TWICE."
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
id: "monitor-agent",
|
|
430
|
+
releaseComponent: null,
|
|
431
|
+
manifestService: "monitorAgent",
|
|
432
|
+
ghRepo: "AskExe/exe-monitor",
|
|
433
|
+
defaultBranch: "main",
|
|
434
|
+
healthPath: null,
|
|
435
|
+
// ← genuinely absent today; a real, reported conformance gap
|
|
436
|
+
releaseWorkflow: null,
|
|
437
|
+
status: "pending",
|
|
438
|
+
migrationOrder: 7,
|
|
439
|
+
gotcha: "Has NO healthUrl in the stack manifest at all \u2014 the conformance check reports this as a missing health path rather than waiving it, because 'it's only an agent' is exactly the reasoning that left the dashboard unpinned. The agent must expose a JSON liveness endpoint before it can be marked `shared`. Second image out of AskExe/exe-monitor."
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: "auth",
|
|
443
|
+
releaseComponent: null,
|
|
444
|
+
manifestService: "auth",
|
|
445
|
+
ghRepo: "AskExe/exe-auth",
|
|
446
|
+
defaultBranch: "main",
|
|
447
|
+
// NOT /api/health — exe-auth 404s that path DELIBERATELY. nginx.conf adds an
|
|
448
|
+
// exact `location = /api/health` so GoTrue's version string is not exposed
|
|
449
|
+
// to the public internet (fingerprinting), and its comment states health of
|
|
450
|
+
// the container is the compose healthcheck on `/`. Verified against the
|
|
451
|
+
// running service: / -> 200, /__version -> 200, /api/health -> 404.
|
|
452
|
+
//
|
|
453
|
+
// The stale value here cost a full production rollback on 2026-09-08: the
|
|
454
|
+
// manifest probed /api/health, got 404, and the all-or-nothing update
|
|
455
|
+
// reverted ERP, CRM, wiki and exe-os along with auth.
|
|
456
|
+
healthPath: "/",
|
|
457
|
+
releaseWorkflow: null,
|
|
458
|
+
status: "pending",
|
|
459
|
+
migrationOrder: 8,
|
|
460
|
+
gotcha: "Not released by `stack-release` at all today (it is in STACK_RECONCILE_APP_IMAGE_REPOS, the escape list for images the release CLI does not cut). The 2026-08-14 incident \u2014 v0.6.0 tagged while production served the vulnerable v0.5.0 \u2014 is exactly the tag->publish gap the shared lane closes. Migrate LAST only because it needs the manifest-publish tail, which lands with exe-os."
|
|
461
|
+
}
|
|
462
|
+
]);
|
|
463
|
+
var THIRD_PARTY_MANIFEST_SERVICES = Object.freeze([
|
|
464
|
+
"exe-db",
|
|
465
|
+
// pgvector/pgvector — upstream Postgres
|
|
466
|
+
"gotrue",
|
|
467
|
+
// supabase/gotrue
|
|
468
|
+
"redis",
|
|
469
|
+
"clickhouse",
|
|
470
|
+
"cloudflared",
|
|
471
|
+
"exe-sso-edge",
|
|
472
|
+
// stock nginx:alpine
|
|
473
|
+
"exe-otel-collector"
|
|
474
|
+
// otel/opentelemetry-collector-contrib
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
// scripts/check-stack-release-drift.mjs
|
|
478
|
+
var execFileAsync = promisify(execFile);
|
|
479
|
+
var ROOT = path2.resolve(path2.dirname(fileURLToPath2(import.meta.url)), "..");
|
|
480
|
+
var COMMITTED_MANIFEST = path2.join(ROOT, "deploy", "stack-manifests", "v0.9.json");
|
|
481
|
+
var DEFAULT_MANIFEST_URL = "https://update.askexe.com/v1/stack-manifest.json";
|
|
482
|
+
var GHCR_NAMESPACE = "askexe";
|
|
483
|
+
var GHCR_REPO_BY_SERVICE = {
|
|
484
|
+
"exe-os": "exe-os",
|
|
485
|
+
crm: "exe-crm",
|
|
486
|
+
wiki: "exe-wiki",
|
|
487
|
+
gateway: "exe-gateway",
|
|
488
|
+
auth: "exe-auth",
|
|
489
|
+
erp: "exe-erp",
|
|
490
|
+
// Bug 1a5fbbcc: dashboard is a first-party image (AskExe/exe-dashboard, its
|
|
491
|
+
// own entry in scripts/lib/release-contract-services.mjs and its own service
|
|
492
|
+
// in deploy/stack-manifests/v0.9.json) and had no entry here, so axis 2 could
|
|
493
|
+
// not look up its newest tag and reported "unknown" for it on every run.
|
|
494
|
+
dashboard: "exe-dashboard",
|
|
495
|
+
monitorHub: "exe-monitor-hub",
|
|
496
|
+
monitorAgent: "exe-monitor-agent",
|
|
497
|
+
// Third-party / infra, mirrored through the proxy but not released by us.
|
|
498
|
+
"exe-db": null,
|
|
499
|
+
gotrue: null,
|
|
500
|
+
redis: null,
|
|
501
|
+
clickhouse: null,
|
|
502
|
+
cloudflared: null,
|
|
503
|
+
"exe-sso-edge": null,
|
|
504
|
+
"exe-otel-collector": null
|
|
505
|
+
};
|
|
506
|
+
function auditServiceMapping(manifestServices, mapping = GHCR_REPO_BY_SERVICE, firstPartyServices = /* @__PURE__ */ new Set()) {
|
|
507
|
+
const findings = [];
|
|
508
|
+
for (const service of manifestServices) {
|
|
509
|
+
const mapped = Object.prototype.hasOwnProperty.call(mapping, service) ? mapping[service] : void 0;
|
|
510
|
+
if (mapped === void 0) {
|
|
511
|
+
findings.push({
|
|
512
|
+
service,
|
|
513
|
+
level: "unknown",
|
|
514
|
+
message: `${service}: no entry in GHCR_REPO_BY_SERVICE, so the pinned-vs-built axis cannot resolve it and would report "unknown" on every run forever. Add \`` + service + ': "exe-<name>"` for a first-party image, or `' + service + ": null` to declare it deliberately third-party."
|
|
515
|
+
});
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (mapped === null && firstPartyServices.has(service)) {
|
|
519
|
+
findings.push({
|
|
520
|
+
service,
|
|
521
|
+
level: "unknown",
|
|
522
|
+
message: `${service}: mapped to null ("third-party, deliberately untracked") but the release contract declares it FIRST-PARTY. null would skip it silently on every run \u2014 map it to its ghcr repo.`
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return findings;
|
|
527
|
+
}
|
|
528
|
+
function firstPartyManifestServices() {
|
|
529
|
+
try {
|
|
530
|
+
return new Set(CONTRACT_SERVICES.map((s) => s.manifestService).filter(Boolean));
|
|
531
|
+
} catch {
|
|
532
|
+
return /* @__PURE__ */ new Set();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function tagOf(image) {
|
|
536
|
+
if (typeof image !== "string") return null;
|
|
537
|
+
const withoutDigest = image.split("@")[0] ?? "";
|
|
538
|
+
const lastColon = withoutDigest.lastIndexOf(":");
|
|
539
|
+
const lastSlash = withoutDigest.lastIndexOf("/");
|
|
540
|
+
return lastColon > lastSlash ? withoutDigest.slice(lastColon + 1) : null;
|
|
541
|
+
}
|
|
542
|
+
function compareVersionTags(a, b) {
|
|
543
|
+
const parse = (t) => {
|
|
544
|
+
const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(t ?? "");
|
|
545
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
546
|
+
};
|
|
547
|
+
const pa = parse(a);
|
|
548
|
+
const pb = parse(b);
|
|
549
|
+
if (!pa && !pb) return 0;
|
|
550
|
+
if (!pa) return -1;
|
|
551
|
+
if (!pb) return 1;
|
|
552
|
+
for (let i = 0; i < 3; i++) {
|
|
553
|
+
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
554
|
+
}
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
function anchoredReleaseTags(tags, pinnedTag) {
|
|
558
|
+
const anchor = /^v(\d+)\.\d+\.\d+$/.exec(pinnedTag ?? "");
|
|
559
|
+
const major = anchor ? anchor[1] : null;
|
|
560
|
+
const out = [];
|
|
561
|
+
for (const t of tags ?? []) {
|
|
562
|
+
const m = /^v(\d+)\.\d+\.\d+$/.exec(t);
|
|
563
|
+
if (!m) continue;
|
|
564
|
+
if (major !== null && m[1] !== major) continue;
|
|
565
|
+
out.push(t);
|
|
566
|
+
}
|
|
567
|
+
return out.sort((a, b) => compareVersionTags(b, a));
|
|
568
|
+
}
|
|
569
|
+
function anchoredReleaseTagsNewerThan(tags, pinnedTag) {
|
|
570
|
+
return anchoredReleaseTags(tags, pinnedTag).filter((t) => compareVersionTags(t, pinnedTag) > 0);
|
|
571
|
+
}
|
|
572
|
+
function newestReleaseTag(tags, pinnedTag) {
|
|
573
|
+
return anchoredReleaseTags(tags, pinnedTag)[0] ?? null;
|
|
574
|
+
}
|
|
575
|
+
function manifestAuthHeader(env = process.env) {
|
|
576
|
+
const explicit = env.EXE_STACK_MANIFEST_TOKEN;
|
|
577
|
+
if (explicit) return explicit.includes(" ") ? explicit : `Bearer ${explicit}`;
|
|
578
|
+
const pull = (env.EXE_REGISTRY_PROXY_PULL_TOKENS || env.EXE_REGISTRY_PROXY_PULL_TOKEN || "").split(",").map((t) => t.trim()).find(Boolean);
|
|
579
|
+
if (pull) return `Bearer ${pull}`;
|
|
580
|
+
if (env.EXE_LICENSE_KEY) return `Bearer ${env.EXE_LICENSE_KEY}`;
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
async function fetchServedManifest(url) {
|
|
584
|
+
const headers = { accept: "application/json" };
|
|
585
|
+
const auth = manifestAuthHeader();
|
|
586
|
+
if (auth) headers.authorization = auth;
|
|
587
|
+
const res = await fetch(url, { headers });
|
|
588
|
+
if (!res.ok) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`GET ${url} -> ${res.status}. ` + (res.status === 401 ? "The manifest endpoint requires Authorization: Bearer <pull token | license key>. Set EXE_REGISTRY_PROXY_PULL_TOKENS, EXE_LICENSE_KEY, or EXE_STACK_MANIFEST_TOKEN." : "Cannot compare served vs committed without it.")
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return res.json();
|
|
594
|
+
}
|
|
595
|
+
async function ghcrToken() {
|
|
596
|
+
if (process.env.GHCR_TOKEN) return process.env.GHCR_TOKEN;
|
|
597
|
+
try {
|
|
598
|
+
const { stdout } = await execFileAsync("gh", ["auth", "token"]);
|
|
599
|
+
return stdout.trim();
|
|
600
|
+
} catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function ghcrTags(repo, token) {
|
|
605
|
+
const res = await fetch(`https://ghcr.io/v2/${GHCR_NAMESPACE}/${repo}/tags/list?n=1000`, {
|
|
606
|
+
headers: { authorization: `Bearer ${Buffer.from(token, "utf8").toString("base64")}` }
|
|
607
|
+
});
|
|
608
|
+
if (!res.ok) throw new Error(`ghcr tags/list ${repo} -> ${res.status}`);
|
|
609
|
+
const body = await res.json();
|
|
610
|
+
return body.tags ?? [];
|
|
611
|
+
}
|
|
612
|
+
var OCI_ACCEPT = [
|
|
613
|
+
"application/vnd.oci.image.index.v1+json",
|
|
614
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
615
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
616
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
617
|
+
].join(", ");
|
|
618
|
+
async function refExists(repo, ref, token) {
|
|
619
|
+
const head = (path4) => fetch(`https://ghcr.io/v2/${GHCR_NAMESPACE}/${repo}/${path4}`, {
|
|
620
|
+
method: "HEAD",
|
|
621
|
+
headers: {
|
|
622
|
+
accept: OCI_ACCEPT,
|
|
623
|
+
authorization: `Bearer ${Buffer.from(token, "utf8").toString("base64")}`
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
const res = await head(`manifests/${ref}`);
|
|
627
|
+
if (res.ok) return true;
|
|
628
|
+
if (res.status !== 404) throw new Error(`ghcr manifests/${ref} -> ${res.status}`);
|
|
629
|
+
const repoVisible = await fetch(
|
|
630
|
+
`https://ghcr.io/v2/${GHCR_NAMESPACE}/${repo}/tags/list?n=1`,
|
|
631
|
+
{
|
|
632
|
+
headers: {
|
|
633
|
+
accept: OCI_ACCEPT,
|
|
634
|
+
authorization: `Bearer ${Buffer.from(token, "utf8").toString("base64")}`
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
);
|
|
638
|
+
if (repoVisible.status === 404 || repoVisible.status === 403) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`cannot see ghcr.io/${GHCR_NAMESPACE}/${repo} at all (manifests/${ref} -> 404, tags/list -> ${repoVisible.status}). ghcr hides packages an authenticated caller cannot read \u2014 404 on a manifest, 403 on the tag list \u2014 so this is an AUTHORISATION gap, not proof the digest is absent \u2014 the token is missing the \`read:packages\` scope. Set GHCR_TOKEN to a PAT that has it; \`gh auth token\` does not. Reported as unverified rather than as a phantom pin, because an unreadable registry is not a registry that says no.`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (!repoVisible.ok) throw new Error(`ghcr tags/list for ${repo} -> ${repoVisible.status}`);
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
function digestOf(image) {
|
|
647
|
+
const m = /@(sha256:[a-f0-9]{64})$/.exec(image ?? "");
|
|
648
|
+
return m ? m[1] : null;
|
|
649
|
+
}
|
|
650
|
+
async function deployedUpdateVersion() {
|
|
651
|
+
const healthUrl = process.env.EXE_UPDATE_HEALTH_URL || new URL("/health", process.env.EXE_STACK_MANIFEST_URL || DEFAULT_MANIFEST_URL).toString();
|
|
652
|
+
try {
|
|
653
|
+
const res = await fetch(healthUrl, { headers: { accept: "application/json" } });
|
|
654
|
+
if (res.ok) {
|
|
655
|
+
const body = await res.json();
|
|
656
|
+
const reported = body?.version ?? body?.appVersion ?? null;
|
|
657
|
+
if (typeof reported === "string" && reported.length > 0) {
|
|
658
|
+
return { source: `GET ${healthUrl}`, version: reported.startsWith("v") ? reported : `v${reported}` };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
}
|
|
663
|
+
const host = process.env.EXE_UPDATE_SSH_HOST;
|
|
664
|
+
if (!host) return null;
|
|
665
|
+
const stdout = await dockerPs(host);
|
|
666
|
+
if (stdout === null) return null;
|
|
667
|
+
const line = stdout.split("\n").find((l) => l.startsWith("exe-update "));
|
|
668
|
+
if (!line) return { source: `ssh ${host}`, version: null };
|
|
669
|
+
return { source: `ssh ${host}`, version: tagOf(line.split(" ")[1]) };
|
|
670
|
+
}
|
|
671
|
+
var dockerPsCache;
|
|
672
|
+
var UNINSPECTABLE_MARK = "@uninspectable@";
|
|
673
|
+
var DOCKER_INVENTORY_CMD = `docker ps --format '{{.ID}} {{.Names}}' | while read id name; do ref=$(docker inspect -f '{{.Config.Image}}' "$id" 2>/dev/null); if [ -z "$ref" ]; then echo "` + UNINSPECTABLE_MARK + ` $name"; continue; fi; imgid=$(docker inspect -f '{{.Image}}' "$id" 2>/dev/null); digests=; if [ -n "$imgid" ]; then digests=$(docker inspect -f '{{range .RepoDigests}}{{.}} {{end}}' "$imgid" 2>/dev/null); fi; echo "$name $ref $digests"; done`;
|
|
674
|
+
function uninspectableContainers(stdout) {
|
|
675
|
+
const names = [];
|
|
676
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
677
|
+
const fields = line.trim().split(/\s+/).filter(Boolean);
|
|
678
|
+
if (fields[0] === UNINSPECTABLE_MARK) names.push(fields[1] ?? "<unnamed>");
|
|
679
|
+
}
|
|
680
|
+
return names;
|
|
681
|
+
}
|
|
682
|
+
function unattributableContainers(stdout) {
|
|
683
|
+
const names = [];
|
|
684
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
685
|
+
const fields = line.trim().split(/\s+/).filter(Boolean);
|
|
686
|
+
if (fields.length === 0 || fields[0] === UNINSPECTABLE_MARK) continue;
|
|
687
|
+
const image = fields[1];
|
|
688
|
+
if (!image) continue;
|
|
689
|
+
const refs = fields.slice(2);
|
|
690
|
+
const repo = looksLikeImageId(image) ? refs.map(repoPathOf).find(Boolean) ?? null : repoPathOf(image);
|
|
691
|
+
if (!repo) names.push(fields[0]);
|
|
692
|
+
}
|
|
693
|
+
return names;
|
|
694
|
+
}
|
|
695
|
+
async function dockerPs(host) {
|
|
696
|
+
if (dockerPsCache !== void 0) return dockerPsCache;
|
|
697
|
+
try {
|
|
698
|
+
const { stdout } = await execFileAsync("ssh", ["-o", "BatchMode=yes", host, DOCKER_INVENTORY_CMD]);
|
|
699
|
+
dockerPsCache = stdout;
|
|
700
|
+
} catch {
|
|
701
|
+
dockerPsCache = null;
|
|
702
|
+
}
|
|
703
|
+
return dockerPsCache;
|
|
704
|
+
}
|
|
705
|
+
function repoPathOf(image) {
|
|
706
|
+
if (typeof image !== "string") return null;
|
|
707
|
+
const withoutDigest = image.split("@")[0] ?? "";
|
|
708
|
+
const lastColon = withoutDigest.lastIndexOf(":");
|
|
709
|
+
const lastSlash = withoutDigest.lastIndexOf("/");
|
|
710
|
+
return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest || null;
|
|
711
|
+
}
|
|
712
|
+
function parseFleetInventory(stdout) {
|
|
713
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
714
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
715
|
+
const fields = line.trim().split(/\s+/).filter(Boolean);
|
|
716
|
+
if (fields[0] === UNINSPECTABLE_MARK) continue;
|
|
717
|
+
const image = fields[1];
|
|
718
|
+
if (!image) continue;
|
|
719
|
+
const refs = fields.slice(2);
|
|
720
|
+
const isImageId = looksLikeImageId(image);
|
|
721
|
+
const repo = isImageId ? refs.map(repoPathOf).find(Boolean) ?? null : repoPathOf(image);
|
|
722
|
+
if (!repo) continue;
|
|
723
|
+
const digest = (isImageId ? null : digestOf(image)) ?? refs.map((ref) => repoPathOf(ref) === repo ? digestOf(ref) : null).find(Boolean) ?? null;
|
|
724
|
+
if (!byRepo.has(repo)) byRepo.set(repo, []);
|
|
725
|
+
byRepo.get(repo).push({ name: fields[0] ?? "<unnamed>", tag: isImageId ? null : tagOf(image), digest });
|
|
726
|
+
}
|
|
727
|
+
return byRepo;
|
|
728
|
+
}
|
|
729
|
+
function looksLikeImageId(field) {
|
|
730
|
+
if (typeof field !== "string" || field.includes("/") || field.includes("@")) return false;
|
|
731
|
+
if (/^sha256:[0-9a-f]{12,64}$/i.test(field)) return true;
|
|
732
|
+
return !field.includes(":") && /^[0-9a-f]{12,64}$/i.test(field);
|
|
733
|
+
}
|
|
734
|
+
function classifyContainer(pinnedTag, pinnedDigest, container) {
|
|
735
|
+
if (pinnedDigest && container.digest) {
|
|
736
|
+
return container.digest === pinnedDigest ? ["ok", `${container.name}: ${container.tag ?? "digest-only"} at the pinned digest`] : [
|
|
737
|
+
"drift",
|
|
738
|
+
`${container.name}: running ${container.tag ?? "digest-only"} at digest ${container.digest}, but the signed manifest pins ${pinnedDigest}` + (container.tag && container.tag === pinnedTag ? " \u2014 SAME TAG, DIFFERENT BYTES" : "")
|
|
739
|
+
];
|
|
740
|
+
}
|
|
741
|
+
if (pinnedTag && container.tag && container.tag !== pinnedTag) {
|
|
742
|
+
return ["drift", `${container.name}: running ${container.tag}, manifest pins ${pinnedTag}`];
|
|
743
|
+
}
|
|
744
|
+
if (pinnedDigest) {
|
|
745
|
+
return [
|
|
746
|
+
"unknown",
|
|
747
|
+
`${container.name}: runs ${container.tag ?? "an untagged image"} but reports no repo digest, so its BYTES could not be compared against the pinned ${pinnedDigest}. A matching tag is not proof of matching bytes (bug b817e34c) \u2014 reported as unchecked, not as matching`
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
if (!container.tag) {
|
|
751
|
+
return [
|
|
752
|
+
"unknown",
|
|
753
|
+
`${container.name}: runs a digest-only image and the manifest pin is TAG-ONLY, so there is nothing comparable on either side`
|
|
754
|
+
];
|
|
755
|
+
}
|
|
756
|
+
return [
|
|
757
|
+
"ok",
|
|
758
|
+
`${container.name}: running ${container.tag}, as pinned (the manifest pin is TAG-ONLY, so this compares tags, not bytes \u2014 pin a digest to make it byte-checkable)`
|
|
759
|
+
];
|
|
760
|
+
}
|
|
761
|
+
function evaluateFleetDeployment(services, running, uninspectable = null) {
|
|
762
|
+
const AXIS = "manifest-vs-deployed";
|
|
763
|
+
if (running === null) {
|
|
764
|
+
if (uninspectable && uninspectable.length > 0) {
|
|
765
|
+
return [{
|
|
766
|
+
axis: AXIS,
|
|
767
|
+
level: "unknown",
|
|
768
|
+
message: `the container inventory is INCOMPLETE: ${uninspectable.length} container(s) (${uninspectable.join(", ")}) could not be accounted for \u2014 either the inspect failed (they disappeared between \`docker ps\` and \`docker inspect\`, the signature of A DEPLOY IN FLIGHT, exactly when this axis matters most) or it returned no repository path to file them under (a bare image ID with no RepoDigests, which is what Docker reports once a reference stops resolving to the bytes in use \u2014 i.e. A MOVED TAG). A partial listing cannot answer whether the manifest describes what is deployed: the container missing from it is precisely the one that would have shown drift, and a sibling on the pinned digest would cover for it. Reported as unobserved rather than quietly filtered out (bug fd7535ab).`
|
|
769
|
+
}];
|
|
770
|
+
}
|
|
771
|
+
return [{
|
|
772
|
+
axis: AXIS,
|
|
773
|
+
level: "unknown",
|
|
774
|
+
message: "the running fleet could not be observed, so it is UNKNOWN whether the signed manifest describes what is deployed. Set EXE_UPDATE_SSH_HOST so the container inventory can be read on the stack host. There is deliberately no env var to declare the deployed versions by hand: a value someone typed is not an observation (bug fd7535ab)."
|
|
775
|
+
}];
|
|
776
|
+
}
|
|
777
|
+
const findings = [];
|
|
778
|
+
for (const [service, svc] of Object.entries(services ?? {})) {
|
|
779
|
+
if (GHCR_REPO_BY_SERVICE[service] === null) continue;
|
|
780
|
+
const repo = repoPathOf(svc?.image);
|
|
781
|
+
const pinnedTag = tagOf(svc?.image);
|
|
782
|
+
const pinnedDigest = digestOf(svc?.image);
|
|
783
|
+
if (!repo || !pinnedTag && !pinnedDigest) {
|
|
784
|
+
findings.push({ axis: AXIS, level: "unknown", message: `${service}: manifest pin "${svc?.image ?? "<none>"}" has no readable repo/tag/digest to compare` });
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const containers = running.get(repo) ?? [];
|
|
788
|
+
if (containers.length === 0) {
|
|
789
|
+
findings.push({
|
|
790
|
+
axis: AXIS,
|
|
791
|
+
level: "unknown",
|
|
792
|
+
message: `${service}: no running container found for ${repo}. Not observed is not "matches" \u2014 the manifest's claim about this service is unchecked.`
|
|
793
|
+
});
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const verdicts = containers.map((c) => classifyContainer(pinnedTag, pinnedDigest, c));
|
|
797
|
+
const drifted = verdicts.filter(([lvl]) => lvl === "drift");
|
|
798
|
+
const unchecked = verdicts.filter(([lvl]) => lvl === "unknown");
|
|
799
|
+
const pin = `${repo}${pinnedTag ? `:${pinnedTag}` : ""}${pinnedDigest ? `@${pinnedDigest}` : ""}`;
|
|
800
|
+
if (drifted.length > 0) {
|
|
801
|
+
findings.push({
|
|
802
|
+
axis: AXIS,
|
|
803
|
+
level: "drift",
|
|
804
|
+
message: `${service}: the signed manifest pins ${pin} but the fleet does not match \u2014 ` + drifted.map(([, d]) => d).join("; ") + '. The manifest does not describe the deployed bytes, so it cannot answer "what is deployed?", and a rollback would move production to a version nobody blessed (bug fd7535ab).'
|
|
805
|
+
});
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (unchecked.length > 0) {
|
|
809
|
+
findings.push({
|
|
810
|
+
axis: AXIS,
|
|
811
|
+
level: "unknown",
|
|
812
|
+
message: `${service}: pinned ${pin}; ${unchecked.length} of ${containers.length} container(s) could not be verified \u2014 ${unchecked.map(([, d]) => d).join("; ")}`
|
|
813
|
+
});
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
findings.push({ axis: AXIS, level: "ok", message: `${service}: ${verdicts.map(([, d]) => d).join("; ")}` });
|
|
817
|
+
}
|
|
818
|
+
if (findings.length === 0) {
|
|
819
|
+
findings.push({
|
|
820
|
+
axis: AXIS,
|
|
821
|
+
level: "unknown",
|
|
822
|
+
message: "the committed manifest pins no first-party service, so nothing was compared against the fleet"
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
return findings;
|
|
826
|
+
}
|
|
827
|
+
function parseMaxAgeDays(raw) {
|
|
828
|
+
if (raw === void 0 || raw === "") return 7;
|
|
829
|
+
const n = Number(raw);
|
|
830
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
831
|
+
return n;
|
|
832
|
+
}
|
|
833
|
+
function evaluateReleaseFreshness(served, maxAgeDays, nowMs) {
|
|
834
|
+
if (!served) {
|
|
835
|
+
return ["unknown", "cannot check release freshness without the served manifest (see served-vs-committed above for why it could not be read)"];
|
|
836
|
+
}
|
|
837
|
+
const entry = served.stacks?.[served.latest];
|
|
838
|
+
const releasedAt = entry?.releasedAt ? Date.parse(entry.releasedAt) : NaN;
|
|
839
|
+
if (!entry || Number.isNaN(releasedAt)) {
|
|
840
|
+
return ["unknown", `served manifest entry for latest="${served.latest}" has no readable releasedAt timestamp`];
|
|
841
|
+
}
|
|
842
|
+
const ageDays = (nowMs - releasedAt) / 864e5;
|
|
843
|
+
if (ageDays > maxAgeDays) {
|
|
844
|
+
return [
|
|
845
|
+
"drift",
|
|
846
|
+
`newest published stack release (${served.latest}, released ${entry.releasedAt}) is ${ageDays.toFixed(1)} days old, past the ${maxAgeDays}-day threshold. Silence itself is a failure: bug ed73c73f is a 3-month-wide drift that nothing was watching for because no check ever asked whether a release had shipped recently.`
|
|
847
|
+
];
|
|
848
|
+
}
|
|
849
|
+
return ["ok", `${served.latest} released ${ageDays.toFixed(1)} day(s) ago (<= ${maxAgeDays}d threshold)`];
|
|
850
|
+
}
|
|
851
|
+
function decideExitCode(findings, opts = {}) {
|
|
852
|
+
const warnOnly = opts.warnOnly === true;
|
|
853
|
+
const driftCount = findings.filter((f) => f.level === "drift").length;
|
|
854
|
+
const unknownCount = findings.filter((f) => f.level === "unknown").length;
|
|
855
|
+
if (driftCount > 0) {
|
|
856
|
+
if (warnOnly && unknownCount === 0) {
|
|
857
|
+
return { code: 0, reason: "warn-only", driftCount, unknownCount };
|
|
858
|
+
}
|
|
859
|
+
if (warnOnly) {
|
|
860
|
+
return { code: 3, reason: "unverified", driftCount, unknownCount };
|
|
861
|
+
}
|
|
862
|
+
return { code: 1, reason: "drift", driftCount, unknownCount };
|
|
863
|
+
}
|
|
864
|
+
if (unknownCount > 0) {
|
|
865
|
+
return { code: 3, reason: "unverified", driftCount, unknownCount };
|
|
866
|
+
}
|
|
867
|
+
return { code: 0, reason: "ok", driftCount, unknownCount };
|
|
868
|
+
}
|
|
869
|
+
function selfTest() {
|
|
870
|
+
const RED = "\x1B[31m";
|
|
871
|
+
const GREEN = "\x1B[32m";
|
|
872
|
+
const OFF = "\x1B[0m";
|
|
873
|
+
const ok = [{ axis: "pinned-vs-built", level: "ok", message: "current" }];
|
|
874
|
+
const drift = [
|
|
875
|
+
{ axis: "pinned-vs-built", level: "drift", message: "exe-os: manifest pins v0.9.324 but v0.9.379 shipped" }
|
|
876
|
+
];
|
|
877
|
+
const unverified = [
|
|
878
|
+
{ axis: "served-vs-committed", level: "unknown", message: "GET .../stack-manifest.json -> 401, no credential" }
|
|
879
|
+
];
|
|
880
|
+
const driftAndUnverified = [...drift, ...unverified];
|
|
881
|
+
const cases = [
|
|
882
|
+
["all-ok passes (exit 0)", decideExitCode(ok).code === 0],
|
|
883
|
+
["drift fails (exit 1), not exit 3", decideExitCode(drift).code === 1],
|
|
884
|
+
[
|
|
885
|
+
"unverified-only fails (exit 3), not exit 1 (bug 4f8fbe9d: never report as drift)",
|
|
886
|
+
decideExitCode(unverified).code === 3
|
|
887
|
+
],
|
|
888
|
+
[
|
|
889
|
+
"drift wins over unverified when both present (exit 1)",
|
|
890
|
+
decideExitCode(driftAndUnverified).code === 1
|
|
891
|
+
],
|
|
892
|
+
[
|
|
893
|
+
"--warn-only downgrades pure drift to exit 0 (opt-in escape hatch only)",
|
|
894
|
+
decideExitCode(drift, { warnOnly: true }).code === 0
|
|
895
|
+
],
|
|
896
|
+
[
|
|
897
|
+
`--warn-only does NOT downgrade unverified-only (exit 3, bug ed73c73f follow-up: warn-only must never suppress "I don't know")`,
|
|
898
|
+
decideExitCode(unverified, { warnOnly: true }).code === 3
|
|
899
|
+
],
|
|
900
|
+
[
|
|
901
|
+
"--warn-only does NOT downgrade drift+unverified together either (exit 3, not 0) (bug ed73c73f follow-up: the unverified finding must survive even when drift is present)",
|
|
902
|
+
decideExitCode(driftAndUnverified, { warnOnly: true }).code === 3
|
|
903
|
+
],
|
|
904
|
+
["counts are reported alongside the code", (() => {
|
|
905
|
+
const r = decideExitCode(driftAndUnverified);
|
|
906
|
+
return r.driftCount === 1 && r.unknownCount === 1;
|
|
907
|
+
})()]
|
|
908
|
+
];
|
|
909
|
+
const NOW = Date.parse("2026-09-08T00:00:00Z");
|
|
910
|
+
const DAY = 864e5;
|
|
911
|
+
const freshManifest = { latest: "0.9.40", stacks: { "0.9.40": { releasedAt: new Date(NOW - 2 * DAY).toISOString() } } };
|
|
912
|
+
const staleManifest = { latest: "0.9.36", stacks: { "0.9.36": { releasedAt: new Date(NOW - 90 * DAY).toISOString() } } };
|
|
913
|
+
cases.push(
|
|
914
|
+
["release-freshness: recent release is ok", evaluateReleaseFreshness(freshManifest, 7, NOW)[0] === "ok"],
|
|
915
|
+
[
|
|
916
|
+
"release-freshness: stale release fails as drift, not unknown (silence is the failure, bug ed73c73f)",
|
|
917
|
+
evaluateReleaseFreshness(staleManifest, 7, NOW)[0] === "drift"
|
|
918
|
+
],
|
|
919
|
+
[
|
|
920
|
+
"release-freshness: no served manifest is unknown, never ok",
|
|
921
|
+
evaluateReleaseFreshness(null, 7, NOW)[0] === "unknown"
|
|
922
|
+
],
|
|
923
|
+
["parseMaxAgeDays: default (unset) is 7", parseMaxAgeDays(void 0) === 7],
|
|
924
|
+
[
|
|
925
|
+
"parseMaxAgeDays: valid numeric string still detects staleness",
|
|
926
|
+
evaluateReleaseFreshness(staleManifest, parseMaxAgeDays("7"), NOW)[0] === "drift"
|
|
927
|
+
],
|
|
928
|
+
[
|
|
929
|
+
"parseMaxAgeDays: non-numeric value REFUSES (null), never silently defaults (bug ed73c73f follow-up: NaN compared against anything is false, which OK'd a 90-day-stale manifest under 'invalid')",
|
|
930
|
+
parseMaxAgeDays("invalid") === null
|
|
931
|
+
],
|
|
932
|
+
[
|
|
933
|
+
"parseMaxAgeDays: Infinity REFUSES too (every age is <= Infinity, so nothing could ever drift)",
|
|
934
|
+
parseMaxAgeDays("Infinity") === null
|
|
935
|
+
],
|
|
936
|
+
[
|
|
937
|
+
"parseMaxAgeDays: zero REFUSES (nonsensical as a monitoring threshold)",
|
|
938
|
+
parseMaxAgeDays("0") === null
|
|
939
|
+
],
|
|
940
|
+
["parseMaxAgeDays: negative REFUSES", parseMaxAgeDays("-5") === null]
|
|
941
|
+
);
|
|
942
|
+
const DIG_A = "sha256:" + "a".repeat(64);
|
|
943
|
+
const DIG_B = "sha256:" + "b".repeat(64);
|
|
944
|
+
const REPO = "update.askexe.com/askexe/exe-crm";
|
|
945
|
+
const PINS = {
|
|
946
|
+
crm: { image: `${REPO}:v0.9.53@${DIG_A}` },
|
|
947
|
+
redis: { image: "redis:7" }
|
|
948
|
+
};
|
|
949
|
+
const inv = (...lines) => parseFleetInventory(lines.join("\n") + "\n");
|
|
950
|
+
const levels = (fs) => fs.map((f) => f.level);
|
|
951
|
+
const only = (pins, ...lines) => levels(evaluateFleetDeployment(pins, inv(...lines)));
|
|
952
|
+
cases.push(
|
|
953
|
+
[
|
|
954
|
+
"fleet AHEAD of the manifest is DRIFT, not ok (fd7535ab ran newer, not older)",
|
|
955
|
+
only(PINS, `exe-crm ${REPO}:v0.9.58 ${REPO}@${DIG_B}`).includes("drift")
|
|
956
|
+
],
|
|
957
|
+
[
|
|
958
|
+
"same tag, DIFFERENT digest is DRIFT \u2014 a matching tag is not proof of matching bytes (codex [P1])",
|
|
959
|
+
only(PINS, `exe-crm ${REPO}:v0.9.53 ${REPO}@${DIG_B}`).includes("drift")
|
|
960
|
+
],
|
|
961
|
+
[
|
|
962
|
+
"same tag with NO observable digest is UNKNOWN, never ok, when the pin carries one",
|
|
963
|
+
only(PINS, `exe-crm ${REPO}:v0.9.53`)[0] === "unknown"
|
|
964
|
+
],
|
|
965
|
+
[
|
|
966
|
+
"a DIGEST-ONLY container matching the pinned digest is OK \u2014 an absent tag is missing metadata, not a mismatch (codex [P1])",
|
|
967
|
+
only(PINS, `exe-crm ${REPO}@${DIG_A}`)[0] === "ok"
|
|
968
|
+
],
|
|
969
|
+
[
|
|
970
|
+
"one verified + one UNVERIFIABLE container is UNKNOWN, not ok \u2014 'somebody matched' is not 'everybody matched' (codex [P1])",
|
|
971
|
+
only(PINS, `a ${REPO}:v0.9.53 ${REPO}@${DIG_A}`, `b ${REPO}:v0.9.53`)[0] === "unknown"
|
|
972
|
+
],
|
|
973
|
+
[
|
|
974
|
+
"one verified + one DRIFTED container is DRIFT (worst verdict wins)",
|
|
975
|
+
only(PINS, `a ${REPO}:v0.9.53 ${REPO}@${DIG_A}`, `b ${REPO}:v0.9.58 ${REPO}@${DIG_B}`)[0] === "drift"
|
|
976
|
+
],
|
|
977
|
+
[
|
|
978
|
+
"a fleet that matches tag AND digest is ok (a fix that refuses everything is not a fix)",
|
|
979
|
+
only(PINS, `exe-crm ${REPO}:v0.9.53 ${REPO}@${DIG_A}`).every((l) => l === "ok")
|
|
980
|
+
],
|
|
981
|
+
[
|
|
982
|
+
"an unobservable fleet is UNKNOWN, never ok",
|
|
983
|
+
evaluateFleetDeployment(PINS, null)[0].level === "unknown"
|
|
984
|
+
],
|
|
985
|
+
[
|
|
986
|
+
"a service with no running container is UNKNOWN, never ok (not observed != matches)",
|
|
987
|
+
only(PINS, "")[0] === "unknown"
|
|
988
|
+
],
|
|
989
|
+
[
|
|
990
|
+
"third-party images are not compared (redis is version-pinned by hand)",
|
|
991
|
+
evaluateFleetDeployment(PINS, inv(`exe-crm ${REPO}:v0.9.53 ${REPO}@${DIG_A}`)).length === 1
|
|
992
|
+
],
|
|
993
|
+
[
|
|
994
|
+
"a digest-only container against a TAG-ONLY pin is UNKNOWN \u2014 nothing comparable either side",
|
|
995
|
+
only({ crm: { image: `${REPO}:v0.9.53` } }, `exe-crm ${REPO}@${DIG_A}`)[0] === "unknown"
|
|
996
|
+
],
|
|
997
|
+
[
|
|
998
|
+
"repoPathOf strips both tag and digest",
|
|
999
|
+
repoPathOf(`${REPO}:v0.9.53@${DIG_A}`) === REPO
|
|
1000
|
+
]
|
|
1001
|
+
);
|
|
1002
|
+
const manifestServices = Object.keys(
|
|
1003
|
+
JSON.parse(readFileSync3(COMMITTED_MANIFEST, "utf8")).stacks?.[JSON.parse(readFileSync3(COMMITTED_MANIFEST, "utf8")).latest]?.services ?? {}
|
|
1004
|
+
);
|
|
1005
|
+
cases.push(
|
|
1006
|
+
[
|
|
1007
|
+
"service-mapping: every service in the committed manifest is mapped (present-or-null)",
|
|
1008
|
+
auditServiceMapping(manifestServices, GHCR_REPO_BY_SERVICE, firstPartyManifestServices()).length === 0
|
|
1009
|
+
],
|
|
1010
|
+
[
|
|
1011
|
+
'service-mapping: dashboard resolves to exe-dashboard, not "unknown" (bug 1a5fbbcc)',
|
|
1012
|
+
GHCR_REPO_BY_SERVICE.dashboard === "exe-dashboard"
|
|
1013
|
+
],
|
|
1014
|
+
[
|
|
1015
|
+
"service-mapping: an UNMAPPED service is reported as unknown, never skipped",
|
|
1016
|
+
(() => {
|
|
1017
|
+
const f = auditServiceMapping(["brandnew"], { crm: "exe-crm" }, /* @__PURE__ */ new Set());
|
|
1018
|
+
return f.length === 1 && f[0].level === "unknown" && f[0].service === "brandnew";
|
|
1019
|
+
})()
|
|
1020
|
+
],
|
|
1021
|
+
[
|
|
1022
|
+
"service-mapping: a FIRST-PARTY service mapped to null is a finding, not a deliberate skip",
|
|
1023
|
+
(() => {
|
|
1024
|
+
const f = auditServiceMapping(["dashboard"], { dashboard: null }, /* @__PURE__ */ new Set(["dashboard"]));
|
|
1025
|
+
return f.length === 1 && f[0].level === "unknown";
|
|
1026
|
+
})()
|
|
1027
|
+
],
|
|
1028
|
+
[
|
|
1029
|
+
"service-mapping: a genuinely third-party null mapping is silent",
|
|
1030
|
+
auditServiceMapping(["redis"], { redis: null }, /* @__PURE__ */ new Set()).length === 0
|
|
1031
|
+
]
|
|
1032
|
+
);
|
|
1033
|
+
let bad = 0;
|
|
1034
|
+
for (const [name, pass] of cases) {
|
|
1035
|
+
console.log(` ${pass ? GREEN + "\u2713" + OFF : RED + "\u2717" + OFF} ${name}`);
|
|
1036
|
+
if (!pass) bad++;
|
|
1037
|
+
}
|
|
1038
|
+
process.exit(bad === 0 ? 0 : 1);
|
|
1039
|
+
}
|
|
1040
|
+
async function main() {
|
|
1041
|
+
const argv = process.argv.slice(2);
|
|
1042
|
+
if (argv.includes("--self-test")) return selfTest();
|
|
1043
|
+
const asJson = argv.includes("--json");
|
|
1044
|
+
const warnOnly = argv.includes("--warn-only");
|
|
1045
|
+
const rawMaxAgeDays = process.env.EXE_STACK_RELEASE_MAX_AGE_DAYS;
|
|
1046
|
+
const maxAgeDays = parseMaxAgeDays(rawMaxAgeDays);
|
|
1047
|
+
if (maxAgeDays === null) {
|
|
1048
|
+
console.error(
|
|
1049
|
+
`[drift] EXE_STACK_RELEASE_MAX_AGE_DAYS="${rawMaxAgeDays}" is not a finite, positive number of days \u2014 refusing to run rather than silently falling back to the 7-day default, which would hide this exact misconfiguration (bug ed73c73f follow-up).`
|
|
1050
|
+
);
|
|
1051
|
+
process.exit(2);
|
|
1052
|
+
}
|
|
1053
|
+
const findings = [];
|
|
1054
|
+
const add = (axis, level, message) => findings.push({ axis, level, message });
|
|
1055
|
+
const committed = JSON.parse(readFileSync3(COMMITTED_MANIFEST, "utf8"));
|
|
1056
|
+
const committedEntry = committed.stacks?.[committed.latest];
|
|
1057
|
+
if (!committedEntry) {
|
|
1058
|
+
console.error(`[drift] committed manifest has no entry for latest "${committed.latest}"`);
|
|
1059
|
+
process.exit(2);
|
|
1060
|
+
}
|
|
1061
|
+
const manifestUrl = process.env.EXE_STACK_MANIFEST_URL || DEFAULT_MANIFEST_URL;
|
|
1062
|
+
let served = null;
|
|
1063
|
+
try {
|
|
1064
|
+
served = await fetchServedManifest(manifestUrl);
|
|
1065
|
+
if (served.latest !== committed.latest) {
|
|
1066
|
+
add(
|
|
1067
|
+
"served-vs-committed",
|
|
1068
|
+
"drift",
|
|
1069
|
+
`${manifestUrl} serves latest=${served.latest} but git has latest=${committed.latest}. A stack release was committed and never published \u2014 customers are still reading the older one.`
|
|
1070
|
+
);
|
|
1071
|
+
} else {
|
|
1072
|
+
add("served-vs-committed", "ok", `both at ${committed.latest}`);
|
|
1073
|
+
}
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
add("served-vs-committed", "unknown", String(err.message ?? err));
|
|
1076
|
+
}
|
|
1077
|
+
for (const finding of auditServiceMapping(
|
|
1078
|
+
Object.keys(committedEntry.services ?? {}),
|
|
1079
|
+
GHCR_REPO_BY_SERVICE,
|
|
1080
|
+
firstPartyManifestServices()
|
|
1081
|
+
)) {
|
|
1082
|
+
add("pinned-vs-built", finding.level, finding.message);
|
|
1083
|
+
}
|
|
1084
|
+
const token = await ghcrToken();
|
|
1085
|
+
if (!token) {
|
|
1086
|
+
add("pinned-vs-built", "unknown", "no GHCR_TOKEN and `gh auth token` failed \u2014 cannot list tags");
|
|
1087
|
+
} else {
|
|
1088
|
+
for (const [service, svc] of Object.entries(committedEntry.services ?? {})) {
|
|
1089
|
+
const repo = GHCR_REPO_BY_SERVICE[service];
|
|
1090
|
+
if (repo === null) continue;
|
|
1091
|
+
if (repo === void 0) continue;
|
|
1092
|
+
const pinned = tagOf(svc.image);
|
|
1093
|
+
let newest;
|
|
1094
|
+
try {
|
|
1095
|
+
const ref = digestOf(svc.image) ?? pinned;
|
|
1096
|
+
if (ref && !await refExists(repo, ref, token)) {
|
|
1097
|
+
add(
|
|
1098
|
+
"pinned-vs-built",
|
|
1099
|
+
"drift",
|
|
1100
|
+
`${service}: PHANTOM PIN \u2014 the manifest pins "${svc.image}" but ${ref} does not exist in ghcr.io/${GHCR_NAMESPACE}/${repo}. Nothing can pull it; the descriptor names bytes nobody built.`
|
|
1101
|
+
);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
newest = newestReleaseTag(await ghcrTags(repo, token), pinned);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
add("pinned-vs-built", "unknown", `${service}: ${String(err.message ?? err)}`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (!newest) {
|
|
1110
|
+
add("pinned-vs-built", "unknown", `${service}: ghcr has no vX.Y.Z release tag at all`);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (pinned && compareVersionTags(pinned, newest) < 0) {
|
|
1114
|
+
add(
|
|
1115
|
+
"pinned-vs-built",
|
|
1116
|
+
"drift",
|
|
1117
|
+
`${service}: manifest pins ${pinned} but ${newest} has shipped to ghcr.io/${GHCR_NAMESPACE}/${repo}. Customers cannot receive it until a stack release picks it up.`
|
|
1118
|
+
);
|
|
1119
|
+
} else {
|
|
1120
|
+
add("pinned-vs-built", "ok", `${service}: ${pinned ?? "digest-only"} is current`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
let deployed = null;
|
|
1125
|
+
try {
|
|
1126
|
+
deployed = await deployedUpdateVersion();
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
add("serving-code", "unknown", `probe failed: ${String(err.message ?? err)}`);
|
|
1129
|
+
}
|
|
1130
|
+
if (deployed === null) {
|
|
1131
|
+
add(
|
|
1132
|
+
"serving-code",
|
|
1133
|
+
"unknown",
|
|
1134
|
+
'the exe-update service does not report its version: GET /health returns {"ok":true,"service":"exe-registry-proxy",\u2026} with no version field, so a 50-version-stale container passes its own healthcheck. The running service therefore predates bug 887869e3, which added `version` to that payload \u2014 redeploy exe-update, or set EXE_UPDATE_SSH_HOST for the interim ssh probe. There is deliberately no way to declare the version by hand.'
|
|
1135
|
+
);
|
|
1136
|
+
} else if (deployed.version === null) {
|
|
1137
|
+
add("serving-code", "unknown", `no exe-update container found via ${deployed.source}`);
|
|
1138
|
+
} else {
|
|
1139
|
+
const pinned = tagOf(committedEntry.services?.["exe-os"]?.image);
|
|
1140
|
+
if (pinned && compareVersionTags(deployed.version, pinned) < 0) {
|
|
1141
|
+
add(
|
|
1142
|
+
"serving-code",
|
|
1143
|
+
"drift",
|
|
1144
|
+
`exe-update runs ${deployed.version} (via ${deployed.source}) but the manifest pins exe-os ${pinned}. The service serving the update path is older than the update it serves.`
|
|
1145
|
+
);
|
|
1146
|
+
} else {
|
|
1147
|
+
add("serving-code", "ok", `exe-update runs ${deployed.version} (via ${deployed.source})`);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
add(
|
|
1151
|
+
"release-freshness",
|
|
1152
|
+
...evaluateReleaseFreshness(served, maxAgeDays, Date.now())
|
|
1153
|
+
);
|
|
1154
|
+
const fleetHost = process.env.EXE_UPDATE_SSH_HOST;
|
|
1155
|
+
const fleetPs = fleetHost ? await dockerPs(fleetHost) : null;
|
|
1156
|
+
const blind = fleetPs === null ? [] : [...uninspectableContainers(fleetPs), ...unattributableContainers(fleetPs)];
|
|
1157
|
+
for (const f of evaluateFleetDeployment(
|
|
1158
|
+
committedEntry.services ?? {},
|
|
1159
|
+
fleetPs === null || blind.length > 0 ? null : parseFleetInventory(fleetPs),
|
|
1160
|
+
blind
|
|
1161
|
+
)) {
|
|
1162
|
+
findings.push(f);
|
|
1163
|
+
}
|
|
1164
|
+
const drift = findings.filter((f) => f.level === "drift");
|
|
1165
|
+
const unknown = findings.filter((f) => f.level === "unknown");
|
|
1166
|
+
if (asJson) {
|
|
1167
|
+
console.log(JSON.stringify({ committedLatest: committed.latest, servedLatest: served?.latest ?? null, findings }, null, 2));
|
|
1168
|
+
} else {
|
|
1169
|
+
const icon = { ok: "\u{1F7E2}", drift: "\u{1F534}", unknown: "\u{1F7E0}" };
|
|
1170
|
+
console.log("| axis | status | detail |");
|
|
1171
|
+
console.log("| --- | --- | --- |");
|
|
1172
|
+
for (const f of findings) {
|
|
1173
|
+
console.log(`| ${f.axis} | ${icon[f.level]} ${f.level} | ${f.message.replace(/\|/g, "\\|")} |`);
|
|
1174
|
+
}
|
|
1175
|
+
console.log("");
|
|
1176
|
+
console.log(`${drift.length} drift, ${unknown.length} unknown, ${findings.length - drift.length - unknown.length} ok`);
|
|
1177
|
+
}
|
|
1178
|
+
const decision = decideExitCode(findings, { warnOnly });
|
|
1179
|
+
if (warnOnly && (decision.driftCount > 0 || decision.unknownCount > 0)) {
|
|
1180
|
+
console.error(
|
|
1181
|
+
`[drift] --warn-only: NOT failing the job despite ${decision.driftCount} drift / ${decision.unknownCount} unverified finding(s) above. This flag is a deliberate, time-boxed migration escape hatch and must never be the default invocation (bug ed73c73f).`
|
|
1182
|
+
);
|
|
1183
|
+
} else if (decision.reason === "unverified") {
|
|
1184
|
+
console.error(
|
|
1185
|
+
`[drift] UNVERIFIED, not drift: ${decision.unknownCount} axis could not be observed (see the \u{1F7E0} unknown row(s) above for the missing credential/endpoint). Failing the job because a monitor that cannot see is not a monitor that is passing (bug ed73c73f).`
|
|
1186
|
+
);
|
|
1187
|
+
} else if (decision.reason === "drift") {
|
|
1188
|
+
console.error(`[drift] ${decision.driftCount} drift finding(s) above \u2014 failing the job.`);
|
|
1189
|
+
}
|
|
1190
|
+
process.exit(decision.code);
|
|
1191
|
+
}
|
|
1192
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1193
|
+
main().catch((err) => {
|
|
1194
|
+
console.error(`[drift] ${String(err?.stack ?? err)}`);
|
|
1195
|
+
process.exit(2);
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/lib/stack-pin-completeness.ts
|
|
1200
|
+
async function evaluatePinCompleteness(services, io, opts = {}) {
|
|
1201
|
+
const report = { violations: [], unknowns: [], ok: [], skipped: [], allowed: [] };
|
|
1202
|
+
for (const [service, entry] of Object.entries(services)) {
|
|
1203
|
+
if (typeof entry?.image !== "string") continue;
|
|
1204
|
+
const repo = Object.hasOwn(io.ghcrRepoForService, service) ? io.ghcrRepoForService[service] : void 0;
|
|
1205
|
+
if (repo === void 0) {
|
|
1206
|
+
report.unknowns.push({ service, repo: "<unmapped>", reason: "no entry in ghcrRepoForService" });
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
if (repo === null) {
|
|
1210
|
+
report.skipped.push(service);
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const pinnedTag = tagOf(entry.image);
|
|
1214
|
+
if (pinnedTag === null) {
|
|
1215
|
+
report.unknowns.push({ service, repo, reason: "cannot order a digest-only pin against registry tags" });
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
const tags = await io.listTags(repo);
|
|
1220
|
+
const newer = anchoredReleaseTagsNewerThan(tags, pinnedTag);
|
|
1221
|
+
let resolved = null;
|
|
1222
|
+
for (const candidate of newer) {
|
|
1223
|
+
if (await io.refExists(repo, candidate)) {
|
|
1224
|
+
resolved = candidate;
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (resolved !== null) {
|
|
1229
|
+
report.violations.push({ service, repo, pinnedTag, shippedTag: resolved });
|
|
1230
|
+
} else if (newer.length > 0) {
|
|
1231
|
+
report.unknowns.push({
|
|
1232
|
+
service,
|
|
1233
|
+
repo,
|
|
1234
|
+
reason: `no shipped tag newer than ${pinnedTag} resolved in the registry \u2014 ${newer.length} newer tag(s) listed (${newer.join(", ")}) but none resolve; verification is UNKNOWN, not current`
|
|
1235
|
+
});
|
|
1236
|
+
} else {
|
|
1237
|
+
report.ok.push(service);
|
|
1238
|
+
}
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
report.unknowns.push({ service, repo, reason: error instanceof Error ? error.message : String(error) });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const allowed = new Set(opts.allowStalePins ?? []);
|
|
1244
|
+
report.violations = report.violations.filter((finding) => {
|
|
1245
|
+
if (!allowed.has(finding.service)) return true;
|
|
1246
|
+
report.allowed.push(finding);
|
|
1247
|
+
return false;
|
|
1248
|
+
});
|
|
1249
|
+
report.unknowns = report.unknowns.filter((finding) => {
|
|
1250
|
+
if (!allowed.has(finding.service)) return true;
|
|
1251
|
+
report.allowed.push(finding);
|
|
1252
|
+
return false;
|
|
1253
|
+
});
|
|
1254
|
+
return report;
|
|
1255
|
+
}
|
|
1256
|
+
function pinCompletenessRefusal(report) {
|
|
1257
|
+
if (report.violations.length === 0 && report.unknowns.length === 0) return null;
|
|
1258
|
+
return [
|
|
1259
|
+
"Refusing stack release: pin completeness failed.",
|
|
1260
|
+
...report.violations.map((f) => `${f.service} (${f.repo}): pinned ${f.pinnedTag}, newest shipped ${f.shippedTag}`),
|
|
1261
|
+
...report.unknowns.map((f) => `${f.service} (${f.repo}): unknown \u2014 ${f.reason}`),
|
|
1262
|
+
"Resolve verification failures and pin the newer image via the stack release, or re-run with --allow-stale-pin <service> only after confirming the newer image must not ship."
|
|
1263
|
+
].join("\n");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/bin/stack-release.ts
|
|
1267
|
+
var execFile2 = promisify2(execFileCb);
|
|
1268
|
+
function manifestServiceKeyOf(repo) {
|
|
1269
|
+
return repo.manifestServiceKey ?? repo.serviceKey;
|
|
1270
|
+
}
|
|
1271
|
+
var NO_STACK_MANIFEST_SERVICE = /* @__PURE__ */ new Set(["exe-build"]);
|
|
1272
|
+
function validateNoStackManifestServiceExemptions(repoConfigs, manifestServiceKeys) {
|
|
1273
|
+
const serviceKeys = new Set(repoConfigs.map((c) => c.serviceKey));
|
|
1274
|
+
for (const serviceKey of NO_STACK_MANIFEST_SERVICE) {
|
|
1275
|
+
if (!serviceKeys.has(serviceKey)) {
|
|
1276
|
+
throw new Error(
|
|
1277
|
+
`NO_STACK_MANIFEST_SERVICE names "${serviceKey}", which is not a STACK_RELEASE_REPO_CONFIGS serviceKey \u2014 fix the exemption list (or add the repo to the configs) so pin handling stays explicit.`
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
if (manifestServiceKeys) {
|
|
1281
|
+
const config = repoConfigs.find((c) => c.serviceKey === serviceKey);
|
|
1282
|
+
if (config && manifestServiceKeys.has(manifestServiceKeyOf(config))) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
`NO_STACK_MANIFEST_SERVICE lists "${serviceKey}", but the stack manifest now has a services entry for it \u2014 the exemption is stale; remove it so the pin gate tracks the service (bug ed188382).`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function classifySuccessfulBuildPin(repo, manifestServiceKeys) {
|
|
1291
|
+
const manifestKey = manifestServiceKeyOf(repo);
|
|
1292
|
+
if (manifestServiceKeys.has(manifestKey)) return { kind: "pin", manifestKey };
|
|
1293
|
+
if (NO_STACK_MANIFEST_SERVICE.has(repo.serviceKey)) {
|
|
1294
|
+
return {
|
|
1295
|
+
kind: "exempt",
|
|
1296
|
+
manifestKey,
|
|
1297
|
+
reason: `${repo.serviceKey} published an image but has no stack-manifest service (listed in NO_STACK_MANIFEST_SERVICE) \u2014 build accepted, nothing to pin`
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
return {
|
|
1301
|
+
kind: "refuse",
|
|
1302
|
+
manifestKey,
|
|
1303
|
+
reason: `Cannot pin successful build for ${repo.serviceKey}: manifest service key mismatch \u2014 services[${manifestKey}] is missing (bug ed188382).`
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
validateNoStackManifestServiceExemptions(STACK_RELEASE_REPO_CONFIGS);
|
|
1307
|
+
function findExeOsRoot() {
|
|
1308
|
+
const envRoot = process.env.EXE_OS_ROOT;
|
|
1309
|
+
if (envRoot && existsSync2(envRoot)) return path3.resolve(envRoot);
|
|
1310
|
+
let dir = path3.dirname(new URL(import.meta.url).pathname);
|
|
1311
|
+
for (let i = 0; i < 5; i++) {
|
|
1312
|
+
const pkgPath = path3.join(dir, "package.json");
|
|
1313
|
+
if (existsSync2(pkgPath)) {
|
|
1314
|
+
try {
|
|
1315
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1316
|
+
if (pkg.name === "@askexenow/exe-os") return dir;
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
dir = path3.dirname(dir);
|
|
1321
|
+
}
|
|
1322
|
+
return path3.resolve(path3.dirname(new URL(import.meta.url).pathname), "..", "..");
|
|
1323
|
+
}
|
|
1324
|
+
var EXE_OS_ROOT = findExeOsRoot();
|
|
1325
|
+
var PARENT_DIR = path3.dirname(EXE_OS_ROOT);
|
|
1326
|
+
var MANIFEST_PATH = path3.join(EXE_OS_ROOT, "deploy", "stack-manifests", "v0.9.json");
|
|
1327
|
+
function applyRootOverride(rootPath) {
|
|
1328
|
+
const resolved = path3.resolve(rootPath);
|
|
1329
|
+
if (!existsSync2(resolved)) {
|
|
1330
|
+
throw new Error(`--root path does not exist: ${resolved}`);
|
|
1331
|
+
}
|
|
1332
|
+
EXE_OS_ROOT = resolved;
|
|
1333
|
+
PARENT_DIR = path3.dirname(EXE_OS_ROOT);
|
|
1334
|
+
MANIFEST_PATH = path3.join(EXE_OS_ROOT, "deploy", "stack-manifests", "v0.9.json");
|
|
1335
|
+
}
|
|
1336
|
+
function resolveRepoPath(dirName) {
|
|
1337
|
+
const direct = path3.join(PARENT_DIR, dirName);
|
|
1338
|
+
if (existsSync2(direct)) return direct;
|
|
1339
|
+
const aliases = {
|
|
1340
|
+
"exe-crm": ["openclaw", "exe-crm"],
|
|
1341
|
+
"exe-wiki": ["ink", "exe-wiki"]
|
|
1342
|
+
};
|
|
1343
|
+
const candidates = aliases[dirName];
|
|
1344
|
+
if (candidates) {
|
|
1345
|
+
for (const alias of candidates) {
|
|
1346
|
+
const p = path3.join(PARENT_DIR, alias);
|
|
1347
|
+
if (existsSync2(p)) return p;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return direct;
|
|
1351
|
+
}
|
|
1352
|
+
function buildRepoConfigs() {
|
|
1353
|
+
return STACK_RELEASE_REPO_CONFIGS.map(({ localDir, ...repo }) => ({
|
|
1354
|
+
...repo,
|
|
1355
|
+
localPath: localDir === "." ? EXE_OS_ROOT : resolveRepoPath(localDir)
|
|
1356
|
+
}));
|
|
1357
|
+
}
|
|
1358
|
+
function parseReleaseArgs(argv) {
|
|
1359
|
+
const flags = {
|
|
1360
|
+
stack: false,
|
|
1361
|
+
dryRun: false,
|
|
1362
|
+
yes: false,
|
|
1363
|
+
repos: null,
|
|
1364
|
+
major: false,
|
|
1365
|
+
minor: false,
|
|
1366
|
+
skipImageVerify: false,
|
|
1367
|
+
allowStalePins: [],
|
|
1368
|
+
allowRed: false,
|
|
1369
|
+
root: null,
|
|
1370
|
+
forceBuildRejected: false,
|
|
1371
|
+
canary: false,
|
|
1372
|
+
allowDirty: false,
|
|
1373
|
+
force: false
|
|
1374
|
+
};
|
|
1375
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1376
|
+
const arg = argv[i];
|
|
1377
|
+
if (arg === "--stack") flags.stack = true;
|
|
1378
|
+
else if (arg === "--dry-run") flags.dryRun = true;
|
|
1379
|
+
else if (arg === "--yes" || arg === "-y") flags.yes = true;
|
|
1380
|
+
else if (arg === "--major") flags.major = true;
|
|
1381
|
+
else if (arg === "--minor") flags.minor = true;
|
|
1382
|
+
else if (arg === "--skip-image-verify") flags.skipImageVerify = true;
|
|
1383
|
+
else if (arg === "--allow-stale-pin" || arg.startsWith("--allow-stale-pin=")) {
|
|
1384
|
+
const service = arg === "--allow-stale-pin" ? argv[++i] : arg.slice("--allow-stale-pin=".length);
|
|
1385
|
+
if (!service?.trim() || service.startsWith("--")) {
|
|
1386
|
+
throw new Error("--allow-stale-pin requires a named manifest service");
|
|
1387
|
+
}
|
|
1388
|
+
flags.allowStalePins.push(service.trim());
|
|
1389
|
+
} else if (arg === "--allow-red") flags.allowRed = true;
|
|
1390
|
+
else if (arg === "--allow-dirty") flags.allowDirty = true;
|
|
1391
|
+
else if (arg === "--canary") flags.canary = true;
|
|
1392
|
+
else if (arg === "--force") flags.force = true;
|
|
1393
|
+
else if (arg === "--repos" && argv[i + 1]) {
|
|
1394
|
+
flags.repos = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
|
|
1395
|
+
} else if (arg.startsWith("--repos=")) {
|
|
1396
|
+
flags.repos = arg.slice("--repos=".length).split(",").map((s) => s.trim()).filter(Boolean);
|
|
1397
|
+
} else if (arg === "--root" && argv[i + 1]) {
|
|
1398
|
+
flags.root = argv[++i];
|
|
1399
|
+
} else if (arg.startsWith("--root=")) {
|
|
1400
|
+
flags.root = arg.slice("--root=".length);
|
|
1401
|
+
} else if (arg === "--force-build") {
|
|
1402
|
+
flags.forceBuildRejected = true;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return flags;
|
|
1406
|
+
}
|
|
1407
|
+
async function git(repoPath, ...args) {
|
|
1408
|
+
const { stdout } = await execFile2("git", ["-C", repoPath, ...args], {
|
|
1409
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
1410
|
+
});
|
|
1411
|
+
return stdout.trim();
|
|
1412
|
+
}
|
|
1413
|
+
async function assertCleanWorkingTree(opts) {
|
|
1414
|
+
const gitStatus = opts.gitStatus ?? (() => git(EXE_OS_ROOT, "status", "--porcelain"));
|
|
1415
|
+
const status = (await gitStatus()).trim();
|
|
1416
|
+
if (!status) return;
|
|
1417
|
+
const dirtyPaths = status.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1418
|
+
if (opts.allowDirty) {
|
|
1419
|
+
console.warn("\n \x1B[33;1m\u26A0 --allow-dirty: releasing with a DIRTY working tree\x1B[0m");
|
|
1420
|
+
console.warn(" \x1B[33m Uncommitted changes are present in exe-os and may leak into the release:\x1B[0m");
|
|
1421
|
+
for (const p of dirtyPaths) console.warn(` \x1B[33m ${p}\x1B[0m`);
|
|
1422
|
+
console.warn(" \x1B[33m Use only for break-glass emergencies.\x1B[0m\n");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
throw new Error(
|
|
1426
|
+
`Refusing to release: the exe-os working tree is not clean (${dirtyPaths.length} dirty path(s)).
|
|
1427
|
+
` + dirtyPaths.map((p) => ` ${p}`).join("\n") + `
|
|
1428
|
+
Commit, stash, or discard these changes first \u2014 or pass --allow-dirty to override (break-glass).`
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
async function getLastTag(repoPath) {
|
|
1432
|
+
try {
|
|
1433
|
+
return await git(repoPath, "describe", "--tags", "--abbrev=0");
|
|
1434
|
+
} catch {
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
async function getCommitsSinceTag(repoPath, tag) {
|
|
1439
|
+
try {
|
|
1440
|
+
const output = await git(repoPath, "log", `${tag}..HEAD`, "--oneline");
|
|
1441
|
+
return output.split("\n").filter(Boolean);
|
|
1442
|
+
} catch {
|
|
1443
|
+
return [];
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
async function ghRun(args) {
|
|
1447
|
+
const { stdout } = await execFile2("gh", args);
|
|
1448
|
+
return stdout.trim();
|
|
1449
|
+
}
|
|
1450
|
+
function isStrictSemverTag(tag) {
|
|
1451
|
+
return /^v\d+\.\d+\.\d+$/.test(tag);
|
|
1452
|
+
}
|
|
1453
|
+
function compareSemver(a, b) {
|
|
1454
|
+
const pa = a.split(".").map(Number);
|
|
1455
|
+
const pb = b.split(".").map(Number);
|
|
1456
|
+
for (let i = 0; i < 3; i++) {
|
|
1457
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1458
|
+
if (diff !== 0) return diff;
|
|
1459
|
+
}
|
|
1460
|
+
return 0;
|
|
1461
|
+
}
|
|
1462
|
+
function higherSemver(a, b) {
|
|
1463
|
+
return compareSemver(a, b) >= 0 ? a : b;
|
|
1464
|
+
}
|
|
1465
|
+
function parseRemoteTagNames(lsRemoteOutput) {
|
|
1466
|
+
return lsRemoteOutput.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => line.split(" ")[1] ?? "").filter((ref) => ref.startsWith("refs/tags/")).map((ref) => ref.slice("refs/tags/".length).replace(/\^\{\}$/, ""));
|
|
1467
|
+
}
|
|
1468
|
+
function highestReleasedVersion(tagNames) {
|
|
1469
|
+
let best = null;
|
|
1470
|
+
for (const tag of tagNames) {
|
|
1471
|
+
if (!isStrictSemverTag(tag)) continue;
|
|
1472
|
+
const version = tag.slice(1);
|
|
1473
|
+
best = best === null ? version : higherSemver(best, version);
|
|
1474
|
+
}
|
|
1475
|
+
return best;
|
|
1476
|
+
}
|
|
1477
|
+
async function getRemoteReleaseTags(repoPath, gitRunner = git) {
|
|
1478
|
+
try {
|
|
1479
|
+
const out = await gitRunner(repoPath, "ls-remote", "--tags", "origin");
|
|
1480
|
+
return parseRemoteTagNames(out);
|
|
1481
|
+
} catch {
|
|
1482
|
+
return [];
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async function resolveVersionBasis(repoPath, packageVersion, gitRunner = git) {
|
|
1486
|
+
const highestTag = highestReleasedVersion(await getRemoteReleaseTags(repoPath, gitRunner));
|
|
1487
|
+
return highestTag ? higherSemver(packageVersion, highestTag) : packageVersion;
|
|
1488
|
+
}
|
|
1489
|
+
async function assertVersionNotAlreadyReleased(repoPath, tag, gitRunner = git) {
|
|
1490
|
+
const out = await gitRunner(repoPath, "ls-remote", "--tags", "origin", `refs/tags/${tag}`);
|
|
1491
|
+
if (out.trim().length > 0) {
|
|
1492
|
+
throw new Error(
|
|
1493
|
+
`refusing to cut ${tag}: this tag already exists on origin. package.json (and/or the computed bump basis) has drifted behind real release history \u2014 resync before releasing. (bug 343af002)`
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
async function assertReleaseFreshness(opts) {
|
|
1498
|
+
const runGit = opts.gitRunner ?? git;
|
|
1499
|
+
const logFn = opts.log ?? log;
|
|
1500
|
+
const originRef = `origin/${opts.defaultBranch}`;
|
|
1501
|
+
logFn("info", `Freshness gate: fetching origin ${opts.defaultBranch} before publish...`);
|
|
1502
|
+
await runGit(opts.repoPath, "fetch", "origin", opts.defaultBranch);
|
|
1503
|
+
const missing = (await runGit(opts.repoPath, "log", `${opts.releaseCommit}..${originRef}`, "--oneline")).trim();
|
|
1504
|
+
if (missing) {
|
|
1505
|
+
throw new Error(
|
|
1506
|
+
`Freshness gate failed: ${originRef} contains commits not included in release commit ${opts.releaseCommit}.
|
|
1507
|
+
Missing-PR list:
|
|
1508
|
+
${missing}
|
|
1509
|
+
Abort: rebase/re-cut the release from current ${originRef} before publishing.`
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
try {
|
|
1513
|
+
await runGit(opts.repoPath, "merge-base", "--is-ancestor", originRef, opts.releaseCommit);
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1516
|
+
throw new Error(
|
|
1517
|
+
`Freshness gate failed: release commit ${opts.releaseCommit} is not a descendant of ${originRef}. Abort: rebase/re-cut the release from current ${originRef} before publishing. (${detail})`
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
logFn("ok", `Freshness gate passed: release commit contains current ${originRef}`);
|
|
1521
|
+
}
|
|
1522
|
+
async function printOpenPrGate(opts) {
|
|
1523
|
+
const baseBranch = opts.baseBranch ?? "main";
|
|
1524
|
+
const runGh = opts.ghRunner ?? ghRun;
|
|
1525
|
+
const logFn = opts.log ?? log;
|
|
1526
|
+
try {
|
|
1527
|
+
const output = await runGh([
|
|
1528
|
+
"pr",
|
|
1529
|
+
"list",
|
|
1530
|
+
"--repo",
|
|
1531
|
+
opts.ghRepo,
|
|
1532
|
+
"--base",
|
|
1533
|
+
baseBranch,
|
|
1534
|
+
"--json",
|
|
1535
|
+
"number,title"
|
|
1536
|
+
]);
|
|
1537
|
+
const prs = JSON.parse(output);
|
|
1538
|
+
if (prs.length === 0) {
|
|
1539
|
+
logFn("info", `Open PR gate: no open PRs targeting ${baseBranch} in ${opts.ghRepo}`);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
logFn("warn", `Open PR gate: ${prs.length} open PR(s) targeting ${baseBranch} in ${opts.ghRepo}:`);
|
|
1543
|
+
for (const pr of prs) {
|
|
1544
|
+
const number = typeof pr.number === "number" ? `#${pr.number}` : "#?";
|
|
1545
|
+
logFn("warn", ` ${number} ${pr.title ?? "(untitled)"}`);
|
|
1546
|
+
}
|
|
1547
|
+
} catch (err) {
|
|
1548
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1549
|
+
logFn("warn", `Open PR gate: unable to list open PRs for ${opts.ghRepo} targeting ${baseBranch}; continuing (${detail})`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
function bumpVersion(version, mode) {
|
|
1553
|
+
const parts = version.split(".").map(Number);
|
|
1554
|
+
if (parts.length < 3) throw new Error(`Invalid version: ${version}`);
|
|
1555
|
+
const [major, minor, patch] = parts;
|
|
1556
|
+
switch (mode) {
|
|
1557
|
+
case "major":
|
|
1558
|
+
return `${major + 1}.0.0`;
|
|
1559
|
+
case "minor":
|
|
1560
|
+
return `${major}.${minor + 1}.0`;
|
|
1561
|
+
case "patch":
|
|
1562
|
+
default:
|
|
1563
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function readPackageVersion(repoPath) {
|
|
1567
|
+
const pkgPath = path3.join(repoPath, "package.json");
|
|
1568
|
+
if (!existsSync2(pkgPath)) {
|
|
1569
|
+
throw new Error(`Missing package.json at ${pkgPath}`);
|
|
1570
|
+
}
|
|
1571
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1572
|
+
return pkg.version;
|
|
1573
|
+
}
|
|
1574
|
+
function readPackageVersionOrNull(repoPath) {
|
|
1575
|
+
const pkgPath = path3.join(repoPath, "package.json");
|
|
1576
|
+
if (!existsSync2(pkgPath)) return null;
|
|
1577
|
+
return readPackageVersion(repoPath);
|
|
1578
|
+
}
|
|
1579
|
+
function repoName(repo) {
|
|
1580
|
+
return repo.ghRepo.split("/").pop() ?? repo.serviceKey;
|
|
1581
|
+
}
|
|
1582
|
+
function warnMissingRepoPackage(repo) {
|
|
1583
|
+
const cloned = existsSync2(path3.join(repo.localPath, ".git"));
|
|
1584
|
+
if (!cloned) {
|
|
1585
|
+
log(
|
|
1586
|
+
"warn",
|
|
1587
|
+
`repo not cloned: clone ${repo.ghRepo} or pass --repos to skip ${repoName(repo)} (${repo.localPath} has no .git)`
|
|
1588
|
+
);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const pkgPath = path3.join(repo.localPath, "package.json");
|
|
1592
|
+
const cause = !existsSync2(pkgPath) ? `has no root package.json \u2014 it is not a Node project (exe-monitor is Go, exe-build is Rust)` : `has a root package.json with no readable "version" field`;
|
|
1593
|
+
log(
|
|
1594
|
+
"warn",
|
|
1595
|
+
`${repoName(repo)} IS cloned at ${repo.localPath} but ${cause}, so stack-release cannot derive its version and is SKIPPING it. This is NOT a missing checkout (bug a1f45a78). Its manifest pin cannot advance until stack-release reads this repository's version, or until it is released by hand.`
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
function hasPackageJson(repo) {
|
|
1599
|
+
return existsSync2(path3.join(repo.localPath, "package.json"));
|
|
1600
|
+
}
|
|
1601
|
+
function deriveCloneUrl(repo) {
|
|
1602
|
+
return `https://github.com/${repo.ghRepo}.git`;
|
|
1603
|
+
}
|
|
1604
|
+
function isCloneTargetSafe(targetPath, parentDir) {
|
|
1605
|
+
const resolvedParent = path3.resolve(parentDir);
|
|
1606
|
+
const resolvedTarget = path3.resolve(targetPath);
|
|
1607
|
+
return resolvedTarget === resolvedParent || resolvedTarget.startsWith(resolvedParent + path3.sep);
|
|
1608
|
+
}
|
|
1609
|
+
var defaultCloneRunner = async (url, targetPath) => {
|
|
1610
|
+
await execFile2("git", ["clone", url, targetPath], {
|
|
1611
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
1612
|
+
});
|
|
1613
|
+
};
|
|
1614
|
+
async function ensureRepoCloned(repo, parentDir = PARENT_DIR, runClone = defaultCloneRunner) {
|
|
1615
|
+
if (hasPackageJson(repo)) return false;
|
|
1616
|
+
const target = repo.localPath;
|
|
1617
|
+
if (!isCloneTargetSafe(target, parentDir)) {
|
|
1618
|
+
throw new Error(
|
|
1619
|
+
`refusing to auto-clone ${repo.ghRepo}: target ${target} is outside resolved root ${parentDir}`
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
const url = deriveCloneUrl(repo);
|
|
1623
|
+
log("info", `repo not cloned: auto-cloning ${repo.ghRepo} \u2192 ${target}`);
|
|
1624
|
+
try {
|
|
1625
|
+
await runClone(url, target);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1628
|
+
throw new Error(`git clone failed for ${repo.ghRepo} (${url} \u2192 ${target}): ${detail}`);
|
|
1629
|
+
}
|
|
1630
|
+
log("ok", `cloned ${repo.ghRepo} \u2192 ${target}`);
|
|
1631
|
+
return true;
|
|
1632
|
+
}
|
|
1633
|
+
function readStackReleaseJson(repoPath) {
|
|
1634
|
+
const p = path3.join(repoPath, "stack.release.json");
|
|
1635
|
+
if (!existsSync2(p)) return null;
|
|
1636
|
+
return JSON.parse(readFileSync4(p, "utf8"));
|
|
1637
|
+
}
|
|
1638
|
+
async function detectChanges(repos) {
|
|
1639
|
+
const changes = [];
|
|
1640
|
+
for (const repo of repos) {
|
|
1641
|
+
let currentVersion = readPackageVersionOrNull(repo.localPath);
|
|
1642
|
+
if (!currentVersion) {
|
|
1643
|
+
await ensureRepoCloned(repo);
|
|
1644
|
+
currentVersion = readPackageVersionOrNull(repo.localPath);
|
|
1645
|
+
if (!currentVersion) {
|
|
1646
|
+
warnMissingRepoPackage(repo);
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
const basisVersion = await resolveVersionBasis(repo.localPath, currentVersion);
|
|
1651
|
+
if (basisVersion !== currentVersion) {
|
|
1652
|
+
log(
|
|
1653
|
+
"warn",
|
|
1654
|
+
`${repo.serviceKey}: package.json (${currentVersion}) is behind the highest release tag on origin \u2014 using ${basisVersion} as the bump basis`
|
|
1655
|
+
);
|
|
1656
|
+
currentVersion = basisVersion;
|
|
1657
|
+
}
|
|
1658
|
+
const lastTag = await getLastTag(repo.localPath);
|
|
1659
|
+
if (!lastTag) {
|
|
1660
|
+
log("warn", `No tags found in ${repo.serviceKey} \u2014 skipping`);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
const commits = await getCommitsSinceTag(repo.localPath, lastTag);
|
|
1664
|
+
if (commits.length === 0) {
|
|
1665
|
+
log("info", `${repo.serviceKey}: no changes since ${lastTag}`);
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
changes.push({
|
|
1669
|
+
repo,
|
|
1670
|
+
currentVersion,
|
|
1671
|
+
newVersion: "",
|
|
1672
|
+
// filled in step 2
|
|
1673
|
+
commits,
|
|
1674
|
+
tag: ""
|
|
1675
|
+
// filled in step 2
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
return changes;
|
|
1679
|
+
}
|
|
1680
|
+
function rebaseImageTag(image, newVersion) {
|
|
1681
|
+
if (/@sha256:[0-9a-f]{64}/i.test(image)) return image;
|
|
1682
|
+
if (/:v[^:/]+$/.test(image)) {
|
|
1683
|
+
return `${image.replace(/:v[^:/]+$/, "")}:v${newVersion}`;
|
|
1684
|
+
}
|
|
1685
|
+
return `${image}:v${newVersion}`;
|
|
1686
|
+
}
|
|
1687
|
+
function imageRepoPath(image) {
|
|
1688
|
+
const withoutDigest = image.split("@")[0] ?? image;
|
|
1689
|
+
const lastColon = withoutDigest.lastIndexOf(":");
|
|
1690
|
+
const lastSlash = withoutDigest.lastIndexOf("/");
|
|
1691
|
+
return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest;
|
|
1692
|
+
}
|
|
1693
|
+
function mayRebaseComponent(component, componentImage, ownImage) {
|
|
1694
|
+
if (component && typeof component === "object" && component.versionLockstep === true) {
|
|
1695
|
+
return true;
|
|
1696
|
+
}
|
|
1697
|
+
if (typeof ownImage !== "string" || ownImage.length === 0) return false;
|
|
1698
|
+
return imageRepoPath(componentImage) === imageRepoPath(ownImage);
|
|
1699
|
+
}
|
|
1700
|
+
async function bumpVersions(changes, mode, dryRun) {
|
|
1701
|
+
for (const change of changes) {
|
|
1702
|
+
const newVersion = bumpVersion(change.currentVersion, mode);
|
|
1703
|
+
change.newVersion = newVersion;
|
|
1704
|
+
change.tag = `v${newVersion}`;
|
|
1705
|
+
await assertVersionNotAlreadyReleased(change.repo.localPath, change.tag);
|
|
1706
|
+
if (dryRun) {
|
|
1707
|
+
log("dry", `${change.repo.serviceKey}: ${change.currentVersion} -> ${newVersion}`);
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
const pkgPath = path3.join(change.repo.localPath, "package.json");
|
|
1711
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1712
|
+
pkg.version = newVersion;
|
|
1713
|
+
writeFileSync2(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
1714
|
+
const srf = readStackReleaseJson(change.repo.localPath);
|
|
1715
|
+
if (srf) {
|
|
1716
|
+
srf.version = newVersion;
|
|
1717
|
+
srf.sourceVersion = newVersion;
|
|
1718
|
+
if (typeof srf.image === "string") {
|
|
1719
|
+
srf.image = rebaseImageTag(srf.image, newVersion);
|
|
1720
|
+
}
|
|
1721
|
+
const ownImage = typeof srf.image === "string" ? srf.image : void 0;
|
|
1722
|
+
if (srf.components && typeof srf.components === "object") {
|
|
1723
|
+
const components = srf.components;
|
|
1724
|
+
for (const key of Object.keys(components)) {
|
|
1725
|
+
const val = components[key];
|
|
1726
|
+
if (typeof val === "string") {
|
|
1727
|
+
if (mayRebaseComponent(val, val, ownImage)) {
|
|
1728
|
+
components[key] = rebaseImageTag(val, newVersion);
|
|
1729
|
+
}
|
|
1730
|
+
} else if (val && typeof val === "object" && "image" in val) {
|
|
1731
|
+
const obj = val;
|
|
1732
|
+
if (typeof obj.image === "string" && mayRebaseComponent(obj, obj.image, ownImage)) {
|
|
1733
|
+
obj.image = rebaseImageTag(obj.image, newVersion);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
writeFileSync2(
|
|
1739
|
+
path3.join(change.repo.localPath, "stack.release.json"),
|
|
1740
|
+
JSON.stringify(srf, null, 2) + "\n"
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
await git(change.repo.localPath, "add", "package.json", "stack.release.json");
|
|
1744
|
+
await git(change.repo.localPath, "commit", "-m", `chore: bump v${newVersion} for stack release`);
|
|
1745
|
+
log("ok", `${change.repo.serviceKey}: bumped ${change.currentVersion} -> ${newVersion} (local commit, push deferred)`);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
var RELEASE_PR_POLL_JQ = "[.state, .mergeable, .mergeStateStatus] | @tsv";
|
|
1749
|
+
async function landViaPR(repoPath, ghRepo, defaultBranch, branch, title) {
|
|
1750
|
+
const pushedSha = await git(repoPath, "rev-parse", "HEAD");
|
|
1751
|
+
await git(repoPath, "push", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`);
|
|
1752
|
+
try {
|
|
1753
|
+
await ghRun([
|
|
1754
|
+
"pr",
|
|
1755
|
+
"create",
|
|
1756
|
+
"--repo",
|
|
1757
|
+
ghRepo,
|
|
1758
|
+
"--base",
|
|
1759
|
+
defaultBranch,
|
|
1760
|
+
"--head",
|
|
1761
|
+
branch,
|
|
1762
|
+
"--title",
|
|
1763
|
+
title,
|
|
1764
|
+
"--body",
|
|
1765
|
+
"Automated release version bump \u2014 auto-merges when required CI is green."
|
|
1766
|
+
]);
|
|
1767
|
+
} catch {
|
|
1768
|
+
}
|
|
1769
|
+
const deadline = Date.now() + 30 * 60 * 1e3;
|
|
1770
|
+
let releasePr;
|
|
1771
|
+
for (; ; ) {
|
|
1772
|
+
try {
|
|
1773
|
+
const output = await ghRun([
|
|
1774
|
+
"pr",
|
|
1775
|
+
"list",
|
|
1776
|
+
"--repo",
|
|
1777
|
+
ghRepo,
|
|
1778
|
+
"--head",
|
|
1779
|
+
branch,
|
|
1780
|
+
"--state",
|
|
1781
|
+
"all",
|
|
1782
|
+
"--json",
|
|
1783
|
+
"number,state,headRefOid"
|
|
1784
|
+
]);
|
|
1785
|
+
releasePr = findPullRequestForHeadSha(JSON.parse(output), pushedSha);
|
|
1786
|
+
} catch {
|
|
1787
|
+
}
|
|
1788
|
+
if (releasePr) break;
|
|
1789
|
+
if (Date.now() > deadline) {
|
|
1790
|
+
throw new Error(
|
|
1791
|
+
`No PR on ${ghRepo} (${branch}) matches released commit ${pushedSha.slice(0, 12)} within 30m \u2014 failing closed rather than attributing another PR's verdict. Aborting release; ${defaultBranch} left untouched.`
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
await sleep(15e3);
|
|
1795
|
+
}
|
|
1796
|
+
if (releasePr.state !== "MERGED") {
|
|
1797
|
+
await ghRun(["pr", "merge", String(releasePr.number), "--repo", ghRepo, "--auto", "--squash"]);
|
|
1798
|
+
}
|
|
1799
|
+
let lastMergeable = "";
|
|
1800
|
+
for (; ; ) {
|
|
1801
|
+
const observed = (await ghRun([
|
|
1802
|
+
"pr",
|
|
1803
|
+
"view",
|
|
1804
|
+
String(releasePr.number),
|
|
1805
|
+
"--repo",
|
|
1806
|
+
ghRepo,
|
|
1807
|
+
"--json",
|
|
1808
|
+
"state,mergeable,mergeStateStatus",
|
|
1809
|
+
"-q",
|
|
1810
|
+
RELEASE_PR_POLL_JQ
|
|
1811
|
+
])).trim();
|
|
1812
|
+
const [state, mergeable = "", mergeStateStatus = ""] = observed.split(" ");
|
|
1813
|
+
if (mergeable) lastMergeable = `mergeable=${mergeable} mergeStateStatus=${mergeStateStatus}`;
|
|
1814
|
+
if (state === "MERGED") break;
|
|
1815
|
+
if (mergeable === "CONFLICTING") {
|
|
1816
|
+
throw new Error(
|
|
1817
|
+
`Release PR ${ghRepo}#${releasePr.number} (${branch} @ ${pushedSha.slice(0, 12)}) CONFLICTS with ${defaultBranch}. GitHub cannot compute a merge commit for it, so NO CI will run and auto-merge can never fire \u2014 waiting would not help. Rebase the release branch and re-run. Aborting release; ${defaultBranch} left untouched.`
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
if (Date.now() > deadline) {
|
|
1821
|
+
throw new Error(
|
|
1822
|
+
`Release PR ${ghRepo}#${releasePr.number} (${branch} @ ${pushedSha.slice(0, 12)}) did not merge within 30m (${lastMergeable || "mergeability never readable"}). Aborting release; ${defaultBranch} left untouched.`
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
await sleep(3e4);
|
|
1826
|
+
}
|
|
1827
|
+
await git(repoPath, "fetch", "origin", defaultBranch);
|
|
1828
|
+
await git(repoPath, "checkout", defaultBranch);
|
|
1829
|
+
await git(repoPath, "reset", "--hard", `origin/${defaultBranch}`);
|
|
1830
|
+
}
|
|
1831
|
+
async function tagAndTriggerCI(changes, dryRun) {
|
|
1832
|
+
for (const change of changes) {
|
|
1833
|
+
if (dryRun) {
|
|
1834
|
+
log("dry", `Would land ${change.repo.serviceKey} bump via PR + tag ${change.tag}`);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
const branch = `release/bump-${change.tag}`;
|
|
1838
|
+
await landViaPR(
|
|
1839
|
+
change.repo.localPath,
|
|
1840
|
+
change.repo.ghRepo,
|
|
1841
|
+
change.repo.defaultBranch,
|
|
1842
|
+
branch,
|
|
1843
|
+
`chore: bump ${change.tag} for stack release`
|
|
1844
|
+
);
|
|
1845
|
+
change.releaseSha = await git(change.repo.localPath, "rev-parse", "HEAD");
|
|
1846
|
+
await assertReleaseFreshness({
|
|
1847
|
+
repoPath: change.repo.localPath,
|
|
1848
|
+
defaultBranch: change.repo.defaultBranch,
|
|
1849
|
+
releaseCommit: change.releaseSha
|
|
1850
|
+
});
|
|
1851
|
+
await printOpenPrGate({ ghRepo: change.repo.ghRepo, baseBranch: "main" });
|
|
1852
|
+
await git(change.repo.localPath, "tag", change.tag);
|
|
1853
|
+
await git(change.repo.localPath, "push", "origin", change.tag);
|
|
1854
|
+
log("ok", `Landed ${change.repo.serviceKey} ${change.tag} via PR + tagged ${change.releaseSha.slice(0, 12)} (green-CI gated)`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
async function rollbackFailedReleases(changes, ciStatuses, dryRun) {
|
|
1858
|
+
if (dryRun) return;
|
|
1859
|
+
const failed = changes.filter((c) => {
|
|
1860
|
+
const ci = ciStatuses.find((s) => s.repo.serviceKey === c.repo.serviceKey);
|
|
1861
|
+
return ci && (ci.status !== "completed" || ci.conclusion !== "success");
|
|
1862
|
+
});
|
|
1863
|
+
if (failed.length === 0) return;
|
|
1864
|
+
log("warn", `Rolling back ${failed.length} failed release(s)...`);
|
|
1865
|
+
for (const change of failed) {
|
|
1866
|
+
const name = repoName(change.repo);
|
|
1867
|
+
try {
|
|
1868
|
+
await git(change.repo.localPath, "push", "origin", `:refs/tags/${change.tag}`);
|
|
1869
|
+
log("ok", `Deleted remote tag ${change.tag} from ${name}`);
|
|
1870
|
+
} catch (err) {
|
|
1871
|
+
log("warn", `Could not delete remote tag ${change.tag} from ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1872
|
+
}
|
|
1873
|
+
try {
|
|
1874
|
+
await git(change.repo.localPath, "tag", "-d", change.tag);
|
|
1875
|
+
} catch {
|
|
1876
|
+
}
|
|
1877
|
+
try {
|
|
1878
|
+
await git(change.repo.localPath, "revert", "--no-edit", "HEAD");
|
|
1879
|
+
await landViaPR(
|
|
1880
|
+
change.repo.localPath,
|
|
1881
|
+
change.repo.ghRepo,
|
|
1882
|
+
change.repo.defaultBranch,
|
|
1883
|
+
`release/revert-${change.tag}`,
|
|
1884
|
+
`Revert bump ${change.tag} (release CI failed)`
|
|
1885
|
+
);
|
|
1886
|
+
log("ok", `Reverted bump commit for ${name} on ${change.repo.defaultBranch} (via PR)`);
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
log("fail", `Could not revert bump commit for ${name}: ${err instanceof Error ? err.message : String(err)}. Manual cleanup required.`);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
function workflowRunHeadSha(run) {
|
|
1893
|
+
return run.headSha ?? run.head_sha;
|
|
1894
|
+
}
|
|
1895
|
+
function findPullRequestForHeadSha(prs, pushedSha) {
|
|
1896
|
+
const normalizedSha = pushedSha.toLowerCase();
|
|
1897
|
+
return prs.find((pr) => pr.headRefOid?.toLowerCase() === normalizedSha);
|
|
1898
|
+
}
|
|
1899
|
+
function findWorkflowRunForHeadSha(runs, releaseSha) {
|
|
1900
|
+
const normalizedReleaseSha = releaseSha.toLowerCase();
|
|
1901
|
+
return runs.find((run) => workflowRunHeadSha(run)?.toLowerCase() === normalizedReleaseSha);
|
|
1902
|
+
}
|
|
1903
|
+
async function resolveReleaseSha(change) {
|
|
1904
|
+
if (change.releaseSha) return change.releaseSha;
|
|
1905
|
+
try {
|
|
1906
|
+
change.releaseSha = await git(change.repo.localPath, "rev-parse", `${change.tag}^{}`);
|
|
1907
|
+
return change.releaseSha;
|
|
1908
|
+
} catch {
|
|
1909
|
+
return void 0;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
async function waitForCI(changes, dryRun) {
|
|
1913
|
+
if (dryRun) {
|
|
1914
|
+
return changes.map((c) => ({
|
|
1915
|
+
repo: c.repo,
|
|
1916
|
+
tag: c.tag,
|
|
1917
|
+
status: "completed",
|
|
1918
|
+
conclusion: "success"
|
|
1919
|
+
}));
|
|
1920
|
+
}
|
|
1921
|
+
const TIMEOUT_MS = 15 * 60 * 1e3;
|
|
1922
|
+
const POLL_INTERVAL_MS = 30 * 1e3;
|
|
1923
|
+
const startTime = Date.now();
|
|
1924
|
+
const statuses = changes.map((c) => ({
|
|
1925
|
+
repo: c.repo,
|
|
1926
|
+
tag: c.tag,
|
|
1927
|
+
status: "in_progress"
|
|
1928
|
+
}));
|
|
1929
|
+
log("info", `Waiting for ${changes.length} CI build(s)... (timeout: 15m)`);
|
|
1930
|
+
while (Date.now() - startTime < TIMEOUT_MS) {
|
|
1931
|
+
const pending = statuses.filter((s) => s.status === "in_progress");
|
|
1932
|
+
if (pending.length === 0) break;
|
|
1933
|
+
for (const st of pending) {
|
|
1934
|
+
try {
|
|
1935
|
+
const change = changes.find((c) => c.repo.serviceKey === st.repo.serviceKey && c.tag === st.tag);
|
|
1936
|
+
if (!change) continue;
|
|
1937
|
+
const releaseSha = await resolveReleaseSha(change);
|
|
1938
|
+
if (!releaseSha) continue;
|
|
1939
|
+
const output = await ghRun([
|
|
1940
|
+
"run",
|
|
1941
|
+
"list",
|
|
1942
|
+
"--repo",
|
|
1943
|
+
st.repo.ghRepo,
|
|
1944
|
+
"--workflow",
|
|
1945
|
+
st.repo.releaseWorkflow,
|
|
1946
|
+
"--limit",
|
|
1947
|
+
"50",
|
|
1948
|
+
"--json",
|
|
1949
|
+
"status,conclusion,url,headSha"
|
|
1950
|
+
]);
|
|
1951
|
+
const runs = JSON.parse(output);
|
|
1952
|
+
const releaseRun = findWorkflowRunForHeadSha(runs, releaseSha);
|
|
1953
|
+
if (!releaseRun) continue;
|
|
1954
|
+
if (releaseRun.status === "completed") {
|
|
1955
|
+
st.status = "completed";
|
|
1956
|
+
st.conclusion = releaseRun.conclusion ?? "unknown";
|
|
1957
|
+
st.url = releaseRun.url;
|
|
1958
|
+
if (st.conclusion === "success") {
|
|
1959
|
+
log("ok", `CI passed: ${st.repo.serviceKey} ${st.tag}`);
|
|
1960
|
+
} else {
|
|
1961
|
+
log("fail", `CI failed: ${st.repo.serviceKey} ${st.tag} (${st.conclusion}) \u2014 ${st.url}`);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
log("warn", `Failed to check CI for ${st.repo.serviceKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
const stillPending = statuses.filter((s) => s.status === "in_progress");
|
|
1969
|
+
if (stillPending.length === 0) break;
|
|
1970
|
+
log("info", `${stillPending.length} CI build(s) still running... (${Math.round((Date.now() - startTime) / 1e3)}s elapsed)`);
|
|
1971
|
+
await sleep(POLL_INTERVAL_MS);
|
|
1972
|
+
}
|
|
1973
|
+
for (const st of statuses) {
|
|
1974
|
+
if (st.status === "in_progress") {
|
|
1975
|
+
st.status = "timed_out";
|
|
1976
|
+
log("fail", `CI timed out: ${st.repo.serviceKey} ${st.tag}`);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return statuses;
|
|
1980
|
+
}
|
|
1981
|
+
var PROXY_REGISTRY = "update.askexe.com";
|
|
1982
|
+
var GHCR_REGISTRY = "ghcr.io";
|
|
1983
|
+
function shortImageName(imageName) {
|
|
1984
|
+
const parts = imageName.split("/");
|
|
1985
|
+
return parts[parts.length - 1] ?? imageName;
|
|
1986
|
+
}
|
|
1987
|
+
async function checkRegistryManifest(registry, repo, tag, token) {
|
|
1988
|
+
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
|
1989
|
+
const headers = [
|
|
1990
|
+
"-H",
|
|
1991
|
+
"Accept: application/vnd.oci.image.index.v1+json",
|
|
1992
|
+
// Also accept legacy Docker v2 manifest list / single manifest so the
|
|
1993
|
+
// registry returns a Docker-Content-Digest for non-OCI images too.
|
|
1994
|
+
"-H",
|
|
1995
|
+
"Accept: application/vnd.docker.distribution.manifest.list.v2+json",
|
|
1996
|
+
"-H",
|
|
1997
|
+
"Accept: application/vnd.docker.distribution.manifest.v2+json"
|
|
1998
|
+
];
|
|
1999
|
+
if (token) {
|
|
2000
|
+
headers.push("-H", `Authorization: Bearer ${token}`);
|
|
2001
|
+
}
|
|
2002
|
+
try {
|
|
2003
|
+
const { stdout } = await execFile2("curl", [
|
|
2004
|
+
"-sS",
|
|
2005
|
+
"-m",
|
|
2006
|
+
"30",
|
|
2007
|
+
"-I",
|
|
2008
|
+
...headers,
|
|
2009
|
+
url
|
|
2010
|
+
]);
|
|
2011
|
+
const statusMatch = stdout.match(/^HTTP\/[\d.]+\s+(\d{3})/m);
|
|
2012
|
+
const code = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
|
2013
|
+
const digestMatch = stdout.match(/^docker-content-digest:\s*(sha256:[0-9a-f]{64})/im);
|
|
2014
|
+
const digest = digestMatch ? digestMatch[1] : void 0;
|
|
2015
|
+
return { status: code, ok: code >= 200 && code < 300, digest };
|
|
2016
|
+
} catch {
|
|
2017
|
+
return { status: 0, ok: false };
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
async function warmProxyCache(imageRef) {
|
|
2021
|
+
try {
|
|
2022
|
+
await execFile2("docker", ["pull", imageRef], { timeout: 12e4 });
|
|
2023
|
+
return true;
|
|
2024
|
+
} catch {
|
|
2025
|
+
return false;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
async function resolveImageGitShaLabel(ref, dryRun) {
|
|
2029
|
+
if (!ref || dryRun) return void 0;
|
|
2030
|
+
try {
|
|
2031
|
+
const { readProvenanceLabels } = await import("./stack-generate-manifest-AXI55MEQ.js");
|
|
2032
|
+
const labels = await readProvenanceLabels(ref);
|
|
2033
|
+
return labels["org.exe.git_sha"] ?? labels["org.opencontainers.image.revision"];
|
|
2034
|
+
} catch (err) {
|
|
2035
|
+
log("warn", `Could not read provenance labels from ${ref}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2036
|
+
return void 0;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
async function resolveDigestViaInspect(imageName, tag) {
|
|
2040
|
+
const shortName = shortImageName(imageName);
|
|
2041
|
+
const refs = [
|
|
2042
|
+
`${GHCR_REGISTRY}/askexe/${shortName}:${tag}`,
|
|
2043
|
+
`${PROXY_REGISTRY}/askexe/${shortName}:${tag}`
|
|
2044
|
+
];
|
|
2045
|
+
for (const ref of refs) {
|
|
2046
|
+
try {
|
|
2047
|
+
const { stdout } = await execFile2("docker", ["manifest", "inspect", ref, "--verbose"], {
|
|
2048
|
+
timeout: 6e4
|
|
2049
|
+
});
|
|
2050
|
+
const parsed = JSON.parse(stdout);
|
|
2051
|
+
let digest;
|
|
2052
|
+
if (Array.isArray(parsed)) {
|
|
2053
|
+
digest = parsed[0]?.Descriptor?.digest;
|
|
2054
|
+
} else {
|
|
2055
|
+
digest = parsed.Descriptor?.digest;
|
|
2056
|
+
}
|
|
2057
|
+
if (digest && /^sha256:[0-9a-f]{64}$/i.test(digest)) {
|
|
2058
|
+
log("ok", `Resolved digest via docker manifest inspect: ${shortName}:${tag} -> ${digest.slice(0, 19)}...`);
|
|
2059
|
+
return digest;
|
|
2060
|
+
}
|
|
2061
|
+
} catch {
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
try {
|
|
2065
|
+
const ghcrRef = `${GHCR_REGISTRY}/askexe/${shortName}:${tag}`;
|
|
2066
|
+
const { stdout } = await execFile2("docker", ["manifest", "inspect", ghcrRef], {
|
|
2067
|
+
timeout: 6e4
|
|
2068
|
+
});
|
|
2069
|
+
const parsed = JSON.parse(stdout);
|
|
2070
|
+
if (parsed.digest && /^sha256:[0-9a-f]{64}$/i.test(parsed.digest)) {
|
|
2071
|
+
log("ok", `Resolved digest via docker manifest inspect (non-verbose): ${shortName}:${tag} -> ${parsed.digest.slice(0, 19)}...`);
|
|
2072
|
+
return parsed.digest;
|
|
2073
|
+
}
|
|
2074
|
+
} catch {
|
|
2075
|
+
}
|
|
2076
|
+
return void 0;
|
|
2077
|
+
}
|
|
2078
|
+
async function verifyImages(changes, ciStatuses, dryRun) {
|
|
2079
|
+
if (dryRun) {
|
|
2080
|
+
return changes.map((c) => ({
|
|
2081
|
+
repo: c.repo,
|
|
2082
|
+
tag: `v${c.newVersion}`,
|
|
2083
|
+
ghcr: "ok",
|
|
2084
|
+
proxy: "ok"
|
|
2085
|
+
}));
|
|
2086
|
+
}
|
|
2087
|
+
const proxyToken = process.env.EXE_REGISTRY_PROXY_PULL_TOKEN ?? void 0;
|
|
2088
|
+
const results = [];
|
|
2089
|
+
let ghcrToken2 = process.env.GHCR_TOKEN?.trim() || void 0;
|
|
2090
|
+
if (!ghcrToken2) {
|
|
2091
|
+
try {
|
|
2092
|
+
const { stdout } = await execFile2("gh", ["auth", "token"]);
|
|
2093
|
+
ghcrToken2 = stdout.trim();
|
|
2094
|
+
} catch {
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const ghcrBearer = ghcrToken2 ? Buffer.from(ghcrToken2).toString("base64") : void 0;
|
|
2098
|
+
log("info", "Verifying Docker images are pullable (with retry for digest resolution)...");
|
|
2099
|
+
for (const change of changes) {
|
|
2100
|
+
const ci = ciStatuses.find((s) => s.repo.serviceKey === change.repo.serviceKey);
|
|
2101
|
+
if (!ci || ci.status !== "completed" || ci.conclusion !== "success") continue;
|
|
2102
|
+
const tag = `v${change.newVersion}`;
|
|
2103
|
+
const shortName = shortImageName(change.repo.imageName);
|
|
2104
|
+
const proxyRepo = `askexe/${shortName}`;
|
|
2105
|
+
const ghcrRepo = `askexe/${shortName}`;
|
|
2106
|
+
const result = {
|
|
2107
|
+
repo: change.repo,
|
|
2108
|
+
tag,
|
|
2109
|
+
ghcr: "error",
|
|
2110
|
+
proxy: "error"
|
|
2111
|
+
};
|
|
2112
|
+
const GHCR_RETRY_DELAYS = [5e3, 1e4, 2e4, 4e4];
|
|
2113
|
+
let ghcrCheck = { status: 0, ok: false };
|
|
2114
|
+
for (let attempt = 0; attempt <= GHCR_RETRY_DELAYS.length; attempt++) {
|
|
2115
|
+
ghcrCheck = await checkRegistryManifest(GHCR_REGISTRY, ghcrRepo, tag, ghcrBearer);
|
|
2116
|
+
if (ghcrCheck.ok && ghcrCheck.digest) {
|
|
2117
|
+
break;
|
|
2118
|
+
}
|
|
2119
|
+
if (ghcrCheck.status === 401 || ghcrCheck.status === 403) {
|
|
2120
|
+
break;
|
|
2121
|
+
}
|
|
2122
|
+
if (attempt < GHCR_RETRY_DELAYS.length) {
|
|
2123
|
+
const delayMs = GHCR_RETRY_DELAYS[attempt];
|
|
2124
|
+
log("info", `${shortName}:${tag} not yet available on GHCR (HTTP ${ghcrCheck.status}) \u2014 retry in ${delayMs / 1e3}s (${attempt + 1}/${GHCR_RETRY_DELAYS.length})`);
|
|
2125
|
+
await sleep(delayMs);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
if (ghcrCheck.ok) {
|
|
2129
|
+
result.ghcr = "ok";
|
|
2130
|
+
result.digest = ghcrCheck.digest;
|
|
2131
|
+
if (!ghcrCheck.digest) {
|
|
2132
|
+
log("warn", `${shortName}:${tag} is on GHCR but Docker-Content-Digest header missing \u2014 trying docker manifest inspect fallback`);
|
|
2133
|
+
const inspectDigest = await resolveDigestViaInspect(change.repo.imageName, tag);
|
|
2134
|
+
if (inspectDigest) {
|
|
2135
|
+
result.digest = inspectDigest;
|
|
2136
|
+
} else {
|
|
2137
|
+
log("warn", `${shortName}:${tag} \u2014 docker manifest inspect also failed to resolve digest. Manifest entry will be tag-only.`);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
} else if (ghcrCheck.status === 401 || ghcrCheck.status === 403) {
|
|
2141
|
+
log("warn", `GHCR auth returned ${ghcrCheck.status} for ${shortName}:${tag} \u2014 trying docker manifest inspect fallback`);
|
|
2142
|
+
const inspectDigest = await resolveDigestViaInspect(change.repo.imageName, tag);
|
|
2143
|
+
if (inspectDigest) {
|
|
2144
|
+
result.ghcr = "ok";
|
|
2145
|
+
result.digest = inspectDigest;
|
|
2146
|
+
log("ok", `${shortName}:${tag} \u2014 digest resolved via docker manifest inspect despite GHCR HEAD ${ghcrCheck.status}`);
|
|
2147
|
+
} else {
|
|
2148
|
+
result.ghcr = "error";
|
|
2149
|
+
result.detail = `GHCR auth failed (${ghcrCheck.status}) and docker manifest inspect failed \u2014 cannot verify image. Fix gh auth or GHCR_TOKEN.`;
|
|
2150
|
+
log("fail", `GHCR auth returned ${ghcrCheck.status} for ${shortName}:${tag} and inspect fallback failed \u2014 cannot verify`);
|
|
2151
|
+
}
|
|
2152
|
+
} else {
|
|
2153
|
+
result.ghcr = "missing";
|
|
2154
|
+
result.detail = `GHCR returned ${ghcrCheck.status} for ${ghcrRepo}:${tag} after ${GHCR_RETRY_DELAYS.length} retries`;
|
|
2155
|
+
log("fail", `Image NOT FOUND on GHCR after retries: ${shortName}:${tag} \u2014 release may be broken!`);
|
|
2156
|
+
}
|
|
2157
|
+
const proxyCheck = await checkRegistryManifest(PROXY_REGISTRY, proxyRepo, tag, proxyToken);
|
|
2158
|
+
if (proxyCheck.ok) {
|
|
2159
|
+
result.proxy = "ok";
|
|
2160
|
+
if (!result.digest && proxyCheck.digest) {
|
|
2161
|
+
result.digest = proxyCheck.digest;
|
|
2162
|
+
}
|
|
2163
|
+
} else {
|
|
2164
|
+
result.proxy = "pending";
|
|
2165
|
+
}
|
|
2166
|
+
if (result.ghcr === "ok" && result.proxy !== "ok") {
|
|
2167
|
+
log("info", `Warming proxy cache for ${shortName}:${tag}...`);
|
|
2168
|
+
const proxyRef = `${PROXY_REGISTRY}/${proxyRepo}:${tag}`;
|
|
2169
|
+
const warmed = await warmProxyCache(proxyRef);
|
|
2170
|
+
if (warmed) {
|
|
2171
|
+
result.proxy = "ok";
|
|
2172
|
+
log("ok", `Proxy cache warmed: ${shortName}:${tag}`);
|
|
2173
|
+
} else {
|
|
2174
|
+
result.proxy = "pending";
|
|
2175
|
+
log("warn", `Could not warm proxy for ${shortName}:${tag} \u2014 GHCR has it, proxy will cache on first customer pull`);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (result.ghcr === "ok" && result.proxy === "ok") {
|
|
2179
|
+
const digestSuffix = result.digest ? ` [${result.digest.slice(0, 19)}...]` : "";
|
|
2180
|
+
log("ok", `Image verified: ${shortName}:${tag} (GHCR + proxy)${digestSuffix}`);
|
|
2181
|
+
} else if (result.ghcr === "ok" && result.proxy === "pending") {
|
|
2182
|
+
log("warn", `Image on GHCR only: ${shortName}:${tag} (proxy will cache on first pull)`);
|
|
2183
|
+
}
|
|
2184
|
+
results.push(result);
|
|
2185
|
+
}
|
|
2186
|
+
return results;
|
|
2187
|
+
}
|
|
2188
|
+
function printImageVerification(results) {
|
|
2189
|
+
if (results.length === 0) return;
|
|
2190
|
+
console.log("\n Image verification:");
|
|
2191
|
+
for (const r of results) {
|
|
2192
|
+
const shortName = shortImageName(r.repo.imageName);
|
|
2193
|
+
const ghcrIcon = r.ghcr === "ok" ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
|
|
2194
|
+
const proxyIcon = r.proxy === "ok" ? "\x1B[32m\u2713\x1B[0m" : r.proxy === "pending" ? "\x1B[33m~\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
|
|
2195
|
+
const proxyLabel = r.proxy === "pending" ? "pending (first pull will cache)" : r.proxy;
|
|
2196
|
+
console.log(
|
|
2197
|
+
` ${shortName}:${r.tag}`.padEnd(40) + `${ghcrIcon} GHCR ${proxyIcon} proxy${r.proxy === "pending" ? ` (${proxyLabel})` : ""}`
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
console.log("");
|
|
2201
|
+
}
|
|
2202
|
+
async function updateStackManifest(changes, ciStatuses, dryRun, imageResults, canary = false, allowStalePins = []) {
|
|
2203
|
+
const manifest = JSON.parse(readFileSync4(MANIFEST_PATH, "utf8"));
|
|
2204
|
+
const currentLatest = manifest.latest;
|
|
2205
|
+
const currentParts = currentLatest.split(".").map(Number);
|
|
2206
|
+
const newStackVersion = `${currentParts[0]}.${currentParts[1]}.${(currentParts[2] ?? 0) + 1}`;
|
|
2207
|
+
const baseEntry = manifest.stacks[currentLatest];
|
|
2208
|
+
const baseServices = baseEntry?.services ?? {};
|
|
2209
|
+
const services = {};
|
|
2210
|
+
for (const [key, svc] of Object.entries(baseServices)) {
|
|
2211
|
+
services[key] = { ...svc };
|
|
2212
|
+
}
|
|
2213
|
+
validateNoStackManifestServiceExemptions(STACK_RELEASE_REPO_CONFIGS, new Set(Object.keys(services)));
|
|
2214
|
+
const successfulChanges = [];
|
|
2215
|
+
const exemptedBuilds = [];
|
|
2216
|
+
for (const change of changes) {
|
|
2217
|
+
const ciStatus = ciStatuses.find((s) => s.repo.serviceKey === change.repo.serviceKey);
|
|
2218
|
+
if (!(ciStatus && ciStatus.status === "completed" && ciStatus.conclusion === "success")) continue;
|
|
2219
|
+
const verdict = classifySuccessfulBuildPin(change.repo, new Set(Object.keys(services)));
|
|
2220
|
+
if (verdict.kind === "exempt") {
|
|
2221
|
+
log("info", verdict.reason);
|
|
2222
|
+
exemptedBuilds.push(change.repo.serviceKey);
|
|
2223
|
+
continue;
|
|
2224
|
+
}
|
|
2225
|
+
if (verdict.kind === "refuse") {
|
|
2226
|
+
throw new Error(verdict.reason);
|
|
2227
|
+
}
|
|
2228
|
+
{
|
|
2229
|
+
const manifestKey = verdict.manifestKey;
|
|
2230
|
+
const svc = services[manifestKey];
|
|
2231
|
+
if (!svc) {
|
|
2232
|
+
throw new Error(`Cannot pin successful build for ${change.repo.serviceKey}: manifest service key mismatch \u2014 services[${manifestKey}] is missing (bug ed188382).`);
|
|
2233
|
+
}
|
|
2234
|
+
successfulChanges.push(change);
|
|
2235
|
+
const tag = `v${change.newVersion}`;
|
|
2236
|
+
const imageTag = `${change.repo.imageName}:${tag}`;
|
|
2237
|
+
let digest = imageResults?.find((r) => r.repo.serviceKey === change.repo.serviceKey)?.digest;
|
|
2238
|
+
if (!digest && !dryRun) {
|
|
2239
|
+
log("info", `No digest from image verification for ${imageTag} \u2014 trying docker manifest inspect fallback (bug 85034df0)`);
|
|
2240
|
+
digest = await resolveDigestViaInspect(change.repo.imageName, tag);
|
|
2241
|
+
}
|
|
2242
|
+
if (!digest && !dryRun) {
|
|
2243
|
+
log("warn", `No content digest resolved for ${imageTag} \u2014 manifest entry will be tag-only and the digest-pinning gate will reject it.`);
|
|
2244
|
+
}
|
|
2245
|
+
svc.image = digest ? `${imageTag}@${digest}` : imageTag;
|
|
2246
|
+
const decision = applyManifestGitSha(svc, {
|
|
2247
|
+
imageLabelSha: await resolveImageGitShaLabel(svc.image, dryRun),
|
|
2248
|
+
tagCommitSha: await resolveReleaseSha(change)
|
|
2249
|
+
});
|
|
2250
|
+
if (!dryRun) {
|
|
2251
|
+
for (const warning of decision.warnings) {
|
|
2252
|
+
log("warn", `${imageTag}: ${warning}`);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
if (successfulChanges.length === 0 && !dryRun) {
|
|
2258
|
+
if (exemptedBuilds.length > 0) {
|
|
2259
|
+
log(
|
|
2260
|
+
"info",
|
|
2261
|
+
`No stack-manifest pins to update (only exempt image builds succeeded: ${exemptedBuilds.join(", ")}) \u2014 manifest not updated`
|
|
2262
|
+
);
|
|
2263
|
+
} else {
|
|
2264
|
+
log("warn", "No successful CI builds \u2014 manifest not updated");
|
|
2265
|
+
}
|
|
2266
|
+
return currentLatest;
|
|
2267
|
+
}
|
|
2268
|
+
const NOT_MIRRORED_TO_GHCR = /* @__PURE__ */ new Set(["auth"]);
|
|
2269
|
+
const pinGateMapping = {};
|
|
2270
|
+
for (const [service, repo] of Object.entries(GHCR_REPO_BY_SERVICE)) {
|
|
2271
|
+
pinGateMapping[service] = NOT_MIRRORED_TO_GHCR.has(service) ? null : repo;
|
|
2272
|
+
}
|
|
2273
|
+
for (const service of NOT_MIRRORED_TO_GHCR) {
|
|
2274
|
+
if (!Object.prototype.hasOwnProperty.call(GHCR_REPO_BY_SERVICE, service)) {
|
|
2275
|
+
throw new Error(
|
|
2276
|
+
`NOT_MIRRORED_TO_GHCR names "${service}", which has no entry in GHCR_REPO_BY_SERVICE \u2014 fix the override list (or add the service to the mapping) so the pin gate tracks it correctly.`
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
const token = await ghcrToken();
|
|
2281
|
+
const missingToken = "Cannot verify GHCR pins: set GHCR_TOKEN or configure gh auth token with read:packages.";
|
|
2282
|
+
const report = await evaluatePinCompleteness(services, {
|
|
2283
|
+
ghcrRepoForService: pinGateMapping,
|
|
2284
|
+
listTags: (repo) => {
|
|
2285
|
+
if (!token) throw new Error(missingToken);
|
|
2286
|
+
return ghcrTags(repo, token);
|
|
2287
|
+
},
|
|
2288
|
+
refExists: async (repo, tag) => {
|
|
2289
|
+
if (!token) throw new Error(missingToken);
|
|
2290
|
+
return refExists(repo, tag, token);
|
|
2291
|
+
}
|
|
2292
|
+
}, { allowStalePins });
|
|
2293
|
+
log("info", "Pin completeness: service | pinned | newest shipped | verdict");
|
|
2294
|
+
for (const [service, svc] of Object.entries(services)) {
|
|
2295
|
+
if (typeof svc.image !== "string") continue;
|
|
2296
|
+
const finding = [...report.violations, ...report.unknowns, ...report.allowed].find((f) => f.service === service);
|
|
2297
|
+
const allowed = report.allowed.some((f) => f.service === service);
|
|
2298
|
+
const verdict = allowed ? "ALLOWED (--allow-stale-pin)" : report.skipped.includes(service) ? "skipped" : report.ok.includes(service) ? "ok" : "REFUSE";
|
|
2299
|
+
const shipped = finding && "shippedTag" in finding ? finding.shippedTag : "\u2014 (no newer shipped image verified)";
|
|
2300
|
+
const detail = finding && "reason" in finding ? `: ${finding.reason}` : "";
|
|
2301
|
+
log(allowed ? "warn" : "info", `${service} | ${tagOf(svc.image) ?? "digest-only"} | ${shipped} | ${verdict}${detail}`);
|
|
2302
|
+
}
|
|
2303
|
+
const refusal = pinCompletenessRefusal(report);
|
|
2304
|
+
if (refusal) {
|
|
2305
|
+
log(dryRun ? "warn" : "fail", refusal);
|
|
2306
|
+
if (!dryRun) throw new Error(refusal);
|
|
2307
|
+
}
|
|
2308
|
+
const notesParts = [];
|
|
2309
|
+
for (const change of successfulChanges) {
|
|
2310
|
+
const summaries = change.commits.slice(0, 5).map((c) => {
|
|
2311
|
+
const msg = c.replace(/^[a-f0-9]+ /, "");
|
|
2312
|
+
return msg;
|
|
2313
|
+
});
|
|
2314
|
+
notesParts.push(`${change.repo.serviceKey} v${change.newVersion}: ${summaries.join("; ")}`);
|
|
2315
|
+
}
|
|
2316
|
+
const notes = notesParts.join(". ");
|
|
2317
|
+
const exeOsVersion = readPackageVersion(EXE_OS_ROOT);
|
|
2318
|
+
const newEntry = {
|
|
2319
|
+
version: newStackVersion,
|
|
2320
|
+
releasedAt: (/* @__PURE__ */ new Date()).toISOString().replace(/T.*/, "T00:00:00Z"),
|
|
2321
|
+
notes,
|
|
2322
|
+
npmVersion: exeOsVersion,
|
|
2323
|
+
breakingChanges: [],
|
|
2324
|
+
services,
|
|
2325
|
+
// Staged rollout (release-safety canary hardening): "canary" entries are
|
|
2326
|
+
// published (images pinned + pullable) but NOT promoted for implicit
|
|
2327
|
+
// adoption — see assertRolloutStageAllowed in src/lib/stack-update.ts.
|
|
2328
|
+
rolloutStage: canary ? "canary" : "ga"
|
|
2329
|
+
};
|
|
2330
|
+
if (dryRun) {
|
|
2331
|
+
log("dry", `Would create stack ${newStackVersion} in manifest${canary ? " (CANARY \u2014 latest would NOT be bumped)" : ""}`);
|
|
2332
|
+
log("dry", `Notes: ${notes}`);
|
|
2333
|
+
manifest.stacks[newStackVersion] = newEntry;
|
|
2334
|
+
if (!canary) manifest.latest = newStackVersion;
|
|
2335
|
+
prospectiveManifest = manifest;
|
|
2336
|
+
return newStackVersion;
|
|
2337
|
+
}
|
|
2338
|
+
manifest.stacks[newStackVersion] = newEntry;
|
|
2339
|
+
if (canary) {
|
|
2340
|
+
writeSignedStackManifest(MANIFEST_PATH, manifest, { warn: (msg) => log("warn", msg) });
|
|
2341
|
+
log("ok", `Stack manifest updated: added CANARY ${newStackVersion} (latest unchanged: ${currentLatest})`);
|
|
2342
|
+
log("warn", `${newStackVersion} is a CANARY release. Deploy to a designated canary host with:`);
|
|
2343
|
+
log("warn", ` exe-os stack-update --target ${newStackVersion} --yes`);
|
|
2344
|
+
log("warn", `Then verify health (vps-health-gate / verify-stack) and promote with:`);
|
|
2345
|
+
log("warn", ` exe-os stack-promote --version ${newStackVersion} --yes`);
|
|
2346
|
+
} else {
|
|
2347
|
+
manifest.latest = newStackVersion;
|
|
2348
|
+
writeSignedStackManifest(MANIFEST_PATH, manifest, { warn: (msg) => log("warn", msg) });
|
|
2349
|
+
log("ok", `Stack manifest updated: ${currentLatest} -> ${newStackVersion}`);
|
|
2350
|
+
}
|
|
2351
|
+
return newStackVersion;
|
|
2352
|
+
}
|
|
2353
|
+
var prospectiveManifest = null;
|
|
2354
|
+
function gatePublishWithPreflight(newStackVersion, dryRun, allowRed, releasingServiceKeys = []) {
|
|
2355
|
+
let manifest;
|
|
2356
|
+
try {
|
|
2357
|
+
manifest = dryRun && prospectiveManifest ? prospectiveManifest : JSON.parse(readFileSync4(MANIFEST_PATH, "utf8"));
|
|
2358
|
+
} catch (err) {
|
|
2359
|
+
throw new Error(`Preflight gate: cannot read manifest ${MANIFEST_PATH}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2360
|
+
}
|
|
2361
|
+
const composeDir = path3.join(EXE_OS_ROOT, "deploy", "compose");
|
|
2362
|
+
const report = runPreflightOnManifest({
|
|
2363
|
+
manifest,
|
|
2364
|
+
stackVersion: newStackVersion,
|
|
2365
|
+
label: `${MANIFEST_PATH} (stack ${newStackVersion})`,
|
|
2366
|
+
envExamplePath: path3.join(composeDir, ".env.example"),
|
|
2367
|
+
composePath: path3.join(composeDir, "docker-compose.yml"),
|
|
2368
|
+
monitorComposePath: path3.join(EXE_OS_ROOT, "deploy", "monitor", "docker-compose.hub.yml")
|
|
2369
|
+
});
|
|
2370
|
+
log(
|
|
2371
|
+
"info",
|
|
2372
|
+
dryRun && prospectiveManifest ? "Running static preflight gate against the manifest this release WOULD write..." : "Running static preflight gate against the freshly-written manifest..."
|
|
2373
|
+
);
|
|
2374
|
+
if (dryRun) {
|
|
2375
|
+
console.log(` [dry-run] preflight verdict: ${report.verdict} (pass ${report.passed} / warn ${report.warned} / fail ${report.failed})`);
|
|
2376
|
+
if (report.verdict !== "GREEN") {
|
|
2377
|
+
for (const f of report.findings) {
|
|
2378
|
+
if (f.status !== "pass") log(report.verdict === "RED" && f.status === "fail" ? "fail" : "warn", `${f.check}: ${f.message}`);
|
|
2379
|
+
}
|
|
2380
|
+
const releasing = new Set(releasingServiceKeys);
|
|
2381
|
+
const digestFails = report.findings.filter((f) => f.status === "fail" && f.check === "digest-pinning");
|
|
2382
|
+
const unavoidable = digestFails.filter(
|
|
2383
|
+
(f) => (f.offenders ?? []).length > 0 && (f.offenders ?? []).every((o) => releasing.has(String(o).split("=")[0].trim()))
|
|
2384
|
+
);
|
|
2385
|
+
const preExisting = digestFails.filter((f) => !unavoidable.includes(f));
|
|
2386
|
+
if (preExisting.length > 0) {
|
|
2387
|
+
log(
|
|
2388
|
+
"fail",
|
|
2389
|
+
"the digest-pinning failure above names service(s) this release is NOT changing, so it is a REAL unpinned image in the base manifest \u2014 not an artefact of previewing. Fix the pin."
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
if (unavoidable.length > 0) {
|
|
2393
|
+
log(
|
|
2394
|
+
"warn",
|
|
2395
|
+
"NOTE: digest-pinning cannot pass in a dry run for the services being released \u2014 their images are built AFTER the tag is pushed, so there is no digest to pin yet. This failure is inherent to previewing, not a defect in the release. Every OTHER check above was evaluated against the manifest this release would actually write."
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
assertPreflightGate(report, {
|
|
2402
|
+
context: "stack-release",
|
|
2403
|
+
allowRed,
|
|
2404
|
+
log: (m) => log("ok", m.replace(/^\[stack-release\]\s*/, "")),
|
|
2405
|
+
warn: (m) => log("warn", m.replace(/^\[stack-release\]\s*/, ""))
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
function gatePreMutationStructure(changes, dryRun, allowRed) {
|
|
2409
|
+
let baseManifest;
|
|
2410
|
+
try {
|
|
2411
|
+
baseManifest = JSON.parse(readFileSync4(MANIFEST_PATH, "utf8"));
|
|
2412
|
+
} catch (err) {
|
|
2413
|
+
throw new Error(`Pre-mutation gate: cannot read manifest ${MANIFEST_PATH}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2414
|
+
}
|
|
2415
|
+
const currentLatest = baseManifest.latest ?? "";
|
|
2416
|
+
const baseServices = baseManifest.stacks?.[currentLatest]?.services ?? {};
|
|
2417
|
+
const services = {};
|
|
2418
|
+
for (const [key, svc] of Object.entries(baseServices)) services[key] = { ...svc };
|
|
2419
|
+
for (const change of changes) {
|
|
2420
|
+
const svc = services[change.repo.serviceKey];
|
|
2421
|
+
if (svc) svc.image = `${change.repo.imageName}:v${change.newVersion}`;
|
|
2422
|
+
}
|
|
2423
|
+
const currentParts = currentLatest.split(".").map(Number);
|
|
2424
|
+
const simVersion = `${currentParts[0] ?? 0}.${currentParts[1] ?? 9}.${(currentParts[2] ?? 0) + 1}`;
|
|
2425
|
+
const simManifest = {
|
|
2426
|
+
schemaVersion: 1,
|
|
2427
|
+
latest: simVersion,
|
|
2428
|
+
stacks: { [simVersion]: { version: simVersion, services } }
|
|
2429
|
+
};
|
|
2430
|
+
const composeDir = path3.join(EXE_OS_ROOT, "deploy", "compose");
|
|
2431
|
+
const report = runPreflightOnManifest({
|
|
2432
|
+
manifest: simManifest,
|
|
2433
|
+
stackVersion: simVersion,
|
|
2434
|
+
label: `pre-mutation simulated manifest (stack ${simVersion})`,
|
|
2435
|
+
envExamplePath: path3.join(composeDir, ".env.example"),
|
|
2436
|
+
composePath: path3.join(composeDir, "docker-compose.yml"),
|
|
2437
|
+
monitorComposePath: path3.join(EXE_OS_ROOT, "deploy", "monitor", "docker-compose.hub.yml")
|
|
2438
|
+
});
|
|
2439
|
+
const adjusted = {
|
|
2440
|
+
...report,
|
|
2441
|
+
findings: report.findings.map(
|
|
2442
|
+
(f) => f.check === "digest-pinning" && f.status === "fail" ? { ...f, status: "warn", message: `${f.message} (pending image build \u2014 enforced after build)` } : f
|
|
2443
|
+
)
|
|
2444
|
+
};
|
|
2445
|
+
const failed = adjusted.findings.filter((f) => f.status === "fail").length;
|
|
2446
|
+
const warned = adjusted.findings.filter((f) => f.status === "warn").length;
|
|
2447
|
+
const passed = adjusted.findings.filter((f) => f.status === "pass").length;
|
|
2448
|
+
const verdict = failed > 0 ? "RED" : warned > 0 ? "YELLOW" : "GREEN";
|
|
2449
|
+
const adjustedReport = { ...adjusted, verdict, failed, warned, passed };
|
|
2450
|
+
log("info", "Running pre-mutation structural gate (before any remote push)...");
|
|
2451
|
+
if (dryRun) {
|
|
2452
|
+
console.log(` [dry-run] pre-mutation verdict: ${adjustedReport.verdict} (pass ${passed} / warn ${warned} / fail ${failed})`);
|
|
2453
|
+
for (const f of adjustedReport.findings) {
|
|
2454
|
+
if (f.status !== "pass") log(f.status === "fail" ? "fail" : "warn", `${f.check}: ${f.message}`);
|
|
2455
|
+
}
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
assertPreflightGate(adjustedReport, {
|
|
2459
|
+
context: "stack-release:pre-mutation",
|
|
2460
|
+
allowRed,
|
|
2461
|
+
log: (m) => log("ok", m.replace(/^\[stack-release:pre-mutation\]\s*/, "")),
|
|
2462
|
+
warn: (m) => log("warn", m.replace(/^\[stack-release:pre-mutation\]\s*/, ""))
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
async function commitAndTagExeOs(newStackVersion, _exeOsChange, dryRun) {
|
|
2466
|
+
if (dryRun) {
|
|
2467
|
+
log("dry", `Would commit manifest + tag exe-os stack-v${newStackVersion}`);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const status = await git(EXE_OS_ROOT, "status", "--porcelain", "deploy/stack-manifests/v0.9.json");
|
|
2471
|
+
if (status) {
|
|
2472
|
+
await git(EXE_OS_ROOT, "add", "deploy/stack-manifests/v0.9.json");
|
|
2473
|
+
const commitMsg = `chore: stack release v${newStackVersion}`;
|
|
2474
|
+
await git(EXE_OS_ROOT, "commit", "-m", commitMsg);
|
|
2475
|
+
await landViaPR(EXE_OS_ROOT, "AskExe/exe-os", "main", `release/stack-v${newStackVersion}`, commitMsg);
|
|
2476
|
+
log("ok", `Committed stack manifest v${newStackVersion} (via PR)`);
|
|
2477
|
+
}
|
|
2478
|
+
const stackTag = `stack-v${newStackVersion}`;
|
|
2479
|
+
const releaseCommit = await git(EXE_OS_ROOT, "rev-parse", "HEAD");
|
|
2480
|
+
await assertReleaseFreshness({
|
|
2481
|
+
repoPath: EXE_OS_ROOT,
|
|
2482
|
+
defaultBranch: "main",
|
|
2483
|
+
releaseCommit
|
|
2484
|
+
});
|
|
2485
|
+
await printOpenPrGate({ ghRepo: "AskExe/exe-os", baseBranch: "main" });
|
|
2486
|
+
try {
|
|
2487
|
+
await git(EXE_OS_ROOT, "tag", stackTag);
|
|
2488
|
+
await git(EXE_OS_ROOT, "push", "origin", stackTag);
|
|
2489
|
+
log("ok", `Tagged exe-os ${stackTag}`);
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2492
|
+
log("fail", `Failed to tag/push exe-os ${stackTag}: ${message}`);
|
|
2493
|
+
throw new Error(
|
|
2494
|
+
`Aborting: failed to tag/push exe-os ${stackTag}: ${message}. The manifest was committed but the stack-v tag that publishes it was not \u2014 customers will keep reading the previous manifest. Fix the cause and re-run; publishing is idempotent.`
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
function printSummary(changes, ciStatuses, newStackVersion, dryRun) {
|
|
2499
|
+
const divider = "\u2500".repeat(70);
|
|
2500
|
+
console.log(`
|
|
2501
|
+
${divider}`);
|
|
2502
|
+
console.log(` ${dryRun ? "[DRY RUN] " : ""}Stack Release Summary \u2014 v${newStackVersion}`);
|
|
2503
|
+
console.log(divider);
|
|
2504
|
+
console.log(
|
|
2505
|
+
` ${"Service".padEnd(14)} ${"Version".padEnd(20)} ${"Commits".padEnd(8)} ${"CI".padEnd(12)}`
|
|
2506
|
+
);
|
|
2507
|
+
console.log(` ${"\u2500".repeat(14)} ${"\u2500".repeat(20)} ${"\u2500".repeat(8)} ${"\u2500".repeat(12)}`);
|
|
2508
|
+
for (const change of changes) {
|
|
2509
|
+
const ci = ciStatuses.find((s) => s.repo.serviceKey === change.repo.serviceKey);
|
|
2510
|
+
const ciLabel = dryRun ? "\x1B[33mpending\x1B[0m" : ci?.status === "completed" && ci.conclusion === "success" ? "\x1B[32msuccess\x1B[0m" : ci?.status === "timed_out" ? "\x1B[31mtimed out\x1B[0m" : `\x1B[31m${ci?.conclusion ?? ci?.status ?? "unknown"}\x1B[0m`;
|
|
2511
|
+
const versionStr = `${change.currentVersion} -> ${change.newVersion}`;
|
|
2512
|
+
console.log(
|
|
2513
|
+
` ${change.repo.serviceKey.padEnd(14)} ${versionStr.padEnd(20)} ${String(change.commits.length).padEnd(8)} ${ciLabel}`
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
if (changes.length === 0) {
|
|
2517
|
+
console.log(" No changes detected across any repo.");
|
|
2518
|
+
}
|
|
2519
|
+
console.log(divider);
|
|
2520
|
+
if (!dryRun) {
|
|
2521
|
+
const failed = ciStatuses.filter(
|
|
2522
|
+
(s) => s.status !== "completed" || s.conclusion !== "success"
|
|
2523
|
+
);
|
|
2524
|
+
if (failed.length > 0) {
|
|
2525
|
+
console.log("\n \x1B[31mWarning:\x1B[0m Some CI builds failed. Check the links above.");
|
|
2526
|
+
} else if (changes.length > 0) {
|
|
2527
|
+
console.log(
|
|
2528
|
+
`
|
|
2529
|
+
\x1B[32mAll done.\x1B[0m Customers will pull new images from update.askexe.com on next stack-update.`
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
console.log("");
|
|
2534
|
+
}
|
|
2535
|
+
function log(level, msg) {
|
|
2536
|
+
const prefix = {
|
|
2537
|
+
info: "\x1B[36m[info]\x1B[0m",
|
|
2538
|
+
ok: "\x1B[32m[ok]\x1B[0m",
|
|
2539
|
+
warn: "\x1B[33m[warn]\x1B[0m",
|
|
2540
|
+
fail: "\x1B[31m[fail]\x1B[0m",
|
|
2541
|
+
dry: "\x1B[35m[dry-run]\x1B[0m"
|
|
2542
|
+
};
|
|
2543
|
+
console.log(` ${prefix[level]} ${msg}`);
|
|
2544
|
+
}
|
|
2545
|
+
function sleep(ms) {
|
|
2546
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2547
|
+
}
|
|
2548
|
+
async function confirm(message) {
|
|
2549
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2550
|
+
return new Promise((resolve) => {
|
|
2551
|
+
rl.question(`
|
|
2552
|
+
${message} [y/N] `, (answer) => {
|
|
2553
|
+
rl.close();
|
|
2554
|
+
resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
function nextStackVersionForLock() {
|
|
2559
|
+
try {
|
|
2560
|
+
const manifest = JSON.parse(readFileSync4(MANIFEST_PATH, "utf8"));
|
|
2561
|
+
const parts = String(manifest.latest ?? "0.9.0").split(".").map(Number);
|
|
2562
|
+
return `stack-v${parts[0] ?? 0}.${parts[1] ?? 9}.${(parts[2] ?? 0) + 1}`;
|
|
2563
|
+
} catch {
|
|
2564
|
+
return "stack-vunknown";
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
async function currentReleaseBranch() {
|
|
2568
|
+
try {
|
|
2569
|
+
return await git(EXE_OS_ROOT, "branch", "--show-current") || "unknown";
|
|
2570
|
+
} catch {
|
|
2571
|
+
return "unknown";
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
function forceBuildRejection(flags) {
|
|
2575
|
+
if (!flags.forceBuildRejected) return null;
|
|
2576
|
+
return [
|
|
2577
|
+
"--force-build is not supported and has been removed (bug b55695a5).",
|
|
2578
|
+
"It never bypassed any gate: nothing downstream read it, images are built by tag push,",
|
|
2579
|
+
"and docker.yml's green-CI gate stayed fully in force. It only printed a bypass banner.",
|
|
2580
|
+
"Fix the red CI, or cut the release from a green SHA. Re-run without --force-build."
|
|
2581
|
+
].join("\n");
|
|
2582
|
+
}
|
|
2583
|
+
async function runStackRelease(flags) {
|
|
2584
|
+
if (!flags.stack) {
|
|
2585
|
+
console.error("Usage: exe-os release --stack [--dry-run] [--repos <list>] [--yes] [--skip-image-verify] [--allow-stale-pin <service>]... [--allow-red] [--allow-dirty] [--canary] [--root <path>] [--force]");
|
|
2586
|
+
process.exit(1);
|
|
2587
|
+
}
|
|
2588
|
+
if (flags.root) {
|
|
2589
|
+
applyRootOverride(flags.root);
|
|
2590
|
+
}
|
|
2591
|
+
const forceBuildError = forceBuildRejection(flags);
|
|
2592
|
+
if (forceBuildError) {
|
|
2593
|
+
console.error(forceBuildError);
|
|
2594
|
+
process.exit(1);
|
|
2595
|
+
}
|
|
2596
|
+
if (flags.force) {
|
|
2597
|
+
console.warn("\n \x1B[33;1m\u26A0 --force: release mutex override is ACTIVE\x1B[0m");
|
|
2598
|
+
console.warn(" \x1B[33m This only overrides ~/.exe-os/release.lock; freshness and publish gates still apply.\x1B[0m");
|
|
2599
|
+
console.warn(" \x1B[33m Use only after verifying no other release is actually running.\x1B[0m\n");
|
|
2600
|
+
}
|
|
2601
|
+
let releaseLock;
|
|
2602
|
+
if (!flags.dryRun) {
|
|
2603
|
+
releaseLock = acquireReleaseLock({
|
|
2604
|
+
targetVersion: nextStackVersionForLock(),
|
|
2605
|
+
branch: await currentReleaseBranch(),
|
|
2606
|
+
force: flags.force,
|
|
2607
|
+
log: (message) => log("warn", message)
|
|
2608
|
+
});
|
|
2609
|
+
log("ok", `Acquired release lock: ${releaseLock.lockPath}`);
|
|
2610
|
+
}
|
|
2611
|
+
try {
|
|
2612
|
+
await assertCleanWorkingTree({ allowDirty: flags.allowDirty || flags.dryRun });
|
|
2613
|
+
console.log(`
|
|
2614
|
+
\x1B[1mexe-os stack release\x1B[0m${flags.dryRun ? " (dry run)" : ""}
|
|
2615
|
+
`);
|
|
2616
|
+
const allRepos = buildRepoConfigs();
|
|
2617
|
+
let repos;
|
|
2618
|
+
if (flags.repos) {
|
|
2619
|
+
repos = [];
|
|
2620
|
+
for (const name of flags.repos) {
|
|
2621
|
+
const match = allRepos.find(
|
|
2622
|
+
(r) => r.serviceKey === name || r.ghRepo.endsWith(`/${name}`) || r.ghRepo === name
|
|
2623
|
+
);
|
|
2624
|
+
if (match) {
|
|
2625
|
+
repos.push(match);
|
|
2626
|
+
} else {
|
|
2627
|
+
log("warn", `Unknown repo: ${name} \u2014 skipping`);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
if (repos.length === 0) {
|
|
2631
|
+
throw new Error("No valid repos specified.");
|
|
2632
|
+
}
|
|
2633
|
+
} else {
|
|
2634
|
+
repos = allRepos;
|
|
2635
|
+
}
|
|
2636
|
+
repos = repos.filter((repo) => {
|
|
2637
|
+
if (hasPackageJson(repo)) return true;
|
|
2638
|
+
warnMissingRepoPackage(repo);
|
|
2639
|
+
return false;
|
|
2640
|
+
});
|
|
2641
|
+
log("info", "Detecting changes across repos...");
|
|
2642
|
+
const changes = await detectChanges(repos);
|
|
2643
|
+
if (changes.length === 0) {
|
|
2644
|
+
console.log("\n No changes detected since last tags. Nothing to release.\n");
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
const bumpMode = flags.major ? "major" : flags.minor ? "minor" : "patch";
|
|
2648
|
+
for (const change of changes) {
|
|
2649
|
+
const newVersion = bumpVersion(change.currentVersion, bumpMode);
|
|
2650
|
+
change.newVersion = newVersion;
|
|
2651
|
+
change.tag = `v${newVersion}`;
|
|
2652
|
+
}
|
|
2653
|
+
console.log("\n Planned releases:");
|
|
2654
|
+
for (const change of changes) {
|
|
2655
|
+
console.log(
|
|
2656
|
+
` ${change.repo.serviceKey.padEnd(14)} ${change.currentVersion} -> ${change.newVersion} (${change.commits.length} commit${change.commits.length === 1 ? "" : "s"})`
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
gatePreMutationStructure(changes, flags.dryRun, flags.allowRed);
|
|
2660
|
+
if (!flags.yes && !flags.dryRun) {
|
|
2661
|
+
const ok = await confirm("Proceed with release?");
|
|
2662
|
+
if (!ok) {
|
|
2663
|
+
console.log(" Aborted.\n");
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
await bumpVersions(changes, bumpMode, flags.dryRun);
|
|
2668
|
+
log("info", "Tagging repos and triggering CI...");
|
|
2669
|
+
await tagAndTriggerCI(changes, flags.dryRun);
|
|
2670
|
+
const ciStatuses = await waitForCI(changes, flags.dryRun);
|
|
2671
|
+
const ciFailures = ciStatuses.filter(
|
|
2672
|
+
(s) => s.status !== "completed" || s.conclusion !== "success"
|
|
2673
|
+
);
|
|
2674
|
+
if (ciFailures.length > 0 && !flags.dryRun) {
|
|
2675
|
+
await rollbackFailedReleases(changes, ciStatuses, flags.dryRun);
|
|
2676
|
+
const ciSuccesses = ciStatuses.filter(
|
|
2677
|
+
(s) => s.status === "completed" && s.conclusion === "success"
|
|
2678
|
+
);
|
|
2679
|
+
if (ciSuccesses.length === 0) {
|
|
2680
|
+
log("fail", "All CI builds failed. Release aborted, all bumps rolled back.");
|
|
2681
|
+
throw new Error("All CI builds failed. Release aborted, all bumps rolled back.");
|
|
2682
|
+
}
|
|
2683
|
+
log("warn", `${ciFailures.length} CI build(s) failed and were rolled back. Continuing with ${ciSuccesses.length} successful build(s).`);
|
|
2684
|
+
}
|
|
2685
|
+
let imageResults = [];
|
|
2686
|
+
if (flags.skipImageVerify) {
|
|
2687
|
+
log("warn", "Image verification skipped (--skip-image-verify)");
|
|
2688
|
+
} else {
|
|
2689
|
+
imageResults = await verifyImages(changes, ciStatuses, flags.dryRun);
|
|
2690
|
+
const failed = imageResults.filter((r) => (r.ghcr === "missing" || r.ghcr === "error") && r.proxy !== "ok");
|
|
2691
|
+
if (failed.length > 0 && !flags.dryRun) {
|
|
2692
|
+
for (const m of failed) {
|
|
2693
|
+
log("fail", `${shortImageName(m.repo.imageName)}:${m.tag} \u2014 GHCR status: ${m.ghcr}, proxy: ${m.proxy} \u2014 cannot release`);
|
|
2694
|
+
}
|
|
2695
|
+
log("fail", "Aborting: images not verified. Fix GHCR auth/CI or use --skip-image-verify to override.");
|
|
2696
|
+
throw new Error("Aborting: images not verified. Fix GHCR auth/CI or use --skip-image-verify to override.");
|
|
2697
|
+
}
|
|
2698
|
+
const pending = imageResults.filter((r) => r.proxy === "pending");
|
|
2699
|
+
if (pending.length > 0) {
|
|
2700
|
+
for (const p of pending) {
|
|
2701
|
+
log("warn", `${shortImageName(p.repo.imageName)}:${p.tag} \u2014 proxy cache pending`);
|
|
2702
|
+
}
|
|
2703
|
+
if (!flags.dryRun) {
|
|
2704
|
+
log("fail", "Aborting: proxy cache not ready for all images. Wait for proxy propagation or use --skip-image-verify.");
|
|
2705
|
+
throw new Error("Aborting: proxy cache not ready for all images. Wait for proxy propagation or use --skip-image-verify.");
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
printImageVerification(imageResults);
|
|
2709
|
+
}
|
|
2710
|
+
log("info", "Updating stack manifest...");
|
|
2711
|
+
const newStackVersion = await updateStackManifest(changes, ciStatuses, flags.dryRun, imageResults, flags.canary, flags.allowStalePins);
|
|
2712
|
+
gatePublishWithPreflight(
|
|
2713
|
+
newStackVersion,
|
|
2714
|
+
flags.dryRun,
|
|
2715
|
+
flags.allowRed,
|
|
2716
|
+
changes.map((c) => c.repo.serviceKey)
|
|
2717
|
+
);
|
|
2718
|
+
const exeOsChange = changes.find((c) => c.repo.serviceKey === "exe-os");
|
|
2719
|
+
await commitAndTagExeOs(newStackVersion, exeOsChange, flags.dryRun);
|
|
2720
|
+
printSummary(changes, ciStatuses, newStackVersion, flags.dryRun);
|
|
2721
|
+
if (!flags.skipImageVerify && !flags.dryRun && imageResults.length > 0) {
|
|
2722
|
+
const pendingImages = imageResults.filter((r) => r.proxy === "pending");
|
|
2723
|
+
if (pendingImages.length > 0) {
|
|
2724
|
+
log("info", "Re-checking proxy availability for pending images...");
|
|
2725
|
+
for (const r of pendingImages) {
|
|
2726
|
+
const shortName = shortImageName(r.repo.imageName);
|
|
2727
|
+
const proxyRepo = `askexe/${shortName}`;
|
|
2728
|
+
const proxyToken = process.env.EXE_REGISTRY_PROXY_PULL_TOKEN ?? void 0;
|
|
2729
|
+
const recheck = await checkRegistryManifest(PROXY_REGISTRY, proxyRepo, r.tag, proxyToken);
|
|
2730
|
+
if (recheck.ok) {
|
|
2731
|
+
r.proxy = "ok";
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
printImageVerification(imageResults);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
} finally {
|
|
2738
|
+
releaseLock?.release();
|
|
2739
|
+
if (releaseLock) log("ok", `Released release lock: ${releaseLock.lockPath}`);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
export {
|
|
2743
|
+
NO_STACK_MANIFEST_SERVICE,
|
|
2744
|
+
RELEASE_PR_POLL_JQ,
|
|
2745
|
+
assertCleanWorkingTree,
|
|
2746
|
+
assertReleaseFreshness,
|
|
2747
|
+
assertVersionNotAlreadyReleased,
|
|
2748
|
+
classifySuccessfulBuildPin,
|
|
2749
|
+
compareSemver,
|
|
2750
|
+
deriveCloneUrl,
|
|
2751
|
+
ensureRepoCloned,
|
|
2752
|
+
findPullRequestForHeadSha,
|
|
2753
|
+
findWorkflowRunForHeadSha,
|
|
2754
|
+
forceBuildRejection,
|
|
2755
|
+
getRemoteReleaseTags,
|
|
2756
|
+
higherSemver,
|
|
2757
|
+
highestReleasedVersion,
|
|
2758
|
+
imageRepoPath,
|
|
2759
|
+
isCloneTargetSafe,
|
|
2760
|
+
isStrictSemverTag,
|
|
2761
|
+
manifestServiceKeyOf,
|
|
2762
|
+
mayRebaseComponent,
|
|
2763
|
+
parseReleaseArgs,
|
|
2764
|
+
parseRemoteTagNames,
|
|
2765
|
+
printOpenPrGate,
|
|
2766
|
+
rebaseImageTag,
|
|
2767
|
+
resolveVersionBasis,
|
|
2768
|
+
runStackRelease,
|
|
2769
|
+
validateNoStackManifestServiceExemptions
|
|
2770
|
+
};
|