diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml new file mode 100644 index 0000000..b09507f --- /dev/null +++ b/.github/workflows/npm-release.yml @@ -0,0 +1,120 @@ +name: npm release + +# Publishes @getdevintern/code and/or @getdevintern/pm to npm. +# +# Triggered by pushing a version tag named after the package: +# git tag code-v2.4.1 && git push origin code-v2.4.1 -> publishes @getdevintern/code +# git tag pm-v2.4.1 && git push origin pm-v2.4.1 -> publishes @getdevintern/pm +# +# Publishing uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret. +# +# Required one-time setup per package on npmjs.com: +# Package -> Settings -> Trusted Publisher -> GitHub Actions: +# Organization/Repository: getdevintern/devintern +# Workflow filename: npm-release.yml +# Environment: (leave empty) +# The very first publish of a new package cannot use OIDC (the package must +# exist before a publisher can be configured) — do that once manually with +# `bun publish` from the package directory, then configure the above. +# +# Required secrets: +# POSTHOG_API_KEY PostHog project key, baked into the @getdevintern/code bundle +# Optional variables: +# POSTHOG_HOST PostHog ingest host (defaults to https://us.i.posthog.com) + +on: + push: + tags: + - "code-v*" + - "pm-v*" + workflow_dispatch: + inputs: + package: + description: "Package to release" + type: choice + default: both + options: + - both + - code + - pm + +concurrency: + group: npm-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + id-token: write # npm Trusted Publishing (OIDC) + +jobs: + publish: + strategy: + fail-fast: false + matrix: + include: + - package: code + dir: packages/code + tag_prefix: code-v + - package: pm + dir: packages/pm + tag_prefix: pm-v + + # Tag pushes publish only the tagged package; manual dispatch follows the input. + if: >- + (startsWith(github.ref, 'refs/tags/') && startsWith(github.ref_name, matrix.tag_prefix)) || + (github.event_name == 'workflow_dispatch' && + (inputs.package == 'both' || inputs.package == matrix.package)) + + runs-on: ubuntu-latest + env: + # Job-level so any rebuild (e.g. a prepublishOnly hook during publish) + # still bakes the analytics key into the @getdevintern/code bundle. + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST || secrets.POSTHOG_HOST }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify tag matches package.json version + if: startsWith(github.ref, 'refs/tags/') + working-directory: ${{ matrix.dir }} + run: | + expected="${GITHUB_REF_NAME#${{ matrix.tag_prefix }}}" + actual="$(bun -p "require('./package.json').version")" + if [ "$expected" != "$actual" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match package version ${actual}." >&2 + exit 1 + fi + + - name: Build + working-directory: ${{ matrix.dir }} + run: bun run build + + - name: Typecheck + working-directory: ${{ matrix.dir }} + run: bun run typecheck + + - name: Publish + working-directory: ${{ matrix.dir }} + # Trusted Publishing requires the npm CLI (>= 11.5.1) to perform the + # OIDC exchange; bun publish does not support it. Provenance is + # attached automatically when publishing via OIDC. + run: | + npm install -g npm@latest + npm publish --access public + + - name: Create GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/code/src/lib/automation-acquirer.ts b/packages/code/src/lib/automation-acquirer.ts index 71b3622..6170605 100644 --- a/packages/code/src/lib/automation-acquirer.ts +++ b/packages/code/src/lib/automation-acquirer.ts @@ -49,6 +49,8 @@ export interface AutomationAcquirerOptions { terminationGraceMs?: number; setTimer?: (callback: () => void, delay: number) => ReturnType; clearTimer?: (timer: ReturnType) => void; + setInterval?: (callback: () => void, ms: number) => ReturnType; + clearInterval?: (timer: ReturnType) => void; } interface ActiveAutomationRun { @@ -153,7 +155,9 @@ export class AutomationAcquirer implements Acquirer { try { let ownsClaim = true; const heartbeatMs = Math.min(this.options.heartbeatMs ?? HEARTBEAT_MS, leaseMs / 2); - const preparationHeartbeat = setInterval( + const setHeartbeatInterval = this.options.setInterval ?? setInterval; + const clearHeartbeatInterval = this.options.clearInterval ?? clearInterval; + const preparationHeartbeat = setHeartbeatInterval( () => { if (!this.store.heartbeat(automation.id, this.owner, this.now(), leaseMs)) { ownsClaim = false; @@ -161,11 +165,11 @@ export class AutomationAcquirer implements Acquirer { }, Math.max(1, heartbeatMs), ); - preparationHeartbeat.unref(); + (preparationHeartbeat as { unref?: () => void }).unref?.(); try { context = await this.options.resolveContext(automation); } finally { - clearInterval(preparationHeartbeat); + clearHeartbeatInterval(preparationHeartbeat); } if (!context) { console.warn(`⏭️ [automation:${automation.id}] occurrence skipped: repository is busy`); diff --git a/packages/code/tests/automation-acquirer.test.ts b/packages/code/tests/automation-acquirer.test.ts index 1bd3606..b8e098c 100644 --- a/packages/code/tests/automation-acquirer.test.ts +++ b/packages/code/tests/automation-acquirer.test.ts @@ -249,10 +249,28 @@ describe("AutomationAcquirer", () => { test("heartbeats a claim while context resolution exceeds the lease", async () => { const dbPath = join(tmpdir(), `acquirer-${Date.now()}-${Math.random()}.db`); dbPaths.push(dbPath); - let preparationStarted!: () => void; - const started = new Promise((resolve) => (preparationStarted = resolve)); + let now = 0; let firstRuns = 0; let secondContexts = 0; + // Injected heartbeat interval so beats fire on a manual clock — real + // timers made this test flaky under CI load (a single delayed beat let + // the 40ms lease expire and the second acquirer steal the claim). + const heartbeatTimers: Array<{ callback: () => void }> = []; + const timerHandles = { + setTimer: () => 1 as unknown as ReturnType, + clearTimer: () => {}, + setInterval: (callback: () => void) => { + const timer = { callback }; + heartbeatTimers.push(timer); + return timer as unknown as ReturnType; + }, + clearInterval: (timer: ReturnType) => { + const index = heartbeatTimers.indexOf(timer as unknown as { callback: () => void }); + if (index >= 0) heartbeatTimers.splice(index, 1); + }, + }; + let releaseContext!: () => void; + const contextGate = new Promise((resolve) => (releaseContext = resolve)); const automation: AutomationConfig = { id: "slow-context", enabled: true, @@ -265,9 +283,10 @@ describe("AutomationAcquirer", () => { dbPath, leaseMs: 40, heartbeatMs: 10, + now: () => now, + ...timerHandles, resolveContext: async () => { - preparationStarted(); - await new Promise((resolve) => setTimeout(resolve, 120)); + await contextGate; return { cwd: "/tmp", env: {}, release() {} }; }, spawnRun: () => { @@ -280,6 +299,8 @@ describe("AutomationAcquirer", () => { dbPath, leaseMs: 40, heartbeatMs: 10, + now: () => now, + ...timerHandles, resolveContext: async () => { secondContexts += 1; return { cwd: "/tmp", env: {}, release() {} }; @@ -288,14 +309,28 @@ describe("AutomationAcquirer", () => { }); await first.start(); - await started; - await new Promise((resolve) => setTimeout(resolve, 70)); + // Advance past the initial cursor and let the first occurrence claim. + // Registration of the heartbeat interval happens synchronously before + // resolveContext suspends on the gate, so it exists right after the call. + now += 10; + const claiming = first.tick(); + expect(heartbeatTimers).toHaveLength(1); + + // Advance past the original lease expiry (t=40), firing every scheduled + // heartbeat. Each beat renews the lease to now + 40ms, so it never lapses. + for (let beat = 0; beat < 5; beat++) { + now += 10; + for (const timer of [...heartbeatTimers]) timer.callback(); + } + await second.start(); expect(secondContexts).toBe(0); await second.stop(); - await new Promise((resolve) => setTimeout(resolve, 70)); + releaseContext(); + await claiming; expect(firstRuns).toBe(1); + expect(heartbeatTimers).toHaveLength(0); await first.stop(); });