Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 7 additions & 3 deletions packages/code/src/lib/automation-acquirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface AutomationAcquirerOptions {
terminationGraceMs?: number;
setTimer?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
setInterval?: (callback: () => void, ms: number) => ReturnType<typeof setInterval>;
clearInterval?: (timer: ReturnType<typeof setInterval>) => void;
}

interface ActiveAutomationRun {
Expand Down Expand Up @@ -153,19 +155,21 @@ 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;
}
},
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`);
Expand Down
49 changes: 42 additions & 7 deletions packages/code/tests/automation-acquirer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<typeof setTimeout>,
clearTimer: () => {},
setInterval: (callback: () => void) => {
const timer = { callback };
heartbeatTimers.push(timer);
return timer as unknown as ReturnType<typeof setInterval>;
},
clearInterval: (timer: ReturnType<typeof setInterval>) => {
const index = heartbeatTimers.indexOf(timer as unknown as { callback: () => void });
if (index >= 0) heartbeatTimers.splice(index, 1);
},
};
let releaseContext!: () => void;
const contextGate = new Promise<void>((resolve) => (releaseContext = resolve));
const automation: AutomationConfig = {
id: "slow-context",
enabled: true,
Expand All @@ -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: () => {
Expand All @@ -280,6 +299,8 @@ describe("AutomationAcquirer", () => {
dbPath,
leaseMs: 40,
heartbeatMs: 10,
now: () => now,
...timerHandles,
resolveContext: async () => {
secondContexts += 1;
return { cwd: "/tmp", env: {}, release() {} };
Expand All @@ -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();
});

Expand Down
Loading