From 2deeb765f2fe966f60dcb55b4b6885338493b92b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:54:56 +0530 Subject: [PATCH 1/7] chore(release): add package repository metadata --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index fb6fe590..202834dd 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "@waishnav/devspace", "version": "1.0.8", "description": "Expose a secure local coding workspace through an MCP server.", + "repository": { + "type": "git", + "url": "git+https://github.com/Waishnav/devspace.git" + }, "type": "module", "main": "dist/server.js", "engines": { From b251cd8275aae5af6877969c41603832c32b7d9c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:59:25 +0530 Subject: [PATCH 2/7] ci(release): publish releases from GitHub Actions --- .github/workflows/release.yml | 182 ++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d3550f82 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,182 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Version to publish + required: true + type: string + +permissions: + actions: read + contents: write + id-token: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + publish: + name: Publish ${{ inputs.version }} + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DEVSPACE_OAUTH_OWNER_TOKEN: ci-owner-token-that-is-long-enough + VERSION: ${{ inputs.version }} + steps: + - name: Checkout release source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Require main + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Releases must be dispatched from main; got $GITHUB_REF" >&2 + exit 1 + fi + + - name: Require successful main CI + env: + GH_TOKEN: ${{ github.token }} + run: | + state="$(gh run list \ + --workflow ci.yml \ + --commit "$GITHUB_SHA" \ + --event push \ + --limit 1 \ + --json status,conclusion \ + --jq '.[0] | "\(.status):\(.conclusion)"')" + if [[ "$state" != "completed:success" ]]; then + echo "The exact release commit must have a successful main CI push run; got ${state:-no run}." >&2 + exit 1 + fi + + - name: Resolve release metadata + id: release + run: | + stable='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + prerelease='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-(beta|rc)\.([1-9][0-9]*)$' + + if [[ "$VERSION" =~ $stable ]]; then + npm_tag=latest + is_prerelease=false + source_version="$(node -p 'require("./package.json").version')" + if [[ "$source_version" != "$VERSION" ]]; then + echo "Stable releases require package.json version $VERSION; found $source_version." >&2 + exit 1 + fi + elif [[ "$VERSION" =~ $prerelease ]]; then + npm_tag=beta + is_prerelease=true + else + echo "Unsupported release version: $VERSION" >&2 + echo "Use X.Y.Z-beta.N, X.Y.Z-rc.N, or X.Y.Z." >&2 + exit 1 + fi + + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "npm_tag=$npm_tag" >> "$GITHUB_OUTPUT" + echo "prerelease=$is_prerelease" >> "$GITHUB_OUTPUT" + + - name: Verify release tag ownership + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + if git show-ref --verify --quiet "refs/tags/$TAG"; then + tag_sha="$(git rev-parse "$TAG^{commit}")" + if [[ "$tag_sha" != "$GITHUB_SHA" ]]; then + echo "$TAG already points to $tag_sha instead of $GITHUB_SHA." >&2 + exit 1 + fi + fi + + - name: Setup pnpm and Node + uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2 + with: + runtime: node@22 + cache: false + install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set package version + run: | + npm pkg set version="$VERSION" + node -e 'const pkg = require("./package.json"); if (pkg.version !== process.env.VERSION) process.exit(1)' + + - name: Typecheck + run: pnpm typecheck + + - name: Test + env: + DEVSPACE_REQUIRE_PI_SANDBOX: "0" + run: pnpm test + + - name: Package install smoke test + run: pnpm test:package-install + + - name: Pack release artifact + id: pack + run: | + mkdir -p .release + filename="$(npm pack --silent --pack-destination .release | tail -n 1)" + test -n "$filename" + echo "tarball=.release/$filename" >> "$GITHUB_OUTPUT" + + - name: Prepare draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + IS_PRERELEASE: ${{ steps.release.outputs.prerelease }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Reusing existing GitHub release $TAG." + else + args=(release create "$TAG" --draft --title "$TAG" --target "$GITHUB_SHA" --generate-notes) + if [[ "$IS_PRERELEASE" == "true" ]]; then + args+=(--prerelease) + fi + gh "${args[@]}" + fi + + - name: Upload release artifact + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TARBALL: ${{ steps.pack.outputs.tarball }} + run: gh release upload "$TAG" "$TARBALL" --clobber + + - name: Setup npm trusted publishing + run: | + npm install --global npm@11.8.0 + npm config set registry https://registry.npmjs.org/ + npm --version + + - name: Publish npm package + env: + NPM_TAG: ${{ steps.release.outputs.npm_tag }} + TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + package_name="$(node -p 'require("./package.json").name')" + if published="$(npm view "$package_name@$VERSION" version 2>/dev/null)" && [[ "$published" == "$VERSION" ]]; then + echo "$package_name@$VERSION is already published; skipping npm publish." + else + npm publish "$TARBALL" --access public --tag "$NPM_TAG" --provenance + fi + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + IS_PRERELEASE: ${{ steps.release.outputs.prerelease }} + run: | + if [[ "$IS_PRERELEASE" == "true" ]]; then + gh release edit "$TAG" --draft=false --prerelease + else + gh release edit "$TAG" --draft=false --prerelease=false --latest + fi From 7b7c97443ee3c8ec0871e5a5444b4328e4948b37 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:59:35 +0530 Subject: [PATCH 3/7] docs(release): document CI release workflow --- docs/development.md | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/development.md b/docs/development.md index ce55db2e..3a977771 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,3 +95,50 @@ pnpm typecheck pnpm test pnpm build ``` + +## Releases + +Releases are published by the manual `Release` GitHub Actions workflow. Do not +publish the package directly from a development checkout for normal releases. +The workflow only accepts runs dispatched from `main`, and the exact commit must +already have a successful `CI` push run. + +Prereleases use the `beta` npm dist-tag. Both beta and release-candidate +versions follow the same install channel: + +```text +1.1.0-beta.1 -> @beta +1.1.0-beta.2 -> @beta +1.1.0-rc.1 -> @beta +1.1.0 -> @latest +``` + +Run the workflow from GitHub Actions and enter the version without a leading +`v`, for example `1.1.0-beta.1`. The workflow temporarily writes prerelease +versions into `package.json`, validates and packs that exact source commit, +publishes the resulting tarball to npm, and then publishes the matching GitHub +release. Prerelease version changes are not committed back to `main`. + +Stable releases are different: `package.json` must already contain the stable +version being published. Land that normal version update on `main`, let CI pass, +then dispatch the Release workflow with the same version. This keeps the source +tree aligned with the latest stable release without creating version commits for +every beta or release candidate. + +### npm trusted publishing setup + +The release workflow authenticates to npm through GitHub Actions OIDC instead of +a long-lived npm token. Configure `@waishnav/devspace` on npm with a GitHub +Actions trusted publisher using: + +- repository owner: `Waishnav` +- repository: `devspace` +- workflow filename: `release.yml` +- no GitHub environment +- allow direct `npm publish` + +The workflow uses a GitHub-hosted runner, requests `id-token: write`, and pins an +npm CLI new enough for trusted publishing. Its package artifact is also attached +to a draft GitHub release before npm publication; the GitHub release is made +public only after npm succeeds. Re-running the same version reuses the matching +release/tag and skips npm publication when that version already exists. From ac538eb34c7bfc1f7e690678edb1fb1338da5da2 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:41:13 +0530 Subject: [PATCH 4/7] fix(release): verify retry artifact identity --- .github/workflows/release.yml | 84 ++++++++++++++++++++++++++++++----- docs/development.md | 11 ++++- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3550f82..a99bde4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,16 +126,82 @@ jobs: mkdir -p .release filename="$(npm pack --silent --pack-destination .release | tail -n 1)" test -n "$filename" - echo "tarball=.release/$filename" >> "$GITHUB_OUTPUT" + tarball=".release/$filename" + integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)" + echo "filename=$filename" >> "$GITHUB_OUTPUT" + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + echo "integrity=$integrity" >> "$GITHUB_OUTPUT" + + - name: Inspect existing publication + id: publication + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TARBALL: ${{ steps.pack.outputs.tarball }} + FILENAME: ${{ steps.pack.outputs.filename }} + LOCAL_INTEGRITY: ${{ steps.pack.outputs.integrity }} + IS_PRERELEASE: ${{ steps.release.outputs.prerelease }} + run: | + package_name="$(node -p 'require("./package.json").name')" + npm_exists=false + if npm_integrity="$(npm view "$package_name@$VERSION" dist.integrity 2>/dev/null)"; then + if [[ -z "$npm_integrity" || "$npm_integrity" != "$LOCAL_INTEGRITY" ]]; then + echo "$package_name@$VERSION already exists on npm with different integrity." >&2 + exit 1 + fi + npm_exists=true + fi + + release_exists=false + release_draft=false + complete=false + if release_json="$(gh release view "$TAG" --json isDraft,isPrerelease,targetCommitish 2>/dev/null)"; then + release_exists=true + release_draft="$(jq -r .isDraft <<<"$release_json")" + release_prerelease="$(jq -r .isPrerelease <<<"$release_json")" + release_target="$(jq -r .targetCommitish <<<"$release_json")" + if [[ "$release_target" != "$GITHUB_SHA" ]]; then + echo "$TAG already targets $release_target instead of $GITHUB_SHA." >&2 + exit 1 + fi + if [[ "$release_draft" != "true" ]]; then + if [[ "$release_prerelease" != "$IS_PRERELEASE" ]]; then + echo "$TAG is already public with the wrong prerelease state." >&2 + exit 1 + fi + if [[ "$npm_exists" != "true" ]]; then + echo "$TAG is already public on GitHub but $package_name@$VERSION is missing from npm." >&2 + exit 1 + fi + + mkdir -p .release/existing + if ! gh release download "$TAG" --pattern "$FILENAME" --dir .release/existing --clobber; then + echo "$TAG is already public but its release artifact could not be verified." >&2 + exit 1 + fi + if ! cmp --silent "$TARBALL" ".release/existing/$FILENAME"; then + echo "$TAG is already public with a different release artifact." >&2 + exit 1 + fi + complete=true + fi + fi + + echo "npm_exists=$npm_exists" >> "$GITHUB_OUTPUT" + echo "release_exists=$release_exists" >> "$GITHUB_OUTPUT" + echo "release_draft=$release_draft" >> "$GITHUB_OUTPUT" + echo "complete=$complete" >> "$GITHUB_OUTPUT" - name: Prepare draft GitHub release + if: steps.publication.outputs.complete != 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.release.outputs.tag }} IS_PRERELEASE: ${{ steps.release.outputs.prerelease }} + RELEASE_EXISTS: ${{ steps.publication.outputs.release_exists }} run: | - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Reusing existing GitHub release $TAG." + if [[ "$RELEASE_EXISTS" == "true" ]]; then + echo "Reusing existing draft GitHub release $TAG." else args=(release create "$TAG" --draft --title "$TAG" --target "$GITHUB_SHA" --generate-notes) if [[ "$IS_PRERELEASE" == "true" ]]; then @@ -145,6 +211,7 @@ jobs: fi - name: Upload release artifact + if: steps.publication.outputs.complete != 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.release.outputs.tag }} @@ -152,24 +219,21 @@ jobs: run: gh release upload "$TAG" "$TARBALL" --clobber - name: Setup npm trusted publishing + if: steps.publication.outputs.complete != 'true' && steps.publication.outputs.npm_exists != 'true' run: | npm install --global npm@11.8.0 npm config set registry https://registry.npmjs.org/ npm --version - name: Publish npm package + if: steps.publication.outputs.complete != 'true' && steps.publication.outputs.npm_exists != 'true' env: NPM_TAG: ${{ steps.release.outputs.npm_tag }} TARBALL: ${{ steps.pack.outputs.tarball }} - run: | - package_name="$(node -p 'require("./package.json").name')" - if published="$(npm view "$package_name@$VERSION" version 2>/dev/null)" && [[ "$published" == "$VERSION" ]]; then - echo "$package_name@$VERSION is already published; skipping npm publish." - else - npm publish "$TARBALL" --access public --tag "$NPM_TAG" --provenance - fi + run: npm publish "$TARBALL" --access public --tag "$NPM_TAG" --provenance - name: Publish GitHub release + if: steps.publication.outputs.complete != 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.release.outputs.tag }} diff --git a/docs/development.md b/docs/development.md index 3a977771..5e594b4d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -140,5 +140,12 @@ Actions trusted publisher using: The workflow uses a GitHub-hosted runner, requests `id-token: write`, and pins an npm CLI new enough for trusted publishing. Its package artifact is also attached to a draft GitHub release before npm publication; the GitHub release is made -public only after npm succeeds. Re-running the same version reuses the matching -release/tag and skips npm publication when that version already exists. +public only after npm succeeds. + +Re-running the same version is safe only when it still identifies the same +artifact. Existing npm versions must have the same package integrity as the +newly packed tarball. Existing draft GitHub releases may be resumed, but public +releases are never modified: the workflow verifies both npm integrity and the +published GitHub tarball and exits successfully only when they already match. +Any partial or mismatched public release fails for manual investigation instead +of rewriting published state. From 7d656a324b22e4bc263afc86bf98f4784db61c5e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:41:25 +0530 Subject: [PATCH 5/7] ci(release): pin privileged checkout action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a99bde4f..b32e5345 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Checkout release source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 persist-credentials: false From 03ac75123883ceaaec22fa31b1a744867f094bcf Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:41:37 +0530 Subject: [PATCH 6/7] ci(release): assert trusted publishing runtime --- .github/workflows/release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b32e5345..a3b2b8ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,7 +223,15 @@ jobs: run: | npm install --global npm@11.8.0 npm config set registry https://registry.npmjs.org/ - npm --version + node_version="$(node -p 'process.versions.node')" + npm_version="$(npm --version)" + echo "Node.js $node_version" + echo "npm $npm_version" + node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (major < 22 || (major === 22 && minor < 14)) process.exit(1)' + if [[ "$npm_version" != "11.8.0" ]]; then + echo "Expected npm 11.8.0 for trusted publishing; resolved npm $npm_version." >&2 + exit 1 + fi - name: Publish npm package if: steps.publication.outputs.complete != 'true' && steps.publication.outputs.npm_exists != 'true' From 8ec03e2b0910fd7a80664101c909ace5ccb431be Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:49:25 +0530 Subject: [PATCH 7/7] fix(release): verify existing npm channel --- .github/workflows/release.yml | 6 ++++++ docs/development.md | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3b2b8ee..93a62961 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,6 +141,7 @@ jobs: FILENAME: ${{ steps.pack.outputs.filename }} LOCAL_INTEGRITY: ${{ steps.pack.outputs.integrity }} IS_PRERELEASE: ${{ steps.release.outputs.prerelease }} + NPM_TAG: ${{ steps.release.outputs.npm_tag }} run: | package_name="$(node -p 'require("./package.json").name')" npm_exists=false @@ -149,6 +150,11 @@ jobs: echo "$package_name@$VERSION already exists on npm with different integrity." >&2 exit 1 fi + published_channel="$(npm view "$package_name" "dist-tags.$NPM_TAG" 2>/dev/null || true)" + if [[ "$published_channel" != "$VERSION" ]]; then + echo "$package_name@$VERSION exists on npm but @$NPM_TAG points to ${published_channel:-nothing}." >&2 + exit 1 + fi npm_exists=true fi diff --git a/docs/development.md b/docs/development.md index 5e594b4d..c43311d8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,9 +143,10 @@ to a draft GitHub release before npm publication; the GitHub release is made public only after npm succeeds. Re-running the same version is safe only when it still identifies the same -artifact. Existing npm versions must have the same package integrity as the -newly packed tarball. Existing draft GitHub releases may be resumed, but public -releases are never modified: the workflow verifies both npm integrity and the +artifact and release channel. Existing npm versions must have the same package +integrity as the newly packed tarball and already be assigned to the requested +`beta` or `latest` dist-tag. Existing draft GitHub releases may be resumed, but +public releases are never modified: the workflow verifies npm state and the published GitHub tarball and exits successfully only when they already match. Any partial or mismatched public release fails for manual investigation instead of rewriting published state.