Skip to content
Open
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
260 changes: 260 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
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@11d5960a326750d5838078e36cf38b85af677262 # 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"
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 }}
NPM_TAG: ${{ steps.release.outputs.npm_tag }}
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
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

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 [[ "$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
args+=(--prerelease)
fi
gh "${args[@]}"
fi

- name: Upload release artifact
if: steps.publication.outputs.complete != 'true'
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
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/
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'
env:
NPM_TAG: ${{ steps.release.outputs.npm_tag }}
TARBALL: ${{ steps.pack.outputs.tarball }}
run: npm publish "$TARBALL" --access public --tag "$NPM_TAG" --provenance
Comment thread
Waishnav marked this conversation as resolved.

- name: Publish GitHub release
if: steps.publication.outputs.complete != 'true'
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
55 changes: 55 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,58 @@ 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 is safe only when it still identifies the same
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.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading